On March 31, 2026, an attacker compromised the npm account of axios’s primary maintainer and published two malicious versions (axios@1.14.1 and axios@0.30.4), each containing a single new dependency: plain-crypto-js. With more than 100 million weekly downloads, the blast radius was enormous. npm pulled both versions within approximately three hours, but the damage window was real. We pulled the malicious package and took it apart. Here’s what we found.
The Phantom Dependency
A binary diff between
axios@1.14.0andaxios@1.14.1reveals exactly one file changed:package.json. All 85 library source files are bit-for-bit identical. The only modification was a single line added todependencies:"plain-crypto-js": "^4.2.1"Here’s what makes this interesting:
plain-crypto-jsis neverrequire()‘d anywhere in axios. Not in a source file, and not in a test. It exists solely for itspostinstallhook, a script that runs automatically duringnpm install. The remaining 55 source files in the package are exact copies of legitimatecrypto-js@4.2.0. Only three files were attacker-created:setup.js,package.json, and a file calledpackage.md. More on that last one shortly.Cracking Open setup.js
The entire dropper lives in a single 4KB file. Strings are obfuscated with a two-layer scheme. First, each encoded string is reversed, underscore characters are swapped for
=padding, and the result is base64-decoded:_trans_2 = function(x, r) { let E = x.split("").reverse().join("").replaceAll("_", "="); let S = Buffer.from(E, "base64").toString("utf8"); return _trans_1(S, r); }The decoded output is then passed through a character-by-character XOR cipher using the key
OrDeR_7077:_trans_1 = function(x, r) { const E = r.split("").map(Number); return x.split("").map((x, r) => { const S = x.charCodeAt(0); const a = E[7 * r * r % 10]; return String.fromCharCode(S ^ a ^ 333); }).join(""); }Each character is XORed with a key digit selected by the formula
key[(7 * r * r) % 10], then XORed again with333. Non-numeric characters in the key (O,r,D,e,R,_) produceNaN, which JavaScript’s bitwise XOR coerces to0. In practice, only the digits7,0,7,7contribute to the cipher. It’s not sophisticated, but it defeats static string scanning.After deobfuscation, the code checks
os.platform()and branches into three platform-specific droppers. Each downloads a payload fromhxxp://sfrclak[.]com:8000/6202033viacurl. The entire function is wrapped intry/catch{}with an empty handler. Errors are silently swallowed, andnpm installalways exits cleanly.Three Payloads, One Goal
Platform Dropper Method Payload Location curl -dBody (OS identifier)macOS AppleScript via osascript/Library/Caches/com.apple.act.mondpackages.npm.org/product0Windows VBScript + PowerShell copied as wt.exe%TEMP%\6202033.ps1packages.npm.org/product1Linux Python script via nohup/tmp/ld.pypackages.npm.org/product2The macOS binary masquerades as an Apple system daemon. On Windows,
powershell.exeis copied to%PROGRAMDATA%\wt.exe(disguised as Windows Terminal) and a VBScript dropper chain fetches and executes a PowerShell RAT with-WindowStyle Hidden -ExecutionPolicy Bypass. On Linux, a Python RAT is dropped to/tmpand launched as an orphaned background process. All payloads fire within seconds ofnpm install, before dependency resolution even completes.The Cleanup
This is the part that caught our attention. After executing the platform-specific payload,
setup.jsperforms three cleanup operations:// 1. Delete itself t.unlink(__filename, (x => {})); // 2. Delete package.json (contains the postinstall hook) t.unlink("package.json", (x => {})); // 3. Rename package.md to package.json t.rename("package.md", "package.json", (x => {}));That third step is the key.
package.mdis a pre-staged clean copy ofpackage.jsonwith identical metadata, but reporting version4.2.0instead of4.2.1and with thescriptssection removed entirely. Nopostinstallhook. No trace ofsetup.js.Post-infection, a responder inspecting
node_modules/plain-crypto-jswould find a clean-lookingpackage.jsonat version4.2.0with no evidence of any install script. The only residual clue is a version mismatch: the lockfile says4.2.1, but the installedpackage.jsonsays4.2.0. This is deliberate anti-forensics. The attacker anticipated incident response.Affected Packages
The compromise extended beyond axios itself. Our research identified additional packages that distributed the same
plain-crypto-jspayload through vendored dependencies.
Package Affected Versions Notes axios1.14.1,0.30.4Injected plain-crypto-jsas a dependencyplain-crypto-js4.2.1Malicious dropper; 4.2.0was a clean decoy@shadanai/openclaw2026.3.28-2,2026.3.28-3,2026.3.31-1,2026.3.31-2Vendors plain-crypto-jspayload directly indist/@qqbrowser/openclaw-qbot0.0.130Ships tampered axios@1.14.1in itsnode_modules/@depup/axios1.14.1-depup.0Automated mirror of axios@1.14.1, republished 17 minutes after the compromised version withplain-crypto-jscarried overIndicators of Compromise
Type Value C2 Domain sfrclak[.]comC2 IP 142.11.206[.]73C2 URL hxxp://sfrclak[.]com:8000/6202033macOS Artifact /Library/Caches/com.apple.act.mondWindows Artifact %PROGRAMDATA%\wt.exeLinux Artifact /tmp/ld.pyIf you find any of these artifacts on your systems, treat it as a full compromise. Isolate the host, rotate all credentials accessible at install time, and rebuild from a known-good state. Projects using committed lockfiles with
npm ciwere protected.Stay Ahead of Advanced Threats
Veracode has taken immediate action to protect customers from these emerging threats, and we continue to monitor the situation. Our Software Composition Analysis (SCA) capabilities can detect the use of these malicious packages, while our Package Firewall provides an additional layer of protection by preventing them from entering your environment.