Worm compromises hundreds of popular npm packages | Datadog Security Labs

·

17 min read Original article ↗

Key points and observations

  • On August 4, 2026, a threat actor compromised hundreds of npm packages and used them to propagate a backdoor.
  • Some of these npm packages have over 150 million weekly downloads.
  • The injected payload can spread on its own, similar to the Shai-Hulud npm worm.
  • To check whether you're affected, jump to How to know if you're affected.

What happened

On August 4, 2026, we identified a malicious commit (174f6a5) in the GitHub repository of the popular keyv npm package.

The malicious commit 174f6a5 has since been deleted on GitHub (click to enlarge)
The malicious commit 174f6a5 has since been deleted on GitHub (click to enlarge)

A few minutes later, the same user pushed similar commits to other projects, including jaredwray/cacheable and jaredwray/ecto.

Another malicious commit in the `jaredwray/ecto` repository (click to enlarge)
Another malicious commit in the `jaredwray/ecto` repository (click to enlarge)

Timeline

Time Event
August 4, 09:02:37 UTC Unsigned commit 29c773…, adds setup.mjs, Math_Symbol.js, the malicious preinstall, package inclusion, an executing test, and release configuration. This is the earliest recovered malicious state.
09:03:24–09:03:44 UTC The account creates release/v6.0.0-latest-config and opens Keyv pull request #2042. This is the first captured incident-period public event.
09:04:27–09:04:31 UTC Five GitHub-signed github-actions[bot] commits add identical Claude and Visual Studio Code execution hooks and payload blobs to v5, v4, the release lineage, main, and an existing Claude-named branch.
09:08:18 UTC A comment from the account warns that automated commits added unexpected .claude and .vscode hooks and downloader code: “Do not merge or publish until the repository and credentials are investigated.”
09:12:13 UTC The release branch is reset non-fast-forward from 2fd79f… to 29c773…. The reset removes the visible repository hooks but retains the npm loader, second stage, preinstall, and executing test already present in 29c773….
09:13:25–09:13:27 UTC Signed bot commit 5da403… restores the exact hook blobs. Pull request #2043 then closes with the comment “Closing: merging directly to main.”
09:14:58–09:18:28 UTC GH Archive records a bot push to main. Unsigned commit ee2681… then replays the 27-file 29c773… package delta directly onto hook-bearing main, which moves to that commit without a recorded pull request merge.
09:23:50–09:24:37 UTC Commit f97eabc… deletes only the 22-line test that directly invokes the loader, while retaining preinstall, both payloads, and the repository hooks. main moves to f97eabc….
09:29:50–09:35:01 UTC Genuine SLSA provenance for keyv@6.0.0 enters Rekor, binding tag v6.0.0 and f97eabc… to .github/workflows/release.yaml and Actions run 30896232272. The confirmed malicious package publishes at 09:35:00.763.
09:53:17–09:54:12 UTC Signed bot bursts continue on v4, v5, and main. The Keyv v6.0.0 GitHub Release object is created at 09:54:12, after the npm publication. It remains unpublished until 11:28:45.
10:06:08–10:07:17 UTC Unsigned Cacheable commit 893f73f… reuses the exact Keyv payload blobs across ten workspaces. It is pushed to main, and GitHub release setup-files-v1 publishes with a body explicitly describing the two payload files and preinstall.
10:09:44–10:14:41 UTC Nine confirmed malicious Cacheable-family versions publish from 893f73f… through Actions run 30899303541: @cacheable/net@2.1.1, @cacheable/node-cache@3.1.2, cacheable@2.5.1, flat-cache@6.1.24, cacheable-request@13.0.20, @cacheable/memory@2.2.1, file-entry-cache@11.1.6, @cacheable/utils@2.5.1, and cache-manager@7.2.10.
10:25:47–10:28:02 UTC Unsigned Ecto commit 983ce1a… adds the same Keyv payload blobs and preinstall, is pushed to main, and becomes release v5.0.1. Confirmed malicious ecto@5.0.1 publishes at 10:28:01.451 from Actions run 30900695639.
11:28:36–11:28:45 UTC The Keyv v6.0.0 tag is deleted for the fourth observed time, then the GitHub release created at 09:54 publishes almost two hours after the npm artifact. A GitHub ReleaseEvent is therefore not a reliable npm publication marker in this incident.
11:30:02–11:47:55 UTC Independent GH Archive events record further signed github-actions[bot] pushes on v5, v4, and the old Claude-named branch.

Analysis of the Payload

The following chain applies when a compromised package invokes the loader through a life cycle script and the package manager permits dependency life cycle scripts:

