Loading...
You are here:  Home  >  Axios Supply Chain Poison

Axios Supply Chain Poison

The Poisoned Link — Inside the axios npm Supply-Chain Attack
Threat Teardown · Software Supply Chain

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  

01 — THE SHORT VERSION

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.

In plain terms The trick was not in getting the user do something unusual; it was in turning an entirely routine action into the point of compromise. Installing a widely trusted package was enough. The malicious payload rode along with the legitimate software and executed as part of the normal setup process. Know thy groceries.

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.

02 — THE IDEA

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.

Dependency & transitive dependency. A dependency is code your project pulls in to avoid reinventing it. A transitive dependency is a dependency of that dependency -code you never chose directly, but that still runs in your build. The poison here lived one level down, where almost nobody looks. 

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.

npm install axios axios@1.14.1 source unchanged follow-redirects form-data proxy-from-env legitimate dependencies (3) plain-crypto-js@4.2.1 +1 line added to the manifest The whole attack: one new name in a list of four.
Fig 1 — The injection. axios's code was untouched. A single added line in its manifest pulled in a fourth "dependency" that existed only to do harm.
03 — THE SETUP

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.

Caret range (^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.
pre-staging ~3h live window −18h decoy 4.2.0 −8h C2 domain registered 23:59 payload 4.2.1 armed 00:21 axios 1.14.1 +89s first infection 01:00 axios 0.30.4 ~03:25 npm pulls packages
Fig 2 — Operational tempo. The clean decoy (left, teal) bought the armed payload (red) a clean reputation. Both axios branches were poisoned 39 minutes apart to catch the widest set of projects.
clean / removed malicious publish event
04 — DETONATION

From install to running malware

Here's the mechanism that makes install-time attacks possible.

postinstall hook. npm lets a package declare commands to run automatically right after it's installed - a convenience for legitimate setup tasks. plain-crypto-js used it to run one command, 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.

npm install postinstall hook node setup.js setup.js — dropper obfuscated · checks os.platform() darwin → Mach-O com.apple.act.mond win32 → PowerShell wt.exe · 6202033.ps1 linux → Python /tmp/ld.py C2 server sfrclak[.]com:8000 / 6202033 self-delete · swap clean package.json
Fig 3 — One dropper, three payloads. The same first stage adapts to the host, fetches a matching second stage from the C2, then erases the evidence of its own hook. Solid lines are control flow; red dashed is malicious data over the network.

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.

05 — INSIDE THE DROPPER

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.

__gvEvKx scrambled reverse flip string _ → = fix padding base64 decode XOR per-char cipher "fs" · "http://sfrclak.com:8000/" The cipher key's flaw key "OrDeR_7077" → Number() each char → letters become NaN → NaN acts as 0 effective key = [0,0,0,0,0,0,7,0,7,7] · out = char ⊕ key[(7·i²) mod 10] ⊕ 333
Fig 4 — The descrambler. Four reversible steps. The XOR key looks elaborate but mostly collapses to zero: the author ran an alphanumeric key through a number parser, and the letters silently became NaN — i.e. zero — leaving only four live digits.

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.

06 — THE SECOND STAGE

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
Why that User-Agent is a gift. A Linux server beaconing out while claiming to be Internet Explorer 8 on Windows XP is a contradiction that essentially never occurs in legitimate traffic. It is   single, high-confidence string to hunt on across an entire network.

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.

07 — FROM THE POOR DEFENDER'S CHAIR

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?

Pod vs. node. A container (pod) runs on a host machine (node). The two are isolated — unless something bridges them. The classic bridge in cloud setups is the metadata service, a special internal address (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.
NODE — host machine holds cloud IAM role · s3:* · assume-role metadata service 169.254.169.254 POD — build container ld.py running here REACHED (blast radius): • environment variables & secrets • workspace files • home/config directory listings escalation path: open but never used verified absent from all traffic in the window
Fig 5 — Where it stopped. The implant had everything inside the pod. The one door to the host's cloud credentials stood unlocked — but network evidence proved the malware never walked through it. Blast radius: the container, not the machine.
compromised / reached escalation target host boundary

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.

08 — TAKEAWAYS

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.

01Pin your dependencies. A locked version file is the difference between "we install exactly what we reviewed" and "we install whatever was newest at 1 a.m." Caret ranges are convenience that an attacker can spend. 02Distrust install scripts. 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

TypeIndicator
C2 IP142.11.206.73 : 8000
C2 domainssfrclak[.]com · callnrwise[.]com
User-Agentmozilla/4.0 (compatible; msie 8.0; windows nt 5.1; trident/4.0)
Packagesaxios@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.jse10b1fa84f1d6481625f741b69892780140d4e0e7769e7491e5f4d894c2e0e09
ld.pyfcb81618bb15edfdedfb638b4c08a2af9cac9ecfa551af135a8402bf980375cf

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.