Miasma / Mini Shai-Hulud: Dissecting the Wormable npm Supply Chain Attack That Hit 32 Red Hat Packages

On June 1, 2026, a single Red Hat employee’s stolen GitHub session cookie turned the @redhat-cloud-services namespace into a worm launcher. Ninety-six malicious versions across 32 packages shipped in under three hours — each one carrying a valid Sigstore signature and clean SLSA provenance. The provenance was telling the truth. That’s the whole problem.

This is the post I wish existed the morning the IOCs started landing. We’re going to walk the entire chain — credential theft, orphan-commit injection, OIDC abuse, the four-layer dropper, the binding.gyp pivot that landed within 72 hours of public disclosure, and the per-infection encryption that makes your hash-based blocklists worthless — and then build detection logic and a triage runbook that actually accounts for the deadman switch waiting to nuke a responder’s home directory.


The Shai-Hulud lineage, and why May 12 broke everything

Miasma didn’t appear from nowhere. It’s the latest mutation of Shai-Hulud, the self-propagating npm worm that first surfaced in September 2025. For most of its life this was bespoke tradecraft operated by a crew that Mandiant tracks as UNC6780 and that publicly brands itself TeamPCP.

The inflection point was May 12, 2026. On that day TeamPCP published the entire worm source on GitHub under an MIT license with the message “Shai-Hulud: Open Sourcing The Carnage.” The release shipped with operational guidance — change your encryption keys, swap your exfil infrastructure, customize your themes — and TeamPCP simultaneously posted a $1,000 BreachForums bounty for whoever pulled off the largest supply-chain attack using the code.

That’s the strategic shift you need to internalize. Before May 12, defending against Shai-Hulud meant tracking one actor’s infrastructure and habits. After May 12, anyone with a stealer log and a grudge could fork Mini Shai-Hulud, rename the Dune references to Greek mythology, and ship. Zscaler ThreatLabz attributes the pre-May-12 waves to TeamPCP/UNC6780 with high confidence — but explicitly withholds confidence on anything after the open-sourcing. Miasma is one of those forks. Its modifications are almost cosmetic: the “spice” and “sandworm” naming got swapped for “spartan” and “Nemean hydra,” but the obfuscation stack, the Bun runtime trick, the bypass_2fa propagation, and the /proc/mem runner scraping are all lifted straight from the open-source base.

When you open-source a weapon, you don’t lose your monopoly on violence — you franchise it.


The seven-week gap: an infostealer log nobody acted on

Here’s the part that should make every security leader uncomfortable. The Red Hat employee’s GitHub credentials — username, password, and an active session cookie capable of bypassing MFA — appeared in commercial infostealer logs on April 13, 2026, and again on May 15. CybelAngel confirmed both sightings. The actual weaponization happened on June 1.

That’s nearly seven weeks of dwell time between credential theft and use. This lag is the norm, not the exception, in stealer-driven intrusions: logs are harvested by one operator, brokered on a marketplace, bought by a second, and triaged at leisure. The session cookie is the detail that matters most. MFA is the control everyone leans on, and a live user_session cookie walks straight past it — no push prompt, no TOTP, no FIDO challenge. The attacker didn’t bypass MFA so much as render it irrelevant by stealing an already-authenticated session.

The lesson is brutal in its simplicity: MFA is not a substitute for session hygiene. Short session lifetimes, device-bound tokens, and continuous dark-web monitoring for your own employee credentials are the controls that would have closed this window. None of them were in place in time.


Orphan commits: how you push to a protected repo without anyone reviewing it

With the session in hand, the attacker pushed orphan commits into three RedHatInsights repositories: frontend-components, javascript-clients, and platform-frontend-ai-toolkit.

An orphan commit is a git object with no parent in the branch’s existing history — git checkout --orphan gives you a clean root. The trick here is that branch-protection rules and required-review policies are typically scoped to specific refs and to pull request flows. Push an orphan branch that isn’t covered by those rules, wire it to a workflow that triggers on: push, and you’ve created a code-execution path inside the repository’s trust boundary that never touched a pull request and never hit a reviewer’s screen.

# Injected workflow — the shape that matters, not the bytes
name: release          # innocuous name; "Run Copilot" was also observed
on:
  push:                # fires on the attacker's orphan push, no PR required
    branches: ['**']
permissions:
  id-token: write      # <-- the keystone. Requests an OIDC token.
  contents: read
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm publish --provenance   # exchanges OIDC token for npm publish rights

