Breaking Down the Axios Supply Chain Attack

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.0 and axios@1.14.1 reveals exactly one file changed: package.json. All 85 library source files are bit-for-bit identical. The only modification was a single line added to dependencies:

"plain-crypto-js": "^4.2.1"

Here’s what makes this interesting: plain-crypto-js is never require()‘d anywhere in axios. Not in a source file, and not in a test. It exists solely for its postinstall hook, a script that runs automatically during npm install. The remaining 55 source files in the package are exact copies of legitimate crypto-js@4.2.0. Only three files were attacker-created: setup.jspackage.json, and a file called package.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 with 333. Non-numeric characters in the key (OrDeR_) produce NaN, which JavaScript’s bitwise XOR coerces to 0. In practice, only the digits 7077 contribute 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 from hxxp://sfrclak[.]com:8000/6202033 via curl. The entire function is wrapped in try/catch{} with an empty handler. Errors are silently swallowed, and npm install always exits cleanly.

Three Payloads, One Goal

PlatformDropper MethodPayload Locationcurl -d Body (OS identifier)
macOSAppleScript via osascript/Library/Caches/com.apple.act.mondpackages.npm.org/product0
WindowsVBScript + PowerShell copied as wt.exe%TEMP%\6202033.ps1packages.npm.org/product1
LinuxPython script via nohup/tmp/ld.pypackages.npm.org/product2

The macOS binary masquerades as an Apple system daemon. On Windows, powershell.exe is 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 /tmp and launched as an orphaned background process. All payloads fire within seconds of npm install, before dependency resolution even completes.

The Cleanup

This is the part that caught our attention. After executing the platform-specific payload, setup.js performs 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.md is a pre-staged clean copy of package.json with identical metadata, but reporting version 4.2.0 instead of 4.2.1 and with the scripts section removed entirely. No postinstall hook. No trace of setup.js.

Post-infection, a responder inspecting node_modules/plain-crypto-js would find a clean-looking package.json at version 4.2.0 with no evidence of any install script. The only residual clue is a version mismatch: the lockfile says 4.2.1, but the installed package.json says 4.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-js payload through vendored dependencies.

PackageAffected VersionsNotes
axios1.14.10.30.4Injected plain-crypto-js as a dependency
plain-crypto-js4.2.1Malicious dropper; 4.2.0 was a clean decoy
@shadanai/openclaw2026.3.28-22026.3.28-32026.3.31-12026.3.31-2Vendors plain-crypto-js payload directly in dist/
@qqbrowser/openclaw-qbot0.0.130Ships tampered axios@1.14.1 in its node_modules/
@depup/axios1.14.1-depup.0Automated mirror of axios@1.14.1, republished 17 minutes after the compromised version with plain-crypto-js carried over

Indicators of Compromise

TypeValue
C2 Domainsfrclak[.]com
C2 IP142.11.206[.]73
C2 URLhxxp://sfrclak[.]com:8000/6202033
macOS Artifact/Library/Caches/com.apple.act.mond
Windows Artifact%PROGRAMDATA%\wt.exe
Linux Artifact/tmp/ld.py

If 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 ci were 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.

Get in touch to learn more.