/briefings
Case StudyMarch 31, 202610 min read

The axios npm Supply Chain Attack: What Happened, How to Detect Exposure, and How to Remediate

Jerrid Brown·OTPulse

On March 31, 2026, axios was compromised in a targeted supply chain attack. Attackers deployed a cross-platform RAT on developer machines, then erased all evidence before anyone noticed. Here is the full breakdown.


OTPulse covers OT and ICS security, but supply chain attacks are increasingly how threat actors get a foothold before pivoting into operational environments. This one was particularly well-constructed, so I wanted to dig into exactly how it worked.

On March 30, 2026, two versions of axios were quietly published to npm. They looked routine. They passed a glance. And within 89 seconds of the package going live, the first developer machine was already infected.

axios is one of the most depended-upon JavaScript packages in existence, pulling down over 100 million downloads per week. On March 30, attackers compromised the account of the project's lead maintainer and used it to publish poisoned releases across both the 1.x and 0.x version lines. Those two releases contained no malicious code in axios itself. The weapon was hidden one layer deeper.

Affected versions: axios@1.14.1 and axios@0.30.4. Both have been removed from npm. Safe versions are axios@1.14.0 and axios@0.30.3.

// HOW THE ATTACK WAS CONSTRUCTED

How the Attack Was Constructed

Step 1 - The maintainer account was taken over

The attacker obtained a long-lived classic npm access token for jasonsaayman, the lead maintainer of the axios project. They changed the account's registered email to ifstap@proton.me, an attacker-controlled address, then used the stolen credentials to publish malicious releases under the maintainer's name.

A critical detail in the npm registry metadata reveals that something was wrong. Every legitimate axios 1.x release is published through GitHub Actions using npm's OIDC Trusted Publisher system, meaning each release is cryptographically tied to a verified workflow run. The compromised 1.14.1 release breaks that pattern entirely. It was published manually via the stolen token, with no OIDC binding and no corresponding commit or tag anywhere in the axios GitHub repository. The release existed only on npm.

Step 2 - A fake dependency was staged in advance

Before publishing the poisoned axios versions, the attacker created a second npm account and used it to publish a package called plain-crypto-js. This was a near-perfect copy of the legitimate crypto-js library, down to the same description, the same author name, and the same 56 cryptographic source files, copied bit-for-bit.

To avoid triggering "new account" flags on security scanners, the attacker first published a clean decoy version 18 hours before the attack. This gave the account a publishing history. Then, hours later, a second version was published. The only differences were the addition of a postinstall script hook, a new file called setup.js containing the dropper, and a pre-staged clean manifest saved under the name package.md whose purpose would become clear later.

Step 3 - The poisoned axios versions were injected

Across all 86 files in axios@1.14.1, exactly one file changed from the prior clean release: package.json. The entire change was the addition of plain-crypto-js as a runtime dependency. Nothing else was touched. The same surgical change was made to the 0.x branch, published 39 minutes after the 1.x release.

VersionDependencies
axios@1.14.0 (safe)follow-redirects, form-data, proxy-from-env
axios@1.14.1 (malicious)follow-redirects, form-data, proxy-from-env, plain-crypto-js

When a developer ran npm install, npm resolved the dependency tree, installed plain-crypto-js automatically, and executed its postinstall hook. That hook ran setup.js. At no point is plain-crypto-js actually imported anywhere inside the axios source code. A dependency that appears in the manifest but has zero use in the codebase is a strong indicator of compromise.

Step 4 - The dropper ran and a RAT was deployed

setup.js is a compact, obfuscated dropper. All sensitive strings were encoded using XOR with a hardcoded key and base64 encoding. The dropper decoded these at runtime, identified the operating system, and executed a platform-specific payload chain.

On Linux, it used curl to download a Python script from the command-and-control server and saved it to /tmp/ld.py, then launched it as a detached background process. On macOS, it downloaded a binary to /Library/Caches/com.apple.act.mond (disguised as an Apple system cache file) and launched it silently. On Windows, it placed a payload at %PROGRAMDATA%\wt.exe (disguised as Windows Terminal), ran it in a hidden window via VBScript, then established persistence by writing a download cradle to %PROGRAMDATA%\system.bat and adding a registry Run key at HKCU:\Software\Microsoft\Windows\CurrentVersion\Run\MicrosoftUpdate. On every subsequent login, that key re-fetches and re-runs the RAT from C2.

