Five days ago, North Korean state hackers hijacked one of the most trusted packages in the JavaScript ecosystem, axios, with 100 million weekly downloads, and turned it into a Remote Access Trojan delivery system.
The attack was live on npm for three hours. npm audit flagged nothing.
If you ran npm install during that window, your machine may have been silently backdoored. Here's exactly how the attack worked, why traditional tools missed it, and how behavioral analysis would have caught it before a single byte of malicious code executed.
The attack, minute by minute
The timeline shows a methodical, multi-stage operation:
| Time (UTC) | Event |
|---|---|
| Mar 30, 05:57 |
plain-crypto-js@4.2.0 published, a clean decoy to establish publishing history |
| Mar 30, 23:59 |
plain-crypto-js@4.2.1 published, now with a malicious postinstall hook |
| Mar 31, 00:21 |
axios@1.14.1 published, adds plain-crypto-js as a dependency |
| Mar 31, 01:00 |
axios@0.30.4 published, targeting legacy users still on 0.x |
| Mar 31, ~03:15 | npm yanks both malicious axios versions (~3 hours live) |
The attacker compromised the npm account of axios's lead maintainer (jasonsaayman), changed the account email to ifstap@proton.me, and published two surgical updates. No axios source code was modified. The only change was one line added to package.json:
"dependencies": {
"plain-crypto-js": "^4.2.1"
}
That's it. One dependency. One line. That's all it takes.
What the payload did
When npm install resolved the new dependency, plain-crypto-js ran a postinstall hook that executed setup.js, a 4.2 KB dropper with two-layer obfuscation (reversed Base64 + XOR cipher with key "OrDeR_7077").
The decoded dropper:
- Detected your OS (macOS, Windows, Linux)
-
Downloaded a platform-specific RAT from
sfrclak[.]com:8000 -
Deleted all evidence, removed
setup.js, swapped the maliciouspackage.jsonfor a clean one reporting version4.2.0
The whole process took 1.1 seconds from npm install to C2 callback.
Platform payloads
macOS: Binary dropped to /Library/Caches/com.apple.act.mond (disguised as an Apple system daemon), executed via /bin/zsh.
Windows: PowerShell copied to %PROGRAMDATA%\wt.exe (disguised as Windows Terminal), VBScript + PowerShell dropper chain.
Linux: Python RAT downloaded to /tmp/ld.py, detached to PID 1 with nohup.
The RAT capabilities included system fingerprinting, 60-second C2 beacon, binary injection (peinject), and remote shell execution. Any machine that ran the install was considered fully compromised.
Why npm audit said nothing
This is the part that matters for every JavaScript developer.
npm audit checks your dependency tree against the GitHub Advisory Database, a list of known, reported vulnerabilities. It's reactive by design. It can only warn you about threats that have already been discovered, analyzed, and filed as advisories.
During the three hours that axios@1.14.1 was live:
- ❌ npm audit, silent. No advisory existed yet.
- ❌
npm outdated, it looked like a normal version bump. - ❌ Lockfile checks, a lockfile only prevents this if you never update.
- ❌ Code review, no axios source was changed. The attack hid in a transitive dependency.
The attack was specifically designed to be invisible to these tools.
What behavioral analysis catches
The attack had multiple behavioral red flags that don't require a vulnerability database. They're structural, detectable by analyzing what the code does, not checking if someone filed a report about it.
I built aegis-scan to detect exactly these patterns. Here's how each analyzer would have responded to this attack:
1. Install Script Analyzer → 🚨 CRITICAL
plain-crypto-js used a postinstall hook to execute node setup.js. aegis-scan flags any lifecycle script that executes a .js file, and escalates to CRITICAL when that file contains network calls or encoded strings.
⛔ CRITICAL, Suspicious Install Script
│ postinstall executes JavaScript file: "node setup.js"
│ 📄 node_modules/plain-crypto-js/package.json
2. Obfuscation Analyzer → 🚨 CRITICAL
setup.js was XOR-encoded with reversed Base64. aegis-scan's entropy analysis would flag the high-entropy strings, and the base64/hex pattern detector would catch the encoded payloads, even with the custom encoding scheme.
⛔ CRITICAL, Obfuscated Code
│ High entropy string detected (Shannon entropy > 5.5)
│ 📄 node_modules/plain-crypto-js/setup.js:12
3. Static Code Analyzer → 🚨 HIGH
The decoded payload uses child_process.exec() to launch shell commands, fs.unlink() for self-deletion, and hardcoded IP addresses for C2 communication, all patterns aegis-scan detects via regex and AST analysis.
⚠️ HIGH, Code Execution
│ child_process exec with dynamic argument
│ 📄 node_modules/plain-crypto-js/setup.js
4. Maintainer Analyzer → ⚠️ HIGH
The compromised maintainer account had its email changed to a Proton Mail address (ifstap@proton.me) shortly before publishing. aegis-scan flags email domain changes on established packages as a maintainer takeover signal.
⚠️ HIGH, Maintainer Change
│ Primary maintainer email domain changed before release
│ 📄 axios@1.14.1 metadata
5. Dependency Tree Analyzer → ⚠️ MEDIUM
plain-crypto-js was a brand-new dependency with no download history, added to a package that hadn't changed its dependency list in months. aegis-scan's dep tree analyzer flags new, unestablished transitive dependencies, especially those with install scripts.
⚠️ MEDIUM, Suspicious Dependency
│ New dependency "plain-crypto-js" has install scripts and minimal history
The combined score
Each finding individually would raise the risk score. Together, they'd push the package well past the danger threshold:
$ aegis-scan check axios@1.14.1
📦 axios@1.14.1
⛔ CRITICAL, Install Script: postinstall executes "node setup.js"
⛔ CRITICAL, Obfuscation: high entropy encoded payload in setup.js
⚠️ HIGH, Code Execution: child_process.exec with shell command
⚠️ HIGH, Maintainer Change: email domain changed before release
⚠️ MEDIUM, Suspicious Dependency: new dep with install scripts
Risk: 9.2/10, DO NOT INSTALL
Five analyzers. Five independent red flags. No advisory database required.
The uncomfortable truth
The axios attack wasn't sophisticated. It used a postinstall hook, the oldest trick in the npm playbook. The obfuscation was basic XOR. The C2 was a raw HTTP server on port 8000.
And it still worked, because the ecosystem's primary defense (npm audit) is fundamentally reactive.
Here's what this means for your workflow:
npm audit is not a security tool. It's an advisory lookup. It tells you about problems that other people already found. It will never protect you from a zero-day supply chain attack.
The attack surface is structural:
- npm runs arbitrary code at install time via lifecycle scripts
- Dependency resolution is transitive and opaque
- Most developers never audit new transitive dependencies
- The gap between "malicious code published" and "advisory filed" is hours to days
Closing that gap requires analyzing behavior, not checking databases.
What you can do today
Right now:
- Check if you installed axios between Mar 30 23:00 UTC and Mar 31 03:15 UTC
- Run
ls node_modules/plain-crypto-js, if that directory exists, you were hit - Rotate every credential on the affected machine
Going forward:
# Install aegis-scan
cargo install aegis-scan
# Scan your project
aegis-scan scan .
# Or gate your installs
aegis-scan install express lodash axios
The install command scans first, installs only if the risk score is below the threshold. You can also run it in CI:
- uses: z8run/aegis-action@v1
with:
path: '.'
fail-on: 'high'
sarif: 'true'
Disable install scripts for untrusted packages:
# npm 11.10+
npm config set install-strategy=hoisted
npm install --ignore-scripts
# Then selectively allow trusted packages
npx --yes allow-scripts
It's open source
aegis-scan is MIT-licensed: github.com/z8run/aegis. It runs locally, offline, with no accounts or API keys.
The axios attack was a wake-up call, but it won't be the last. The next one might not be caught in three hours. It might not be caught for weeks. The only defense that works against unknown threats is analyzing what code actually does, before it runs on your machine.
This is a follow-up to my previous post "I Built an npm Malware Scanner in Rust Because npm audit Isn't Enough". If you have questions or want to contribute, open an issue on GitHub.
Top comments (0)