Two publication waves followed: the first at roughly 10:53 UTC, the second at 13:44–13:46 UTC. Ninety-six malicious versions across 32 packages, published in two tight bursts, before detection. The burstiness itself is a detection signal we’ll come back to.


Flow diagram showing how a stolen GitHub session cookie enables an orphan commit that triggers a workflow, obtains an OIDC token, publishes malicious packages, and generates valid [SLSA provenance](https://genxcyber.com/cyber-threat-intelligence-fundamentals-sources-types-lifecycle/)
The attacker never touched a pull request or a reviewer’s queue — the orphan commit fired the workflow directly, and valid SLSA provenance was the honest result.

The SLSA provenance trap

Now the uncomfortable architectural truth. Those id-token: write workflows requested a short-lived OIDC token through GitHub’s Trusted Publishing mechanism, exchanged it for npm publish rights, and Sigstore signed the resulting attestations. Every malicious release shipped with cryptographically valid SLSA provenance.

Read that again, because the industry sold Trusted Publishing as the answer to supply-chain attacks. And it did solve a real problem: it eliminated long-lived npm publish tokens as a standalone attack surface. There was no static _authToken for the attacker to steal here. That’s genuine progress.

But provenance answers exactly one question — “was this package built by the workflow in this repository?” — and the answer was an honest yes. The package really was built by that repo’s workflow. What provenance structurally cannot tell you is whether the workflow itself was authorized. The build was legitimate. The intent behind the build was not.

SLSA provenance attests that the gun was manufactured in the right factory. It says nothing about who pulled the trigger.

This is why “we verify SLSA provenance” is a comforting but insufficient control. If your verification pipeline accepts any package with valid provenance from the expected repo, you accepted all 96 malicious versions without a single failed check. The fix isn’t to abandon provenance — it’s to layer it: verify the attestation and diff the workflow YAML SHA against a known-good baseline and alert when a workflow with id-token: write is added by an account with no prior workflow history.


Wave 1: the preinstall hook and the 4.29 MB dropper

Each compromised release carried a weaponized package.json:

{
  "scripts": {
    "build": "tsc",
    "preinstall": "node index.js"
  }
}

npm runs preinstall automatically during npm install, before any of your application code executes. Merely resolving the dependency tree detonates the payload. There’s no “import the module” step required — npm install is the trigger.

The index.js that preinstall calls is where it gets interesting. A normal build of these packages ships a roughly 200 KB index.js. The malicious one weighs 4.29 MB — a 25× blowup that is, on its own, one of the most reliable detection signals in the whole campaign. There’s a forensic tell inside the tarball too: only two files have a recent modification time — the mutated package.json and the bloated index.js. Every other file still carries the epoch (1970) placeholder that npm pack writes when there’s no original timestamp. That mismatch — two fresh files in a sea of epoch-stamped ones — is a forensic fingerprint you can scan for.

That 4.29 MB is four layers of obfuscation stacked deliberately to defeat each tier of static analysis:

LayerTechniquePurpose
1eval() + ROT-based string decodingHide the bootstrap from grep-style scanners
2Two AES-128-GCM encrypted blobsOne fetches the Bun runtime, one is the primary payload
3Obfuscator.io string-array protectionRotated string arrays, decoder functions, hundreds of alias wrappers
4Custom PBKDF2-HMAC-SHA cipherFinal unwrap before Bun launches the second stage

The deobfuscation drill, at the Layer 1 level, is approachable — and you should always do it in a sandboxed VM context, never a naked eval:

const vm = require('vm');

function rot13(s) {
  return s.replace(/[A-Za-z]/g, c => {
    const base = c <= 'Z' ? 65 : 97;
    return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base);
  });
}

const encoded = "pbafhyr.ybt('Ynl 1 qrpelcgrq')";
const decoded = rot13(encoded);

// NEVER eval() unknown payloads directly. Use an isolated context.
vm.runInNewContext(decoded, { console });

For the deeper layers you want synchrony for Obfuscator.io unrolling, js-beautify for readability, and manual AES-GCM decryption once you’ve statically pulled the key and IV out of the bootstrap.

The Bun runtime trick

The most operationally clever part of the stack is the runtime swap. One decrypted blob defines a getBunPath() routine that curls Bun v1.3.13 from the legitimate oven-sh release endpoint, unzips it into a b- prefixed temp directory created with mkdtempSync, marks it executable, and then runs the actual worm with bun run. The second-stage payload itself gets written to /tmp/p<random>.js.