Across all three platforms, C2 contact happened within about 2 seconds of npm install starting, before npm had even finished.

Step 5 - The evidence was destroyed

After launching the background payload, setup.js cleaned up in three steps: it deleted itself, deleted the malicious package.json, and renamed the pre-staged stub to package.json. That stub reported version 4.2.0 rather than 4.2.1. A developer who ran npm list after infection would see the wrong version number, potentially leading them to conclude their machine was clean.

The entire dropper was also wrapped in a try/catch block that swallowed all errors silently. If the C2 was unreachable or any step failed, npm would exit with code 0, no output, and no indication anything went wrong.

// ATTACK TIMELINE

Attack Timeline

2026-03-30 · 05:57 UTC
Decoy package published

plain-crypto-js@4.2.0 published from nrwise@proton.me. Clean contents, no malicious hook. Establishes publishing history to avoid scanner alerts.

2026-03-30 · 23:59 UTC
Malicious payload staged

plain-crypto-js@4.2.1 published. Adds setup.js dropper and postinstall hook.

2026-03-31 · 00:21 UTC
axios@1.14.1 published

Compromised maintainer account publishes poisoned 1.x release. No corresponding GitHub commit exists.

2026-03-31 · 00:22:29 UTC
First confirmed infection

89 seconds after publication, the first developer machine contacts the C2 server. The RAT is running before most users have even seen the release.

2026-03-31 · 01:00 UTC
axios@0.30.4 published

Same injection into the legacy 0.x branch, 39 minutes after the 1.x release. Both release lines now compromised.

2026-03-31 · ~03:15 UTC
npm removes both versions

Both poisoned releases unpublished. latest dist-tag reverts to 1.14.0. 1.14.1 was live for roughly 2 hours 53 minutes. At least 135 endpoints across all three platforms had contacted C2 by this point.

2026-03-31 · 04:26 UTC
Security hold placed on plain-crypto-js

npm publishes a security-holder stub, replacing the malicious package. Attempting to install any version of plain-crypto-js now returns a security notice.

// INDICATORS OF COMPROMISE

Indicators of Compromise

IndicatorValue
C2 Domainsfrclak.com
C2 IP142.11.206.73
C2 URLhttp://sfrclak.com:8000/6202033
Malicious packageplain-crypto-js@4.2.1
XOR obfuscation keyOrDeR_7077
macOS RAT path/Library/Caches/com.apple.act.mond
Linux RAT path/tmp/ld.py
Windows RAT path%PROGRAMDATA%\wt.exe
Windows persistence script%PROGRAMDATA%\system.bat
Windows registry Run keyHKCU:\Software\Microsoft\Windows\CurrentVersion\Run\MicrosoftUpdate
Compromised npm accountjasonsaayman
Attacker emailifstap@proton.me

// HOW TO CHECK IF YOU'RE AFFECTED

How to Check If You're Affected

Work through these in order. If any come back positive, skip to remediation and treat the machine as fully compromised.

1. Check your installed axios version

bash
npm list axios
npm list -g axios

If the output shows axios@1.14.1 or axios@0.30.4, you installed a compromised version.

2. Check your lockfile history

bash
git log -p -- package-lock.json | grep "plain-crypto-js"

Any result here means the malicious package was installed. Legitimate axios has exactly three dependencies: follow-redirects, form-data, and proxy-from-env.

3. Check for the plain-crypto-js directory

Even after the dropper replaced package.json with a clean stub, the directory itself remains. Its presence is sufficient evidence the dropper ran, regardless of what version the manifest reports.

bash
# Mac/Linux
ls node_modules/plain-crypto-js 2>/dev/null && echo "DROPPER RAN"
powershell
# Windows
Test-Path "node_modules\plain-crypto-js"

4. Check for RAT artifacts

bash
# macOS
ls -la /Library/Caches/com.apple.act.mond 2>/dev/null && echo "COMPROMISED"

# Linux
ls -la /tmp/ld.py 2>/dev/null && echo "COMPROMISED"
powershell
# Windows - check all three artifacts
Test-Path "$env:PROGRAMDATA\wt.exe"
Test-Path "$env:PROGRAMDATA\system.bat"
Get-ItemProperty "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" -Name "MicrosoftUpdate" -ErrorAction SilentlyContinue