Phase Behavior Result
Initial execution npm invokes node setup.mjs through preinstall The loader runs during installation without an application importing the package
Runtime staging The loader accepts an installed bun or downloads Bun v1.3.13 The second stage runs under Bun rather than the npm-spawned Node.js process
Backgrounding Outside recognized CI, the second stage respawns itself detached Collection can continue after the parent installation process ends
Collection Files, environment variables, runner memory, and cloud secret stores are queried GitHub, npm, cloud, CI, and application credentials may be exposed
Exfiltration and C2 Contract-selected HTTPS endpoint, with a signed GitHub fallback for domain discovery; public-repository fallback for exfiltration Encrypted results leave the host; a primary C2 response can deliver more code
Propagation Valid npm or GitHub credentials are used conditionally Additional packages or repository branches receive the payload

The first-stage Bun loader

The 11 KB setup.mjs file is lightly obfuscated and has one job: start the second stage under Bun. It first invokes bun --version. Any executable named bun that exits successfully is accepted; the loader does not require version 1.3.13 when Bun is already installed.

If Bun is absent, the loader maps the host to one of the following release assets:

  • Linux AArch64
  • Linux x86-64 with glibc
  • Linux x86-64 with musl
  • macOS AArch64 or x86-64
  • Windows AArch64 or x86-64

The musl check is narrow. It examines ldd --version output and looks for the case-sensitive string Alpine in /etc/os-release; there is no musl-specific AArch64 target. It then constructs an official Bun release URL under:

https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/<asset>.zip

The downloader uses a node User-Agent, permits up to five HTTP 301, 302, 307, or 308 redirects, and applies a 121-second timeout to each request. It places the archive in a temporary directory whose name begins with bun-dl-. There is no response-body size limit and no origin restriction after a redirect.

Extraction prefers the system unzip utility on every operating system. If unzip is unavailable on Windows, it tries PowerShell Expand-Archive; the final fallback is a built-in parser for stored or deflated ZIP entries. If an available external extractor fails, the code does not fall back to the internal parser. On non-Windows systems the extracted runtime is made executable with mode 0755.

Finally, the loader synchronously starts Bun with the second-stage path and inherits the current working directory, environment, and standard streams. A finally block removes the temporary directory. If Bun was already installed, the download, extraction, and temporary-directory behavior do not occur.

The loader obtains an official Bun release asset but does not cryptographically verify it. GitHub can also redirect release downloads to other hosts, so github.com is not necessarily the only network destination. Running the payload under Bun may evade monitoring limited to Node.js runtime internals, but host-level endpoint detection can still observe the process tree, files, and network activity.

Inside the second stage

The second stage begins with // @bun @bun-cjs and is packed into two physical lines. Its obfuscation combines a 4,613-entry Base91 string table, a 1,283-element constant array, per-call alphabet permutations, name shadowing, split strings, and flattened generator state machines. We counted 73 Base91 alphabet literals, all of them unique permutations.

Backgrounding and execution guards

Outside environments recognized as CI, the payload respawns its current executable with the same arguments using detached: true, ignores its standard streams, adds _NODE_RUNTIME_INIT=1 to prevent recursion, calls unref(), and exits the parent. It creates a single-instance lock named tmp.dpkg_14527.lock under the operating system's temporary directory. It also installs no-op handlers for SIGINT and SIGTERM.

The source includes Exiting as russian language detected!, but the obfuscated dispatcher resolves the corresponding condition to a function that always returns false. There is no reachable locale inspection on that branch. The string may be inherited from another build or left as misdirection; it is not an active geofence here.

Credential collection

The implementation has three fast collectors, five deeper collectors, and one conditional GitHub Actions collector. domain is an exfiltration sender, while npm token handling belongs to the propagation path.

Source Data acquired Important conditions and limitations
File system Full contents of selected files, with GitHub and npm token matches identified 5 MiB limit per file, two concurrent readers, no aggregate cap
Shell and environment Entire process.env plus gh auth token output Requires GitHub command-line interface (CLI) authentication for the command to yield a token
GitHub Actions runner Secret names and values recovered from Runner.Worker memory Linux only, GITHUB_ACTIONS=true, and successful sudo access to /proc/<pid>/mem
AWS Security Token Service (STS) Account, Amazon Resource Name (ARN), user ID, source, and whether credentials appear static Resolves credentials first; STS collector itself reports identity metadata rather than the secret key values
AWS Secrets Manager Secret strings or binary secret values Uses credentials with sufficient permissions across 17 configured commercial regions
AWS Systems Manager Parameter Store Parameter names and decrypted values DescribeParameters pages of 50; GetParameters batches of 10 with WithDecryption:true
Kubernetes Namespaces and Base64-decoded Secret data In-cluster API host is required; five namespaces are skipped; kubeconfig API server parsing is incomplete
HashiCorp Vault Key-value (KV) v1 or v2 values First 100 top-level keys per mount; nested keys ending in / are skipped
GitHub Actions workflow abuse Values exposed through ${{ toJSON(secrets) }} Requires a valid token with workflow scope and a repository where the injected workflow can run

