Note
Incident reporting around the axios compromise came from public reporting by Socket, StepSecurity, SecurityOnline, and the axios maintainer team on GitHub. The payload analysis in this article is our own. We recovered a cached copy of plain-crypto-js@4.2.1 from a local npm cache before the package's cleanup routine could erase the evidence from disk. We then performed a full static teardown of setup.js, the stage-1 installer, without executing it.
No code was run. No authentication was performed. No state-changing requests were made.
One important boundary: this article analyzes the first-stage installer. The second-stage payload was fetched remotely at install time and was not recovered here. That means we can describe how the compromise worked, how the launcher behaved, and how it attempted to hide itself. We cannot claim to have recovered the full implant.
The recovered dropper tells you exactly what the operator expected: defenders would arrive after the evidence was gone.
setup.js deletes itself. The malicious package.json deletes itself. A clean-looking replacement manifest is restored in place. By the time most responders open node_modules/plain-crypto-js, the artifact that actually ran is already gone and the package directory is staged to look substantially benign.
We obtained a cached tarball of that payload before the cleanup path could do its job and tore apart the stage-1 installer without executing a single line. What follows is one of the first public static teardowns of the actual artifact, and a case study in the policy failure that mattered more than the malware: a registry that let a weaker publish path override a stronger one on a package with enormous downstream blast radius.
Introduction
On March 31, 2026, an attacker published two poisoned versions of axios to npm. The malicious releases did not alter axios source code. They introduced a new runtime dependency, plain-crypto-js@^4.2.1, whose only meaningful purpose was to execute a cross-platform postinstall dropper.
The attacker did not need a novel exploit. They did not need to compromise npm's infrastructure. They gained access to a maintainer account, published with a classic long-lived npm token, and pushed malicious versions of one of the most widely used packages in the JavaScript ecosystem to a registry that was still willing to treat that publish path as legitimate.
That is the technical story.
The policy story is worse.
npm already supports the mechanisms that should have made this attack materially harder: OIDC Trusted Publishers, provenance attestation, stronger publisher identity, and more constrained token models. axios had already adopted OIDC publishing through GitHub Actions. None of that mattered, because npm still accepted a weaker publish path beside the stronger one.
The payload is interesting. The registry decision is the indictment.
The Timeline
The attacker did not rush this.
On March 30 at 05:57 UTC, plain-crypto-js@4.2.0 was published to npm. It was clean. No postinstall hook. No obvious malicious logic. It appears to have served as a staging release to establish package history.
At 23:59 UTC on March 30, plain-crypto-js@4.2.1 was published with a postinstall hook pointing to setup.js, a compact cross-platform downloader and launcher. Socket's automated detection reportedly flagged the package within minutes.
At 00:21 UTC on March 31, the attacker published axios@1.14.1.
At 01:00 UTC, axios@0.30.4 followed.
Within roughly forty minutes, both release branches were poisoned.
Timeline (UTC)
Mar 30 05:57 plain-crypto-js@4.2.0 published (clean staging) Mar 30 23:59 plain-crypto-js@4.2.1 published (malicious payload) Mar 31 00:05 Socket automated detection reportedly flags plain-crypto-js Mar 31 00:21 axios@1.14.1 published via compromised maintainer account Mar 31 01:00 axios@0.30.4 published Mar 31 later npm removes the malicious versions
Public reporting indicated the attacker had access to the npm account of an axios maintainer and published directly with a classic npm token. That bypassed the project's normal GitHub Actions release path, including its OIDC-based Trusted Publisher configuration.
That downgrade in trust should have been treated as a dangerous anomaly. npm accepted it.
The Mechanism
Neither poisoned axios release appears to have modified axios implementation code.
Many defenders still think in terms of “diff the package,” as if compromise must mean a changed source file in the top-level dependency. Here, the attack lives one layer deeper in the graph.
The malicious axios versions simply add a new dependency:
"plain-crypto-js": "^4.2.1"
That package is not needed for axios runtime behavior. It exists to supply a postinstall hook:
"postinstall": "node setup.js"
This is a dependency injection attack, not a source tampering attack.
If you only compare axios source to a known-good release, you miss it. If you only inspect axios files in node_modules, you miss it. If you inspect the package directory after the malicious install has already run, you may still miss it, because the installer is built to erase obvious evidence.
The Payload
The recovered stage-1 artifact is compact, deliberate, and anti-forensic by design.
Its responsibilities are narrow and well-defined:
- Deobfuscate the strings it needs
- Detect the host operating system
- Fetch an OS-specific second stage
- Launch that second stage in the background
- Delete or disguise the files that would make the compromise obvious
That division of labor is the point. The installer is small because most of the operational capability sits behind a remotely served second stage. What we recovered is the launcher, not the full implant. But launchers tell you a lot about operator intent, target assumptions, and defender expectations.
The Obfuscation
The file uses two simple layers of string hiding. Sensitive strings are stored encoded in an array and decoded at runtime through helper functions that:
- Reverse the string
- Substitute
_back to= - Base64-decode the result
- XOR each character with a weak position-derived key and a constant
Deobfuscation routine (reconstructed)
function decode(encoded, key = "OrDeR_7077"):
// Layer 2
// reverse string
// replace "_" with "="
// base64 decode
// Layer 1
// XOR each decoded byte against:
// - a digit drawn from the key based on character position
// - the constant 333This is not sophisticated cryptography. It is an inexpensive way to defeat lazy inspection, registry-side string matching, and quick scans of unpacked package contents. Once decoded, the control flow is plain.
Selected decoded strings
stq[0] = "child_process" stq[1] = "os" stq[2] = "fs" stq[3] = "<redacted-c2-base>/" stq[5] = "win32" stq[6] = "darwin" stq[13] = "package.json" stq[14] = "package.md" stq[15] = ".exe" stq[16] = ".ps1" stq[17] = ".vbs"
The script also hard-codes a campaign identifier: 6202033. That identifier shows up in the platform-specific file names and request patterns.
Platform-Specific Behavior
Once deobfuscated, the control flow is explicit. The script imports fs, os, and child_process, concatenates the redacted C2 base with the campaign id, branches on os.platform(), materializes an OS-specific launcher, and hands off execution with execSync. This is not a resident implant. It is a first-stage bootstrapper engineered for fast execution and low post-install visibility.
macOS. The dropper writes a temporary AppleScript file and launches it with osascript. That AppleScript builds the campaign URL, downloads a second-stage binary to /Library/Caches/com.apple.act.mond, makes it executable, launches it in the background through /bin/zsh, and deletes the temporary AppleScript. The path choice is deliberate — com.apple.act.mond is shaped to read like an Apple-adjacent cache artifact during hurried triage.
Windows. The Windows branch shows the most overt tradecraft. It locates powershell.exe with where powershell, copies it to %PROGRAMDATA%\wt.exe, writes temporary .vbs and .ps1 files, and invokes the renamed PowerShell binary with hidden window and execution-policy bypass. That wt.exe step is the important one — copying PowerShell to a different filename in a different directory is an evasion move aimed at weak parent-child or filename-based analytics. A detection that keys on node.exe → powershell.exe is less useful when the child process is %PROGRAMDATA%\wt.exe.
Linux. The leanest branch. A single shell command downloads a second-stage Python script to /tmp/ld.py and launches it in the background with nohup python3. Exactly what you would expect from an operator optimizing for speed, portability, and minimal first-stage complexity: fetch, background, detach, move on.
The Cleanup
The most important part of stage 1 is not the downloader logic. It is the cleanup.
After launching the platform-specific payload, the script deletes evidence of the compromise from its own package directory:
Self-deletion sequence
fs.unlink(__filename) // deletes setup.js
fs.unlink("package.json") // deletes malicious manifest
fs.rename("package.md", "package.json")That last line is the trick.
The file named package.md is not markdown. It is a clean-looking JSON manifest for the benign package version. The attacker shipped the replacement artifact inside the same tarball as the malicious installer so that, after execution, the package directory could be made to appear substantially normal.
After the installer runs, the obvious signs are intentionally gone: setup.js is deleted, the malicious package.json is deleted, and a clean-looking replacement manifest is restored in its place.
This is not incidental cleanup. It is pre-positioned anti-forensics designed around the default responder workflow: open node_modules, inspect the package directory, decide whether anything looks suspicious. The malware anticipates that inspection and manufactures a reassuring answer.
What We Can Say, and What We Cannot
The recovered artifact gives us a clean view of the first-stage compromise chain.
We can say with confidence that stage 1 was a cross-platform downloader and launcher, used lightweight obfuscation to hide strings, fetched an OS-specific second stage, used platform-specific launch wrappers, and attempted to reduce obvious traces after install.
We cannot say, from stage 1 alone, exactly what stage 2 did on every host.
We did not recover the remotely served second-stage payload in this analysis. That means claims about persistence, credential theft, lateral movement, or post-compromise behavior must be framed carefully unless supported by separate observation from other researchers.
That limitation does not weaken the first-stage analysis. It forces precision. Too many incident writeups inflate confidence exactly where the evidence runs out. The recovered launcher is enough to prove compromise mechanics, anti-forensics, and delivery intent. It is not enough, by itself, to overclaim post-exploitation behavior.
Indicators from Static Analysis
The stage-1 artifact exposes a useful set of indicators even with the C2 base redacted.
Campaign ID: 6202033
C2 port: 8000
POST routing: packages.npm.org/product0 (macOS)
packages.npm.org/product1 (Windows)
packages.npm.org/product2 (Linux)
macOS drop: /Library/Caches/com.apple.act.mond
Windows drops: %PROGRAMDATA%\wt.exe
%TEMP%\6202033.ps1
%TEMP%\6202033.vbs
Linux drop: /tmp/ld.py
Cleanup: setup.js deleted via fs.unlink(__filename)
package.json deleted
package.md renamed in its placeOne practical implication follows from this immediately: absence of obvious package-directory artifacts is not evidence of safety. That is a design feature of the malware.
What Defenders Should Check
If your CI or local machine ran npm install during the exposure window, the right response is not “did I find setup.js in node_modules.”
The right response is:
- Check lockfiles and CI logs for resolution to
axios@1.14.1oraxios@0.30.4 - Check whether
plain-crypto-jsappeared as a transitive dependency at all - Inspect package manager caches, shell history, and network telemetry
- Look for the platform-specific drop paths and launcher behavior described above
- Treat any positive dependency or network indicator as a serious compromise signal
Because the installer self-deletes and the second stage was remote, a clean-looking filesystem after the fact is not dispositive. In this incident, “nothing weird in node_modules” is not a reassuring result. It is one of the outcomes the attacker explicitly engineered.
The Forensic Differentiator
One of the clearest signals in this incident is not in the malware at all. It is in the publish path.
Legitimate modern axios releases were published through GitHub Actions with OIDC-backed metadata. The malicious releases, by public reporting, were published through a compromised maintainer account using a classic npm token.
That is not a subtle distinction. It is a machine-verifiable downgrade in trust.
Any registry that takes provenance seriously should treat the transition from repository-bound, workflow-bound OIDC publishing to direct publish with a classic long-lived token as a high-risk event, especially for a package of this reach.
Instead, npm appears to have treated both publish paths as equally acceptable. That is the policy failure.
The Gap npm Chose Not to Close
The attack surface here is not “maintainer made a mistake” in the abstract. Maintainers get phished. Tokens get stolen. Accounts get taken over. That is ordinary threat modeling for a registry at npm's scale.
The real question is what the registry does once that happens.
Classic tokens remain too powerful for the role they still occupy. They do not enforce short lifetimes by default. They are not narrowly package-bound in the way high-risk ecosystems should demand. They preserve a weak publish path long after a stronger one exists.
OIDC Trusted Publishers remain optional, and that optionality guts much of their practical value. Once a project has moved to repository-bound, workflow-bound publishing, the registry should be able to say: “Only this workflow may publish this package.” If a classic token shows up afterward, that should be blocked by default or require an explicit, high-friction recovery path.
Instead, the stronger path and the weaker path coexist, and the effective security of the package collapses to the weaker one. Once both are valid, the attacker does not need to defeat OIDC. They only need a surviving route around it.
Mandatory 2FA also remains under-enforced for high-impact packages. Other ecosystems have shown there is no reason to treat a package with massive downstream reach the same as a package with no operational significance. Registry policy can reflect blast radius. npm still largely declines to do so.
Detection Is Not Prevention
Socket reportedly flagged the malicious dependency quickly. That is good work and worth saying plainly.
But early detection is not the same thing as prevention. If a poisoned release of a top-tier package can still be installed by real systems before it is removed, the ecosystem is still depending on reaction time after the malicious package is already live.
For low-impact packages, maybe that is survivable. For a package with axios's distribution footprint, it is not a serious security model. The registry should not be relying on downstream scanners to compensate for an upstream policy gap it already knows how to close.
What This Means for the Future
If npm does not change the publish trust model after this incident, the lesson for attackers is obvious.
They do not need novel malware. They do not need to alter upstream source. They do not need to beat the strongest publisher control a project has adopted. They need one valid route to publish, one package with enormous transitive reach, one installer that executes before defenders have context, and one cleanup path that makes retrospective inspection unreliable.
That is a repeatable pattern.
The warning is not that someone may try this again. The warning is that the ecosystem has already validated a reusable template: stage a plausible dependency, introduce install-time execution, hand off to a remotely served second stage, erase the local launcher, and rely on registry trust ambiguity to do the rest.
If the registry continues to let packages move backward from repository-bound OIDC publishing to direct token publishing without a hard block, attackers will keep targeting the downgrade path. The right post-incident response is not a blog post about improving detection. It is a policy change.
At a minimum, registries should be moving toward:
- One-way trust upgrades, where a package that adopts OIDC cannot silently fall back to classic token publishing
- Mandatory high-friction recovery flows for any publish-method downgrade
- Mandatory provenance checks for top-tier ecosystem packages
- Expiration, scoping, and eventual deprecation of classic publish tokens
- Authentication requirements calibrated to package blast radius, not maintainer preference
If that does not happen, the next attacker will not need to innovate. They will just reuse the shape of this operation with a different package name.
The payload is competent, compact, and worth studying. The stage-1 installer was built to disappear. It used enough obfuscation to frustrate casual inspection, enough platform awareness to hand off execution cleanly, and enough cleanup logic to make post-install forensics materially harder.
But the payload is not the most important part of the story.
The important part is that a package with enormous downstream reach had already adopted a stronger publish model, and the registry still allowed a weaker publish method to override it.
That is not a tooling failure. That is not a zero-day. That is not an inevitable cost of open source at scale.
That is a policy choice.
npm had the controls. It chose not to require them.
Chase Norton is the founder of Redeux Security and an independent security researcher based in Honolulu, HI. Our earlier research on the LiteLLM supply-chain compromise was published on March 24, 2026.