Shai-Hulud worm returns in @workbench-stack/core 3.9.8, exfiltrating npm and cloud credentials through GitHub
Blog authorArmando, ReversingLabs AI threat analyst ยท Aug 6, 2026
campaign: shai-hulud-v3
The Shai-Hulud npm worm is back for a third time, and this wave is the most capable yet. The worm still works the way it always has -- it steals maintainer credentials the moment a package is installed, then uses those credentials to republish trojanized versions under the victim's own name, so every infected developer extends its reach. The stolen secrets still leave through GitHub, committed into attacker-created repositories over the API, where the traffic is indistinguishable from a developer pushing code. What is new is the engineering around that: the payload runs under a Bun runtime the install hook downloads on the spot, and when the GitHub path fails it falls back to domains fetched at runtime from a smart contract, so that backup infrastructure rotates without republishing a thing. On 2026-08-04, @workbench-stack/core 3.9.8 appeared on npm carrying an injected preinstall hook and a 711 KB payload. Any developer or CI pipeline that resolved this version ran a credential harvester that reaches into npm tokens, GitHub tokens, and cloud secret stores.
Key Findings
Malicious version: 3.9.8, published 2026-08-04; the package's prior release line is clean.
Entry point: an injected preinstall hook runs setup.mjs, which downloads the Bun JavaScript runtime and uses it to execute the payload math_init.js. Both files are added to the manifest's files array so they ship inside the tarball.
Shared payload:math_init.js is byte-for-byte identical across every package in this wave (SHA1 35a672cf34b996b91f3e1c28cbf3a05a37e036e4), making it a campaign-level indicator. The file is named math_init.js in most of the wave and Math_Symbol.js in a small number of packages; the contents are the same either way.
Exfiltration: stolen data is encrypted, then committed into GitHub repositories the worm creates under the victim's own account. They carry the description Shai-Hulud: Here We Go Again, are named from a fixed Dune-lore vocabulary as two words and a number such as , and hold a single commit titled . The commits are made with the victim's own git identity, so there is no attacker name to search for. This is the channel that does the work.
fremen-thumper-796
Add files.
Rotatable fallback: when the GitHub path fails, a second sender posts to domains the payload fetches at runtime from a smart contract. They are not in the sample and never will be, so the operators move that infrastructure without republishing anything.
Credential scope: npm tokens, GitHub tokens, AWS instance and container role credentials, AWS SSM Parameter Store values decrypted across every region, and HashiCorp Vault endpoints.
Signed, not forged: the worm exchanges stolen GitHub Actions OIDC identity for npm publishing rights and signs through the real Sigstore path, so republished packages carry genuinely valid SLSA provenance. Provenance attests how a package was built, not whether the source it was built from was already trojanized.
Detection: Script-JS.Worm.ShaiHulud.
Package Background
@workbench-stack/core has been on npm since 2017-04-16. Supply chain attackers do not pick targets for what the code does -- they pick them for who maintains it and what that maintainer can publish. A single stolen npm token unlocks every package its owner can write to, which is precisely how a worm like this one turns one compromised laptop into hundreds of trojanized releases.
Attack Analysis
The delivery chain is deliberately split in two, so that the part visible in a manifest diff looks as boring as possible.
package.json gains one line: a preinstall hook that runs node setup.mjs. Two filenames are quietly appended to the files array so the new scripts are included in the published tarball. Nothing else in the manifest changes.
setup.mjs (11 KB in this package) is not the payload -- it is a runtime bootstrapper, obfuscated with a string array that is base64-encoded and then RC4-encrypted with a different key for every call site. Decoded, its job is straightforward: work out the host platform and architecture, including whether the machine uses musl by checking ldd output and /etc/os-release for Alpine, download the matching build of Bun v1.3.13 from the project's official GitHub release page, unpack the archive (via PowerShell Expand-Archive on Windows, unzip elsewhere, with an inline ZIP inflater as a fallback), mark the binary executable, and hand off to the payload. Using the real Bun distribution point means the only network request a casual observer sees at install time is one to a well-known open source project.
math_init.js is the payload -- 727,680 bytes of Bun-compiled JavaScript, identical in every affected package. Its strings are held in a shared 4,613-entry table where each entry is basE91-encoded, and each of the 46 or so bundled modules carries its own 91-character custom alphabet and decodes only the entries that belong to it. Recovering the alphabets and decoding the table exposes what the payload actually does.
It is built as a collection pipeline. A collector stage runs a set of independent source modules, a dispatcher stage batches whatever they return, and pluggable senders ship the batches out. The source modules cover a wide spread of credential stores: the AWS instance metadata service including the IMDSv2 token dance, the container credential endpoint used by ECS tasks, AWS STS for identity confirmation, AWS SSM Parameter Store queried with decryption enabled and retried across every region the account uses, HashiCorp Vault, npm tokens validated against the registry's whoami and token endpoints, and GitHub tokens exercised against the REST and GraphQL APIs. Alongside the secrets it collects basic host fingerprinting -- username, home directory, CPU count, total memory, network interfaces, and MAC addresses.
Two senders carry the results out, and the GitHub one does the work. It creates a repository under the compromised account and commits the stolen data into it through the API, using the victim's own git identity to do so. That choice is the whole trick: exfiltration that reads as a developer pushing code, from that developer's own account, to a host no engineering organization can plausibly block. The domain sender is the fallback -- it posts over HTTPS, but only after a health check confirms the destination resolves and responds. Both encrypt before transmitting: an AES session key wrapped with RSA-OAEP, so intercepted traffic yields nothing without the operators' private key.
The fallback is where the engineering shows. Its destinations are not compiled into the sample: the payload calls a smart contract, logs how many domains the contract returned, health-checks each one, and uses the survivors. The operators can therefore move that infrastructure by updating a contract rather than republishing packages, and pulling apart a captured sample will never reveal the current list. It is a resilient, low-effort answer to takedowns -- but it is not where the data has been going. The GitHub channel is, and that is the one to hunt.
The worm half is intact. The payload issues PUT requests to the npm registry to republish packages using stolen tokens, which is how a wave reaches hundreds of packages without central infrastructure. Republishing runs through the genuine signing path rather than around it: Sigstore certificate issuance through Fulcio, transparency log entries in Rekor, in-toto and SLSA statement construction, and an npm OIDC token exchange driven by GitHub Actions identity tokens. Nothing here is forged -- holding a stolen token and a borrowed CI identity, the worm is entitled to sign, so a republished package arrives with provenance that is not merely convincing but cryptographically valid. What that provenance attests is how the package was built, which says nothing at all about whether the source it was built from had already been trojanized.
Credential theft is not the end of it. With a stolen GitHub token in hand, the worm writes execution hooks into repositories it can reach -- an Environment Setup task in .vscode/tasks.json and a SessionStart hook in .claude/settings.json, each pointing at a copy of the bootstrapper dropped beside them as .vscode/setup.mjs and .claude/setup.mjs. Cloning an affected repository is then enough to be exposed, because opening the folder in an editor or starting an agent session inside it runs the payload again on a machine that never installed the package at all. Those commits are pushed across as many as 50 branches through the GraphQL API, so a protected default branch does not stop them landing somewhere.
Malicious Files
math_init.js -- the payload. A Bun-compiled JavaScript bundle carrying the credential collector, the encrypted exfiltration pipeline -- GitHub commits first, contract-resolved domains as fallback -- the npm republishing worm, and the Sigstore signing path it uses to re-sign what it republishes. Identical across the whole wave, which makes its hash the single most useful indicator here.
setup.mjs -- the bootstrapper. Obfuscated JavaScript that fingerprints the host, downloads and unpacks the Bun runtime from its official release page, and executes the payload. Contains no malicious behaviour of its own beyond fetching a runtime and running the next stage, which is exactly why it is easy to overlook.
package.json -- the weaponized manifest. Its only modifications are the injected preinstall hook and the two added entries in files.
Recommendations
Identify exposure: check whether @workbench-stack/core 3.9.8 appears anywhere in your dependency tree, including as a transitive dependency. Any machine or pipeline that ran an install while this version was current should be treated as compromised.
Rotate everything, in the right order: assume that npm tokens, GitHub tokens, AWS credentials reachable from instance or container metadata, SSM parameters, and Vault secrets present at install time were all taken. Revoke and reissue them before anything else, and start with the credentials that can publish packages.
Audit your publishing history: the worm's purpose is to spread. If a stolen token could publish other packages, review those packages for releases you did not make -- and do not treat valid provenance as proof of authenticity, because a compromised source produces validly signed malware.
Hunt the exfiltration channel, not just the domains: the data leaves through GitHub, in repositories created under the compromised account itself. List every repository your users and organization own and look for ones nobody created deliberately -- the description Shai-Hulud: Here We Go Again, names built from two Dune-lore words and a number such as fremen-thumper-796, and a single commit titled Add files.. Do not try to hunt this by commit author: the worm commits under the victim's own git identity, so the authorship looks entirely normal. Finding one of these repositories under a developer's account means that developer's tokens are gone.
Block the fallback, then prefer allowlists: block npm-cache.com at the egress layer. Because the backup destinations are fetched from a smart contract and rotate, an egress allowlist will outlast any blocklist you can maintain.
Check your repositories, not just your dependencies: this wave plants execution hooks in .vscode/tasks.json and .claude/settings.json, so simply opening an affected repository in an editor, or starting an agent session inside it, re-runs the payload on a machine that never installed the package. Inspect those paths across every branch of any repository a compromised account could write to, and treat their presence as an active infection rather than leftover debris.
Watch for the runtime download: an install-time request to the Bun release page from a machine that does not use Bun is a strong signal, as is an unexpected preinstall hook appearing in a manifest diff.
Pin to a clean release: remove 3.9.8 from all dependency and lock files and pin to a known-good prior version.
Scan your supply chain: use Spectra Assure Community at https://secure.software/npm/packages/@workbench-stack/core to check whether packages in your environment have been flagged as part of this campaign.