File system and runner collection

The file system configuration contains 290 Linux patterns, 129 macOS patterns, and 50 Windows patterns. Targets include environment files, shell histories, SSH material, Git, npm, PyPI, cloud, Kubernetes, Docker, Vault, Terraform, CI, and AI assistant credentials, along with cryptocurrency wallets, VPN configurations, /etc/shadow, and /proc/self/environ. Each matching file is read in full up to 5 MiB. The scanner does not enforce an overall count or byte limit.

On a Linux GitHub Actions runner, the payload pipes an embedded Python program into sudo python3. That program locates the Runner.Worker process, reads each readable mapping through /proc/<pid>/mem, and emits the bytes. A shell pipeline removes null bytes and extracts serialized records with an isSecret: true flag. The parser explicitly drops the github_token entry but retains other discovered names and values.

Cloud secret stores

AWS credential resolution tries environment variables, web identity, Amazon Elastic Container Service (Amazon ECS) metadata, Amazon Elastic Compute Cloud (Amazon EC2) Instance Metadata Service Version 2 (IMDSv2), and shared credential and configuration profiles. The code does not fall back to IMDSv1. Secrets Manager and Systems Manager iterate 17 hardcoded commercial regions rather than every AWS region.

The Kubernetes collector queries the in-cluster API, lists non-system namespaces, retrieves Secret objects, and Base64-decodes their data fields. It can extract a bearer token from kubeconfig, but does not parse the kubeconfig API server; it still depends on the in-cluster host environment variable. The Vault collector checks environment and local token files and includes Kubernetes and AWS authentication helpers. Its nominal AWS login sends only a role, which is insufficient for a normal AWS Identity and Access Management (IAM) login flow, so that branch is unlikely to succeed as written.

These imperfections are operationally relevant but do not diminish the breadth of the working paths. When the process runs with cloud or cluster privileges, the collectors attempt to convert those privileges into plaintext secret values rather than merely inventorying secret names.

How GitHub secret names become values

GitHub's representational state transfer (REST) endpoints for repository and organization Actions secrets return names and metadata, not secret values. The malware follows that model: it maps the REST responses to each secret's name. GitHub documents that these APIs do not reveal encrypted values.

The next step is active workflow abuse. For a validated classic GitHub token whose scopes include workflow, the payload:

  1. Enumerates up to 100 pushable repositories updated since September 1, 2025.
  2. Selects repositories with visible repository or organization secret names.
  3. Creates dependabot/github_actions/format/setup-formatter.
  4. Commits .github/workflows/codeql_analysis.yml.
  5. Starts a workflow that assigns ${{ toJSON(secrets) }} to an environment variable, writes it to format-results.txt, and uploads that file as an artifact.
  6. Downloads and reads the artifact.
  7. Deletes the workflow run and branch.

The injected workflow can recover values made available to its job. Environment-scoped secrets that the job cannot access remain out of reach, and the cleanup may leave audit-log, workflow-run, and artifact records even after the temporary branch is gone.

C2 discovery and encrypted exfiltration

The primary sender does not contain a fixed destination domain. Instead, it iterates a list of 73 Ethereum remote procedure call (RPC) endpoints and makes an eth_call to:

Contract: 0xE1f2395ee43e45A1556EC6438a88c31B83493103
Selector: 0x53ed5143
Block:    latest

The response parser decodes an application binary interface (ABI)-encoded dynamic array of strings. This establishes the smart-contract dead drop directly; it is not an inference from a log string. Each RPC attempt has a 3-second timeout. The malware resolves each returned domain and performs an HTTPS GET to port 443 on /router. Only status 400 or 404 is considered healthy. It uses the first healthy domain for exfiltration. The RPC hosts are mostly legitimate public infrastructure.

If the contract path fails, the malware searches GitHub commits for thebeautifulmarchoftime , including the trailing space. Its parser then accepts a deliberately different marker, thebeautifulsnadsoftime, followed by a Base64-encoded domain and RSA signature using SHA-256. The signature is checked with a second embedded 4,096-bit public key before the domain is used.