The process chain is:

node → shell → bun → payload

Why bother downloading an entire alternate JavaScript runtime? Because every Node-level hook, instrumentation shim, and EDR-injected agent that watches node is now blind. The malware runs under Bun, a runtime your monitoring almost certainly doesn’t instrument, and it carries its own copy so the trick works even on hosts that have never seen Bun. It’s a runtime-level analogue of bring-your-own-vulnerable-driver: bring your own interpreter, and your defenders’ hooks evaporate.


Wave 2: “Phantom Gyp” — and the 72-hour pivot

Here’s the part that tells you this is a living, adaptive operation. Once Wave 1’s preinstall technique got publicly reported, defenders started tuning scanners around the package.json scripts field. Within 72 hours, the operators shipped Wave 2 — and it drops preinstall entirely.

Wave 2’s technique is Phantom Gyp. It abuses a 157-byte binding.gyp file:

# binding.gyp — JSON-ish gyp syntax. 157 bytes of pure execution.
{
  "targets": [{
    "target_name": "Setup",
    "type": "none",
    "sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
  }]
}

When npm sees a binding.gyp in a package, it automatically runs node-gyp rebuild during install — a feature designed for packages with native C/C++ addons. The <!( ... ) syntax is gyp’s command-substitution operator: it executes the enclosed shell command and uses the stdout as a source filename. So <!(node index.js > /dev/null 2>&1 && echo stub.c) runs the payload, discards its output, and returns stub.c as the “source.” The build framework itself becomes the execution vector.

The defender insight here is the one most people get wrong: npm install --ignore-scripts does NOT stop this. --ignore-scripts suppresses lifecycle hooks like preinstall and postinstall. It does not suppress the node-gyp build step, which runs as a separate mechanism. Teams that hardened against Wave 1 by globally enabling --ignore-scripts and called it done were wide open to Wave 2.

Wave 2 also broadened the targeting well beyond Red Hat: @vapi-ai/server-sdk (71,000 weekly downloads), ai-sdk-ollama (31,000 weekly downloads), and 55 other packages — 647,204 total monthly downloads in scope.

 Wave 1Wave 2
Triggerpreinstall in package.jsonbinding.gyp command substitution
Stopped by --ignore-scripts?YesNo
Monitored by typical scanners?Yes (after disclosure)No
Time to pivot~72 hours after Wave 1 disclosure

Illustration of one broken shield representing defeated preinstall defenses while a serpent slips under a second intact shield representing the binding.gyp bypass that ignore-scripts cannot stop
When defenders patched the `preinstall` vector within hours, Phantom Gyp was already live — routing execution through `node-gyp` rebuild, a path `–ignore-scripts` does not touch.

What it steals, and the new cloud-identity collectors

Once the second stage is running under Bun, it splits behavior by environment.

On CI/CD runners, it validates GitHub tokens against the API, enumerates accessible repositories and org secrets, and — this is the nasty bit — locates the Runner.Worker process and scans /proc/<pid>/mem to recover masked workflow secrets. GitHub masks secrets in logs; it cannot mask them in the worker’s memory, where they exist in plaintext to be used.

On developer workstations, it harvests shell environment variables, host identity, local credential files, and pulls GitHub CLI tokens via gh auth token. It hunts classic and fine-grained GitHub PATs, Actions tokens, npm tokens, cloud credentials, SSH and other private keys, Docker creds, shell histories, and .env files.

The headline change in Miasma specifically is a new class of cloud identity collectors for GCP and Azure. Earlier Shai-Hulud variants mostly grabbed secrets out of cloud environments. Miasma added collectors that enumerate every identity the infected machine can assume — a clear signal the operators want to pivot into the cloud itself, not just snatch keys. There’s a free network IOC in this: the GCP metadata-server queries go out with a fixed user-agent:

google-api-nodejs-client/7.0.0 gl-node/20.11.0 gccl/7.0.0

Hunt for that string hitting 169.254.169.254 from a host that has no business talking to GCP’s metadata endpoint.

The full harvest target list, for your DLP and hunting rules:

CategoryTargets
Git/CIGITHUB_TOKEN, GitHub PATs (classic + fine-grained), Actions secrets, npm publish tokens, CircleCI tokens
CloudAWS creds, GCP identities, Azure identities, K8s kubeconfig + service-account tokens
Secrets infraHashiCorp Vault tokens
Keys/hostSSH keys, Git credentials, Docker creds, .env files, shell history

Worm propagation: bypass_2fa, dead drops, and self-reignition

