Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Not every install-time execution path is an explicit `package.json` script. When

GYP evaluates `conditions` as Python expressions during configuration, even if the declared target has `type: none`. In the analyzed variant, a condition traversed Python's class tree to find `catch_warnings`, recovered its module's `__builtins__` despite GYP's empty builtins dictionary, and used `__import__` to reach `os.system` and launch a script. Unicode escapes concealed the names and command. A comparison consumed the call's return value, making execution a side effect of evaluating the condition. Unlike earlier `<!(...)` command expansions, this path is easy to miss when scanning only for expansions or readable commands.<sup>[[26]](#references)[[28]](#references)</sup>

A `sources` entry can also run a shell command through GYP's `<!(...)` expansion. For example, `"sources": ["<!(node index.js >/dev/null 2>&1; echo stub.c)"]` runs `index.js` during configuration, then substitutes `stub.c` as the apparent source filename. This needs the implicit or an explicitly invoked `node-gyp` build; it does not run merely because `binding.gyp` exists in the tarball.<sup>[[29]](#references)[[30]](#references)</sup>

**This is not an automatic bypass of `--ignore-scripts`.** npm's implicit `node-gyp rebuild` is an install lifecycle hook, which `--ignore-scripts` suppresses. An explicit `preinstall` or `install` script also prevents npm from adding that hook. If a package has both a `preinstall` script and a poisoned `binding.gyp`, a separate invocation of `node-gyp` is needed to evaluate the GYP expression; an install with `--ignore-scripts` does not do so by itself.<sup>[[27]](#references)</sup>

Practical checks:
Expand All @@ -59,6 +61,22 @@ Practical checks:
- In `binding.gyp`, inspect `conditions` as well as `<!(...)` expansions; decode Unicode escapes before reviewing class or builtins access.<sup>[[26]](#references)[[28]](#references)</sup>
- Review release jobs that run `npm install`, `npm rebuild`, or dependency build steps after restoring untrusted artifacts/caches.<sup>[[2]](#references)[[11]](#references)</sup>

### Encrypted payload beside a precompiled Node addon

An npm package can make its install script look like a small native-helper call while keeping the JavaScript stage outside the visible entry point. A `postinstall` can select a platform-specific `.node` addon and call `init()` inside a `try/catch` that hides errors; the published tarball still needs the addon and its adjacent encrypted data file. This is an explicit lifecycle script, unlike the implicit `node-gyp` path above.<sup>[[29]](#references)[[30]](#references)</sup>

```json
{
"scripts": {
"postinstall": "node -e \"try{require(process.platform==='win32'?'./addon_win.node':'./addon.node').init()}catch(e){}\""
}
}
```

In the analyzed loader, the addon locates its own directory, reads a neighboring `.napi_cache`, XORs its bytes with `0x96`, and executes the recovered JavaScript with Node in a detached process. The Linux build uses `/proc/self/maps` to find the addon and `fork`/`setsid` to detach; the Windows build writes a hidden temporary script and starts Node with `CreateProcessA` and detached/no-window flags. The payload is therefore absent from the addon's readable JavaScript entry point, but not from the package's installed files.<sup>[[29]](#references)[[30]](#references)</sup>

This particular loader returns **before decryption** when it sees CI variables, container indicators, little RAM, one CPU, or a debugger; the Windows build also checks for analyst processes. An inert run in CI does not establish that a developer workstation will be inert. This gate is a property of the analyzed build, not of Node addons generally.<sup>[[29]](#references)[[30]](#references)</sup>

## Runtime Backdoors at Plaintext Secret Boundaries

A compromised publisher does not need an install hook: a malicious release can preserve the package API and instrument a wallet constructor, authentication helper, cryptographic factory, or credential parser. A hook placed before validation, derivation, hashing, or encryption receives the original secret, and downstream applications trigger it when they use the legitimate API. In one npm compromise, `fromMnemonic()` passed the complete recovery phrase to a function named `trackKeyDerivation()` before deriving a wallet; `fromHex()` passed a string-form private key, while its `Uint8Array` branch sent only a `bytes` marker.<sup>[[16]](#references)[[17]](#references)</sup>
Expand Down Expand Up @@ -201,6 +219,8 @@ A different carrier is a Solana transaction memo rather than contract state. A l

Trusted publishing/OIDC removes long-lived static npm tokens, but it does not make a compromised release workflow safe. If attacker-controlled code runs inside a workflow already authorized as an npm trusted publisher and granted `id-token: write`, the job can mint a GitHub OIDC token and exchange it for an npm publishing credential; the malicious release can also receive valid provenance because the legitimate workflow built and published it.<sup>[[1]](#references)[[2]](#references)[[8]](#references)[[13]](#references)[[15]](#references)</sup>

The direct exchange path does not require `npm publish` to discover OIDC automatically. In a job with `id-token: write`, code can request an identity token from `ACTIONS_ID_TOKEN_REQUEST_URL` using `ACTIONS_ID_TOKEN_REQUEST_TOKEN` and the audience `npm:registry.npmjs.org`, then POST that token as `oidcToken` to `https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/<package>`.<sup>[[30]](#references)[[31]](#references)</sup> The returned short-lived token can publish a rebuilt tarball with an added install hook only if the package's trusted-publisher configuration matches the job's repository and workflow and permits direct publishing.<sup>[[2]](#references)[[31]](#references)</sup> In the observed publisher, checks against `GITHUB_WORKFLOW_REF` and `GITHUB_REPOSITORY` kept its own payload quiet outside the target workflow; they were not npm authorization checks. The recovered code shows an attempted publishing path, not proof that this particular release was published by that workflow.<sup>[[30]](#references)</sup>

Provenance answers **which workflow built this artifact**, not **whether the workflow, source tree, cache, or build steps were clean**.<sup>[[1]](#references)[[2]](#references)[[13]](#references)[[15]](#references)</sup>

### Build-to-publish privilege crossing
Expand Down Expand Up @@ -262,8 +282,6 @@ Useful behavior-level signals from the same chain include:<sup>[[18]](#reference
- Review repository-scoped developer automation files such as `.vscode/tasks.json` and `.claude/settings.json` as executable supply-chain inputs, and protect non-default release branches as rigorously as `main`.<sup>[[18]](#references)</sup>
- Monitor unexpected runtime bootstrap downloads and package-manager child processes rather than limiting detections to Node.js internals; block release jobs from fetching unpinned runtimes where the build does not require them.<sup>[[18]](#references)</sup>

{{#include ../../../banners/hacktricks-training.md}}

## References

- [1] [What the Miasma campaign reveals about the new supply chain threat model and the underground market for developer credentials](https://www.tenable.com/blog/what-the-miasma-campaign-reveals-about-the-new-supply-chain-threat-model-and-the-underground)
Expand Down Expand Up @@ -294,3 +312,6 @@ Useful behavior-level signals from the same chain include:<sup>[[18]](#reference
- [26] [Shai-Hulud Trinitite Hits @7nohe/openapi-react-query-codegen | JFrog](https://research.jfrog.com/post/shai-hulud-trinitite/)
- [27] [npm-rebuild | npm Docs](https://docs.npmjs.com/cli/v11/commands/npm-rebuild/)
- [28] [GYP condition evaluation source | node-gyp](https://github.com/nodejs/node-gyp/blob/main/gyp/pylib/gyp/input.py)
- [29] [testmetest npm worm: Miasma variant | SafeDep](https://safedep.io/blog/testmetest-npm-worm-miasma-variant)
- [30] [Beyond testmetest: 42 Hijacked GitHub Repositories and Hidden CI Publishers | JFrog](https://research.jfrog.com/post/beyond-testmetest-42-hijacked-github-repos/)
- [31] [npm Registry API: OIDC token exchange](https://api-docs.npmjs.com/)
Loading