Before transmission, the payload serializes results as JSON and compresses them with gzip. It generates a random 32-byte AES key and 12-byte initialization vector, encrypts the data with AES-256-GCM, and appends the 16-byte authentication tag. It wraps the AES key with RSA Optimal Asymmetric Encryption Padding (RSA-OAEP) using SHA-256 and an embedded 4,096-bit public key. The posted object contains the Base64-encoded encrypted envelope, the wrapped key, and a 32-character host fingerprint derived from host, user, CPU, memory, platform, architecture, home directory, and media access control (MAC) address data.

generateKeyPairSync() creates an ephemeral P-256 key for the Sigstore path; it is not part of the RSA envelope. The exfiltration public key is embedded, and the AES key flows directly into publicEncrypt() with RSA-OAEP and SHA-256.

C2 responses create a code-execution channel

After the primary sender receives HTTP 200, it parses the body as JSON and handles it as follows in deobfuscated form:

const responseBody = JSON.parse(await response.text());
if (responseBody.code) {
  await eval(responseBody.code);
}

The response body is not signed, and the code is not sandboxed. Transport Layer Security (TLS) authenticates whichever contract-selected domain the process contacts, but it does not constrain what that domain returns. Anyone controlling a selected domain and its valid HTTPS endpoint can execute arbitrary JavaScript inside the Bun process.

The fixed code focuses on credential theft and propagation, but this response handling also makes the implant a general code-execution channel. The absence of a hardcoded wiper does not prevent the operator from deleting files, installing another implant, using collected credentials, or taking destructive cloud actions.

GitHub as the exfiltration fallback

When no primary domain sender is available, the payload looks for classic GitHub personal access or OAuth tokens beginning with ghp_ or gho_. It validates a token through /user, inspects the x-oauth-scopes response header, and requires a token capable of creating a public repository. It also checks that the user profile is publicly reachable.

The fallback creates a public repository with a randomized Dune-themed name and the description: Shai-Hulud: Here We Go Again

GitHub repository created by the malware for exfiltration (click to enlarge)
GitHub repository created by the malware for exfiltration (click to enlarge)

It writes Base64-encoded result files below results/ with names beginning results-<timestamp>-<counter>.json. Objects larger than 30 MiB are split into .p1, .p2, and subsequent parts. Uploads use the GitHub REST Contents API and retry HTTP 422 and server errors up to five times. The GraphQL commit functions elsewhere in the file are not used by this exfiltration path.

If the compromised user belongs to no GitHub organizations, the payload includes the GitHub token in the result object and commit message after applying Base64 twice. The exact relay marker is: IfYouBlockThisAPIKeyItWillCrashTheLiveProductionServersOfAllThirdPartyClients

The payload can search public commits for that marker, decode the token, and reuse it if it retains repo or public_repo scope. A public exfiltration repository can therefore double as a token relay.

Two propagation systems and one provenance path

The file contains several independent subsystems with different credentials, targets, and publication semantics.

Propagation with stolen npm tokens

For each collected token matching npm's granular-token format, the payload queries npm's token API and requires a matching record with bypass_2fa: true and package-write permission. Organization package enumeration also requires organization-write access. It then identifies writable packages, downloads the current latest tarball, and modifies it as follows:

  1. Copies the running second stage to package/math_init.js.
  2. Writes the embedded 11 KB loader to package/setup.mjs.
  3. Replaces the entire scripts object with {"preinstall":"node setup.mjs"}.
  4. Increments the patch version.
  5. Repacks and publishes directly with the stolen token.

Generic npm propagation runs on Linux and macOS. Replacing the complete scripts object can break legitimate build, test, or release behavior in the victim package. The registry publish request also sets rejectUnauthorized: false, disabling server-certificate verification for that operation.

Generic npm-token propagation does not generate provenance. It uses the stolen registry credential and a direct publish request.

Propagation across GitHub branches

A separate path uses GitHub credentials in an Actions environment to fetch up to 50 branches, excluding Dependabot- and Copilot-prefixed refs, and writes five files to each eligible branch:

.vscode/tasks.json
.claude/math_init.js
.claude/settings.json
.claude/setup.mjs
.vscode/setup.mjs

The Visual Studio Code (VS Code) task runs node .claude/setup.mjs when a folder opens. The Claude settings install a SessionStart hook that runs node .vscode/setup.mjs. These files can retrigger the loader when a developer opens the repository or starts a Claude session.

Provenance can authenticate malicious content