This is what makes Miasma a worm and not just a stealer. With harvested npm tokens, the malware calls the publish API with the bypass_2fa: true parameter to push backdoored versions of every other package the victim owns — skipping the 2FA prompt that should have gated publishing. It then creates a public repo in the victim’s GitHub account titled “Miasma: The Spreading Blight” and injects its own source as .github/setup.js into unprotected branches of the victim’s repos, so the next CI run reignites the whole chain. Each infection plants the seed for the next.

Exfiltration uses GitHub dead drops: the malware spins up a fresh repo on the fly (observed names like nemean-hydra-34343), then uploads stolen credentials as encrypted JSON into a results/ directory. The commits are disguised:

chore: update dependencies
chore: update dependencies [skip ci]

The [skip ci] suffix is deliberate — it prevents the victim’s own CI from running on the exfil commit and tipping anyone off. JFrog tags these as IOCs 24-30 and 24-31. The propagation logic also leans on workflow environment variables OIDC_PACKAGES, WORKFLOW_ID, and REPO_ID_SUFFIX, and dumps secrets into an artifact named format-results / format-results.txt.


Per-infection encryption: why your hash blocklist is dead on arrival

Miasma generates a uniquely encrypted payload for every single infection. The ciphertext — and therefore the file hash — changes with every package version and every host. This isn’t packing for size; it’s deliberate IOC defeat. Any blocklist built from “known-bad SHA-256” is structurally useless against this family. The day you publish the hash of the sample you analyzed is the day that hash matches exactly one file on earth: the one you already have.

This forces a discipline shift. Stop chasing hashes and pivot to structural and behavioral indicators that survive re-encryption:

  • index.js that is ~25× its expected size (200 KB → 4+ MB)
  • Tarballs where only package.json and index.js have fresh mtimes and everything else is epoch-stamped
  • A 157-byte binding.gyp containing <!(node ...) command substitution
  • The node → shell → bun → payload process tree
  • A curl to oven-sh Bun releases from a CI runner or build host
  • The GCP user-agent above hitting a metadata endpoint

Graph diagram showing per-infection re-encryption defeating hash-based blocklists while behavioral indicators like file-size blowup, epoch mtime mismatches, the node-to-bun process chain, and oven-sh curl requests remain detectable
Every file hash changes per infection, but the structural and behavioral fingerprints of the dropper are invariant — hunt those instead.

The deadman switch — read this before you revoke anything

Miasma plants a decoy honeytoken named:

IfYouInvalidateThisTokenItWillNukeTheComputerOfTheOwner

This is psychological warfare aimed squarely at incident responders. The reflexive first move during npm-token compromise is “revoke all tokens immediately.” Do that here, and a wiper routine fires rm -rf against ~/ and ~/Documents. The trigger is wired to a persistence daemon that watches token state:

PlatformDaemon path
macOS~/Library/LaunchAgents/com.user.gh-token-monitor.plist
Linux~/.config/systemd/user/gh-token-monitor.service

The operational rule is absolute: find and remove the gh-token-monitor daemon FIRST, then revoke tokens. Reverse that order and you destroy your own evidence and the developer’s machine.

The implant rounds out its foothold with:

  • Privilege escalation in CI: writing runner ALL=(ALL) NOPASSWD:ALL to /etc/sudoers.d via a bind mount at /mnt, granting passwordless sudo on the runner.
  • Monitoring sinkholing: appending 127.0.0.1 entries to /etc/hosts for security-product telemetry endpoints, so EDR can’t phone home.
  • EDR awareness: checking for CrowdStrike, SentinelOne, and Carbon Black before proceeding.
  • IDE/AI-agent persistence: backdoors in .vscode/tasks.json and .claude/settings.json that survive npm package removal. Uninstalling the bad dependency does not evict the attacker. You have to audit and clean those config files by hand.

Illustration of a hand reaching for a key connected by a hidden tripwire to burning documents, symbolizing the honeytoken deadman switch that destroys evidence if tokens are revoked before the monitor daemon is removed
Miasma’s honeytoken wiper is psychological warfare against responders — remove the `gh-token-monitor` daemon first, or revoking a token incinerates the victim’s home directory.

Detection and defense

MITRE ATT&CK mapping