5. Check for active C2 connections

bash
netstat -an | grep "142.11.206.73"

6. Scan your entire system

bash
# Mac/Linux: find all axios installs and report their versions
find / -path "*/node_modules/axios/package.json" 2>/dev/null | while read f; do
  version=$(grep '"version"' "$f" | head -1)
  echo "$f -> $version"
done

The version number trick: After infection, npm list may report plain-crypto-js@4.2.0 because the dropper swapped in a stub that reports the wrong version. Do not trust the version number. Trust the presence or absence of the directory.

// REMEDIATION

Remediation

Do not try to clean in place. If you found any indicators of compromise, treat the machine as fully compromised. The RAT had full network access from the moment it ran. You do not know what it sent or where it persisted. The only safe state is a clean rebuild.

  1. Stop what you're doing. Do not continue development on the affected machine until credential rotation is complete.
  2. Rotate all credentials. npm tokens, SSH keys, API keys, cloud credentials, database passwords, CI/CD secrets, and anything in .env files accessible at the time of infection.
  3. Downgrade axios. Run npm install axios@1.14.0 (or axios@0.30.3 for 0.x users).
  4. Remove the plain-crypto-js directory. Run rm -rf node_modules/plain-crypto-js, then reinstall with npm install --ignore-scripts.
  5. Block C2 traffic. Add sfrclak.com and 142.11.206.73 to your firewall blocklist.
  6. Windows only - remove persistence. Delete %PROGRAMDATA%\system.bat and %PROGRAMDATA%\wt.exe, then remove the MicrosoftUpdate value from HKCU:\Software\Microsoft\Windows\CurrentVersion\Run. Removing the npm package alone is not sufficient on Windows. The registry Run key will re-fetch the RAT on every login until removed.
  7. Audit CI/CD pipelines. Review all pipeline logs for any npm install runs that may have pulled either malicious version. Rotate any secrets those pipelines had access to.
  8. Rebuild from a clean image if any RAT artifacts were found.

// HOW TO PROTECT YOURSELF GOING FORWARD

How to Protect Yourself Going Forward

Disable postinstall scripts. The entire dropper ran via a postinstall hook. This single change would have blocked the attack entirely.

bash
npm config set ignore-scripts true

When you need a package that genuinely requires scripts (like sharp or bcrypt), override per-install:

bash
npm install <package> --ignore-scripts=false

Pin exact versions. The caret in "axios": "^1.14.0" is what allowed npm to auto-upgrade to 1.14.1.

bash
# .npmrc
save-exact=true

Use npm ci in CI/CD. Installs exactly what is in your lockfile, no upgrades.

bash
npm ci --ignore-scripts

Consider pnpm or bun. Both do not run lifecycle scripts by default. This attack would have failed entirely on either without any extra configuration.

// WHAT MADE THIS ATTACK DIFFERENT

What Made This Attack Different

Supply chain attacks via npm are not new. What makes this one stand out is the operational precision. The malicious dependency was staged 18 hours before the poisoned axios releases, giving it enough publishing history to avoid automated scanner flags. Platform-specific payloads were pre-built for three operating systems. Both release branches were poisoned within 39 minutes of each other. Every artifact was designed to self-destruct.

The decision to put zero malicious code inside axios itself was intentional. A developer comparing the release against the previous version would find nothing wrong. The attack was hidden inside a dependency that was never imported anywhere in the codebase.

The speed of infection is striking. The first confirmed endpoint contacted C2 just 89 seconds after axios@1.14.1 appeared on npm. By the time the package was pulled roughly 2 hours and 53 minutes later, at least 135 machines across all three platforms had called home to the attacker's server.

One detail worth noting: the campaign ID embedded in the C2 URL (6202033) reverses to 3302026, or 3-30-2026, the date the attack began. Whether deliberate or coincidence, it is the kind of detail that suggests the attacker was not in a hurry.

If you did not run npm install during that window pulling either of the two affected versions, you were not exposed.

Get OT security insights every Tuesday

Advisory breakdowns, a weekly summary, and incident analyses for the people actually defending OT environments. Free, no account required.

Built for the people who protect operational technology. hello@otpulse.io