The provenance implementation is real but narrowly gated. It runs only when GITHUB_REPOSITORY contains /opensearch-js and GITHUB_WORKFLOW_REF contains release-drafter.yml. It targets @opensearch-project/opensearch, increments the patch version, and adds this optional dependency:

@opensearch/setup = github:opensearch-project/opensearch-js#d446803f4c3bc116263faa3499a1d3f95b2825de

That path attempts to construct an in-toto statement with Supply-chain Levels for Software Artifacts (SLSA) provenance for the modified tarball, request a GitHub OpenID Connect (OIDC) token for Sigstore, generate an ephemeral P-256 key, obtain a Fulcio certificate, submit the signed envelope to Rekor, attach a Sigstore bundle, request an npm-audience OIDC token, exchange it for a short-lived npm publishing token, and publish. Static analysis establishes the request sequence, not that the external services accepted a particular run. If provenance generation fails, the code still attempts a publish without the bundle.

npm provenance establishes where and how a package was built. In a compromised workflow, a valid attestation can faithfully bind a malicious source and an attacker-controlled build to the resulting package. It is neither forged nor a safety verdict. Provenance is valuable evidence, but consumers must still inspect the referenced source, workflow, and artifact.

How to know if you're affected

The situation is evolving quickly. Below, we maintain a dynamic list of npm packages that this campaign compromised and used to distribute the malicious payload. You can also access a CSV version on our GitHub repository.

Show the list of compromised npm packages

Loading latest data…

How Datadog can help

Datadog Code Security can identify hosts, containers, and build environments where the compromised version was installed. Refer to the in-app Security Research Feed card for more information.

Datadog also maintains the open source supply-chain security firewall (SCFW), which can proactively block known malicious packages at installation time. It can also log every single npm and PyPI package installed on a developer's workstation and ship it to your Datadog organization, allowing you to react when new malicious or compromised packages are discovered.

Indicators of Compromise

Files and embedded artifacts

Artifact SHA-256
Stage-two payload, math_init.js or Math_Symbol.js 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc
Embedded and propagated loader, setup.mjs fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb
.vscode/tasks.json 927387d0cfac1118df4b383decc2ea6ba49c9d2f98b47098bcbcba1efc026e1f
.claude/settings.json 14eb4ce01dd4307759887ff819359b70d7d9ff709ecde039a5abc1aac325b128
Injected GitHub workflow 3f3f42d072bd36860ab7bd7fb5e10ac0d22c741c13c89505ccd6ec0ea572eea7
Runner-memory Python 29ac906c8bd801dfe1cb39596197df49f80fff2270b3e7fbab52278c24e4f1a7
Token-monitor installer 619c56acf572df75b6004a6fc013c80900316a76099b241d64312da3a44f10b4

The SHA-256 fingerprint of the exfiltration RSA public key in Distinguished Encoding Rules (DER) form is dc1e6a7ddb29390dd53cf1e5aac40ad9204ea7c6b83ef5656e7cb7a796808b67. The fallback-domain verification key fingerprint is 166be2b7b58a440f7b17520ffb0368be5d89c76661704b4945417eb04b9ada65.

Network and behavior

Indicator Context
0xE1f2395ee43e45A1556EC6438a88c31B83493103 Ethereum C2 dead-drop contract
0x53ed5143 eth_call selector used against the contract
https://awqhnjewqjkl[.]icu:443/router C2 URL for exfiltration
https://pypi-get[.]com:443/router C2 URL for exfiltration
https://js-mirror[.]com:443/router C2 URL for exfiltration
https://npm-cache[.]com:443/router C2 URL for exfiltration
thebeautifulmarchoftime GitHub commit-search query; trailing space is significant
thebeautifulsnadsoftime Marker parsed from signed fallback commit messages
IfYouBlockThisAPIKeyItWillCrashTheLiveProductionServersOfAllThirdPartyClients GitHub token-relay marker
Shai-Hulud: Here We Go Again Public GitHub exfiltration-repository description
tmp.dpkg_14527.lock under the temporary directory Single-instance lock
_NODE_RUNTIME_INIT=1 Detached-child recursion guard

Dormant token-monitor paths

~/.local/bin/gh-token-monitor.sh
~/.config/gh-token-monitor/token
~/.config/gh-token-monitor/handler
~/.config/gh-token-monitor/started_at
~/Library/LaunchAgents/com.user.gh-token-monitor.plist
~/.config/systemd/user/gh-token-monitor.service
/tmp/gh-token-monitor.out.log
/tmp/gh-token-monitor.err.log

Acknowledgements

Thank you to Ian Kretz, Tesnim Hamdouni, and Eslam Salem, who contributed to the making of this post.

Updates made to this entry