TacticTechniqueWhere it shows up
Initial AccessT1195.002 Supply Chain Compromise: SoftwareThe poisoned npm packages
Initial AccessT1078 Valid AccountsStolen GitHub session cookie
ExecutionT1059.007 JavaScriptpreinstall / binding.gyp payload
Defense EvasionT1027 Obfuscated FilesFour-layer stack, per-infection encryption
Defense EvasionT1562.001 Disable/Modify Tools/etc/hosts telemetry sinkhole, EDR checks
Credential AccessT1552.001 Credentials in Files.env, .npmrc, keys
Credential AccessT1003 OS Credential Dumping/proc/<pid>/mem of Runner.Worker
Privilege EscalationT1548.003 Sudo and Sudo Caching/etc/sudoers.d NOPASSWD
PersistenceT1543 Create/Modify System Processgh-token-monitor daemon
ExfiltrationT1567 Exfil Over Web ServiceGitHub dead-drop repos
ImpactT1485 Data DestructionHoneytoken wiper

GitHub Actions anomaly rules

Query your Enterprise audit log (or SIEM-forwarded copy) for:

SignalQuery shape
Orphan commit to protected branchgit.push where ref SHA has no parent in the default-branch graph
id-token: write added by a workflow-noviceNew YAML containing id-token: write, pushed by an account with no prior workflow commits
Burst publish via OIDCnpm publish from OIDC token, >3 versions in <60s, outside normal CI window
Suspicious workflow namesRun Copilot or release triggered by push to a non-default branch
Worm exfil commitsauthor github-actions@github.com, message chore: update dependencies [skip ci], branch ≠ default

Bonus IOCs: workflow env vars OIDC_PACKAGES, WORKFLOW_ID, REPO_ID_SUFFIX; artifact format-results.txt.

SLSA provenance — layer it

  1. Verify the Sigstore attestation and diff the producing workflow YAML SHA against a known-good baseline.
  2. Alert whenever a workflow carrying id-token: write is introduced by an unusual identity.
  3. Treat provenance as one input, never the sole gate.

npm-side hardening

  • Pin and allowlist dependencies; impose a release-cooldown (don’t auto-consume versions younger than N days).
  • Enforce SBOMs and scan for the structural signals above, not hashes.
  • Know that --ignore-scripts does not stop binding.gyp — block native rebuilds you don’t expect.

Developer-workstation triage checklist (run in this order)

#!/usr/bin/env bash
# 1) DISABLE the deadman daemon BEFORE touching any token.
launchctl list | grep gh-token-monitor
systemctl --user list-units | grep gh-token-monitor
ls ~/Library/LaunchAgents/com.user.gh-token-monitor.plist 2>/dev/null
ls ~/.config/systemd/user/gh-token-monitor.service 2>/dev/null

# 2) Clean IDE/AI persistence (survives npm uninstall)
grep -r "setup\.js\|router_runtime" ~/.claude ~/.vscode 2>/dev/null

# 3) Hunt Bun + staged payload
find /tmp /var/folders -name "bun" 2>/dev/null
find /tmp -name "p*.js" 2>/dev/null

# 4) Check lockfiles for the namespace
grep -r "@redhat-cloud-services" package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null

# 5) Find dead-drop repos
gh repo list --json name,description \
  | jq '.[] | select(.description | test("Miasma|Shai-Hulud|Spreading Blight";"i"))'

# 6) ONLY NOW: enumerate and (after daemon removal) revoke tokens
npm token list

After the host is clean: revoke and rotate everything the harvest list touches — GitHub PATs, npm tokens, cloud identities, Vault tokens, SSH keys — and audit cloud identity logs for the new GCP/Azure enumeration behavior.


Key takeaways

  • A stolen session cookie beats MFA. Session hygiene, device-bound tokens, and monitoring your own employees’ credentials in stealer logs are the controls that close the seven-week window. MFA alone does not.
  • SLSA provenance proves where, never whether it should have happened. Verify provenance, but diff the workflow and gate on identity anomalies, or you’ll cryptographically bless your own compromise.
  • --ignore-scripts is not a seatbelt. Phantom Gyp routes execution through node-gyp, untouched by lifecycle-hook controls. Block unexpected native rebuilds.
  • Hash IOCs are dead for this family. Per-infection encryption means you hunt structure and behavior — file-size blowups, epoch-mtime mismatches, the node → shell → bun chain — not SHA-256.
  • Remove gh-token-monitor before you revoke a single token, or the honeytoken wiper takes the responder’s machine with it.
  • Open-sourcing the worm changed the game. After May 12, you’re not defending against one crew — you’re defending against everyone with a fork, a grudge, and a $1,000 incentive.

Related Tutorials