Import of No Return - Post Mortem
How a lone line of code weaponized npm’s most-installed HTTP library into a malware delivery chain and how to take it apart
A trusted tool that attacked the moment it is installed
On March 31, 2026, attackers published two malicious versions of axios to the npm registry, where the JavaScript library is downloaded more than 100 million times each week. Any project that pulled one of those versions could be compromised without a user opening a file, clicking a link, or manually running anything. The payload executed automatically during installation, affecting Windows, macOS, and Linux systems alike.
The malicious releases remained available on the npm registry for only about three hours before they were removed, but the window was more than enough. The first confirmed infected host checked in just 89 seconds after publication. What follows is a reconstruction of the attack from publication to execution, written to be useful both to readers encountering supply-chain malware for the first time and to practitioners who want the technical detail. Terms are explained where they appear, and where the code is central to the story, the deobfuscated logic is shown directly.
Why supply chain is scary
Modern software is assembled, not written. A single app may pull in hundreds of small open-source packages, and each of those pulls in more. You vet the package you chose — but you rarely vet the packages it depends on. This is what makes supply chain poisoning so horrid.
The attackers never altered a single line of axios's actual source. They made one surgical change to its manifest - the small file that lists what axios depends on -adding a brand-new package called plain-crypto-js. That package is the weapon. Axios was just the trusted envelope that carried it to millions of machines.
The compromise path was put in place before the first installation triggered execution.
This was not opportunistic. The operators staged the attack in stages, hours apart, specifically to slip past the automated scanners that watch npm for new threats.
Eighteen hours before detonation, they published a clean version of plain-crypto-js - a near-verbatim copy of a real library, with no malicious code. Its only job was to age: to give the package a publishing history so that when the armed version appeared, scanners would not flag it as a brand-new, zero-history package. Then they swapped in the weaponized version and, minutes later, used a hijacked maintainer account to push the two poisoned axios releases.
^1.14.0). A common way projects request dependencies: "give me 1.14.0 or any newer 1.x." Convenient — until "newer" means a version published by an attacker. Every project using a caret range pulled the poisoned build on its next install, automatically.From install to running malware
Here's the mechanism that makes install-time attacks possible.
node setup.js, with no prompt and no user action.Running setup.js - the first-stage dropper — checks which operating system it landed on, reaches out to the attacker's server to download the right second-stage payload for that platform, runs it, and then cleans up after itself.
That last step matters for us folk: after running, the dropper deletes itself and renames a clean stand-in over the manifest, so a developer inspecting the installed package afterward sees nothing wrong. The one tell it cannot erase is the plain-crypto-js folder sitting in node_modules - presence alone is proof it ran.
Unwrapping the obfuscation
The dropper's strings - module names, the server address, command templates -are scrambled so that a quick glance sees nothing readable. The descrambling is done by two small functions the malware carries with it. Reading those functions is the deobfuscation: run the malware's own logic and the secrets fall out.
For the reversing : reimplementing those two routines and feeding the string table back through them yields the dropper's intent directly - no guessing.
# the sample's own decoder, reimplemented — run it on the string table stq[0] → "child_process" # modules it needs stq[1] → "os" stq[2] → "fs" stq[3] → "http://sfrclak.com:8000/" # C2 base address stq[5] → "win32" stq[6] → "darwin" # else → linux # entry call supplies the campaign tag: _entry("6202033") # assembled C2 path → http://sfrclak.com:8000/6202033
One detail worth lingering on, because it's a recurring theme in this actor's work: the Windows branch doesn't ship its own copy of PowerShell. It locates the system's real powershell.exe, copies it to a new name (wt.exe) to dodge detection rules keyed on the original filename, and disguises its download traffic with a decoy parameter (packages.npm.org/product1) so a glance at network logs reads like ordinary package activity. Quiet, native, and cheap to run.
The Linux payload, line by line
On Linux, the dropper fetches a Python script to /tmp/ld.py and launches it detached, handing it the C2 address as its only argument. Unlike the dropper, this stage isn't obfuscated at all - it reads like ordinary, slightly rushed application code, which makes it an unusually clear window into what the operators actually wanted.
It is a straightforward remote-access trojan with no persistence - it survives only until the machine reboots. On launch it sends a first-contact report: a listing of the user's home, config, Documents, and Desktop folders. Then it settles into a loop, beaconing to the server every 60 seconds with a full fingerprint of the host and waiting for orders.
# ld.py — the beacon identity (paraphrased structure) hostname, username, OS + kernel, timezone, install date, boot time, hardware vendor/model, full running-process list → JSON → base64 → HTTP POST every 60s # the giveaway in every request: User-Agent: "mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)" # a fake Internet Explorer 8 / Windows XP — on a Linux host
The server can reply to any beacon with one of four commands: kill (stop), rundir (list any folder), runscript (run an arbitrary shell or Python command), and peinject (download and execute a further binary). That fourth one is the interesting part - because in the Linux build, it doesn't work.
A bug that capped the blast radius
The function that handles peinject takes its payload in a parameter called ijtbin - and then tries to decode a variable called b64_string that is never defined anywhere in the script. The moment the operator sends that command, the script throws an error and returns a failure status. The headline capability — pushing a fresh binary onto the victim at will — is dead on arrival on Linux. The operator can still run commands through runscript, but the dedicated implant-delivery path simply fails.
# the defect, faithfully: def do_action_ijt(ijtbin, param): payload = base64.b64decode(b64_string) # ← undefined name → crash # ijtbin (the real payload) is never used
This is not a footnote. It's the kind of finding that only holds up if you have watched the malware run in a real environment -which brings us to the part the vendor write-ups could not cover.
What it looked like inside a live estate
We observed this campaign inside an enterprise cloud environment where the poisoned axios reached two separate areas: a cluster of automated build agents (call it BUILD-CLUSTER-A) and a smaller batch-job scheduler (BATCH-CLUSTER-B). Both ran the malicious install. Neither story ended the way the malware's authors intended.
On BATCH-CLUSTER-B, the telemetry showed exactly what the source predicts: the host fingerprint and directory listings went out on schedule, but the deeper exploitation never landed. The broken peinject handler and the script's brittle assumptions about a normal Linux host - it reads hardware and process details from paths that often do not exist inside a stripped-down container -meant the implant beaconed but could not escalate. The static bug and the field behavior agreed.
The more important question on BUILD-CLUSTER-A was the one every container compromise raises: did it stay in the box, or did it get out?
169.254.169.254) that hands out the host's cloud credentials to anything that can reach it. Reach that, and a contained breach becomes a host breach.The flow logs answered it cleanly: the metadata address appears nowhere in the traffic for the entire compromise window, and the cloud audit trail shows no credential theft or role-chaining from the host's identity. The path to the host was open — and that's why "lock that door" sits near the top of the remediation list — but the malware never took it. The breach was real, it was contained, and the credentials that were exposed inside the containers were rotated. That distinction, drawn from evidence rather than assumption, is the whole job.
What to actually do about it
The specifics of this campaign are dead — the packages are gone, the server is offline. The pattern is not. Here's the durable part.
npm ci --ignore-scripts in your pipelines stops postinstall hook . Most builds don't need them; the ones that do, you'll know.
03Watch egress, not just installs. The cleanest signal here was outbound: a build agent talking to a server it had no business talking to. A default-deny egress policy on build pods would have severed the C2 channel outright.
04Close the pod-to-host door before you need it. Enforcing the metadata-service hop limit turns a possible escalation into an impossible one. This breach didn't use that path; the next one will look for it.
05If a poisoned package installed, assume the secrets are gone. Rotate credentials, tokens, and keys for anything that build touched. The implant's whole first move was to read its environment.
Indicators of compromise
| Type | Indicator |
|---|---|
| C2 IP | 142.11.206.73 : 8000 |
| C2 domains | sfrclak[.]com · callnrwise[.]com |
| User-Agent | mozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0) |
| Packages | axios@1.14.1 · axios@0.30.4 · plain-crypto-js@4.2.1 |
| Host (Linux) | /tmp/ld.py · /tmp/.<6> · node_modules/plain-crypto-js/ |
| Host (Win) | %PROGRAMDATA%\wt.exe · system.bat · 6202033.ps1 |
| Host (macOS) | com.apple.act.mond · /tmp/.XXXXXX.scpt |
| setup.js | e10b1fa84f1d6481625f741b69892780140d4e0e7769e7491e5f4d894c2e0e09 |
| ld.py | fcb81618bb15edfdedfb638b4c08a2af9cac9ecfa551af135a8402bf980375cf |
A note on attribution
The fingerprints here point to a North Korean state-aligned operation — the kind of actor that funds itself through intrusion. Different research teams file it under different names (one tracks it as UNC1069; another as Sapphire Sleet), and there are tooling overlaps that pull in the broader Lazarus and BlueNoroff lineages, including a macOS build whose project path and codename echo this cluster's earlier work. The vendor label is less important than the tradecraft: patient pre-staging, abuse of trust rather than exploitation of a bug, native tools over custom ones, and an obsession with cleaning up. That is a profile, and it will be back under a different package name.