diff --git a/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-npm-supply-chain-abuse.md b/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-npm-supply-chain-abuse.md index dc59a594b5..09ac2c5b33 100644 --- a/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-npm-supply-chain-abuse.md +++ b/src/pentesting-ci-cd/github-security/abusing-github-actions/gh-actions-npm-supply-chain-abuse.md @@ -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 `[[26]](#references)[[28]](#references) +A `sources` entry can also run a shell command through GYP's `/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.[[29]](#references)[[30]](#references) + **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.[[27]](#references) Practical checks: @@ -59,6 +61,22 @@ Practical checks: - In `binding.gyp`, inspect `conditions` as well as `[[26]](#references)[[28]](#references) - Review release jobs that run `npm install`, `npm rebuild`, or dependency build steps after restoring untrusted artifacts/caches.[[2]](#references)[[11]](#references) +### 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.[[29]](#references)[[30]](#references) + +```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.[[29]](#references)[[30]](#references) + +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.[[29]](#references)[[30]](#references) + ## 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.[[16]](#references)[[17]](#references) @@ -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.[[1]](#references)[[2]](#references)[[8]](#references)[[13]](#references)[[15]](#references) +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/`.[[30]](#references)[[31]](#references) 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.[[2]](#references)[[31]](#references) 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.[[30]](#references) + Provenance answers **which workflow built this artifact**, not **whether the workflow, source tree, cache, or build steps were clean**.[[1]](#references)[[2]](#references)[[13]](#references)[[15]](#references) ### Build-to-publish privilege crossing @@ -262,8 +282,6 @@ Useful behavior-level signals from the same chain include:[[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`.[[18]](#references) - 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.[[18]](#references) -{{#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) @@ -294,3 +312,6 @@ Useful behavior-level signals from the same chain include:[[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/)