# CI Runner Token Extraction

A class of attack where malicious code that has gained execution on a CI runner reads the runner-host process's memory directly to pull out an OIDC token — and then uses that token to authenticate against a downstream service (npm, PyPI, AWS via federated identity) *outside* the workflow's declared publish steps.

This is the link that turns "code execution on the runner" into "wrote to the production package registry."

## How it works (GitHub Actions specifics)

GitHub-hosted runners run a `Runner.Worker` process that handles workflow execution. When a workflow declares `id-token: write` and a step requests an OIDC token (`getIDToken()` in `@actions/core`, the implicit call inside Trusted-Publishing-aware steps, etc.), the runner mints a JWT lazily and holds it in memory.

If attacker code is on the same machine — running as the same user as the worker, which it is by default — it can:

1. Locate the `Runner.Worker` PID via `/proc/*/cmdline`
2. Read `/proc/<pid>/maps` to find the worker's address space layout
3. Read `/proc/<pid>/mem` to dump that memory
4. Pattern-match for a JWT (`eyJ…` base64 prefix is a fast signal)
5. POST the JWT to the registry's OIDC-trusted-publisher endpoint

The token is valid until expiry — usually minutes — and any publish that lands inside that window is indistinguishable from a publish the legitimate `Publish Packages` step would have made. The registry sees a workflow with a valid binding asking to publish; it has no per-step provenance to consult.

The same memory-dump script (verbatim, with attribution comment) has been observed in:

- **tj-actions/changed-files compromise, March 2025** — original publication via a force-pushed tag on an unpinned action.
- **TanStack `@tanstack/*` compromise, May 2026** — see [[tanstack-npm-supply-chain-postmortem]].

The script's authors call this technique [out of scope for the registry to defend against] — and they're right. The defense has to be on the workflow side.

## Why the workflow's `Publish Packages` step doesn't matter

A common false sense of safety: "Our publish step has a manual approval / a separate environment / a hardware-key-signed step." None of that matters if the OIDC token is mintable from *any* job in the workflow with `id-token: write`. The attacker doesn't need to enter your publish step. They mint the token themselves and POST directly to the registry.

The TanStack incident is the worked example. The legitimate `Publish Packages` step *was skipped* because tests had failed. The publishes still happened — from the test/cleanup phase, where attacker binaries ran after the poisoned cache was restored.

## Defenses

The strong fix is **separation of execution from token possession**.

**Move publish to a job that runs no contributor code.**

- A typical safe shape: job 1 builds and produces an artifact (uploaded via `actions/upload-artifact`). Job 2 has `id-token: write`, runs no third-party deps, downloads the artifact, verifies it against a hash manifest signed elsewhere, and publishes. If job 2 has nothing in its environment that *can* run attacker code, no in-memory token can be reached.

**Minimize `id-token: write` to one job.**

- Set `permissions: {}` at the workflow level and add `id-token: write` *only* in the publish job. Default-deny per job.

**Don't restore caches in the publish job.**

- The publish job should not invoke `actions/cache@v…` for any path attacker code could have written. Pull dependencies fresh in that job, or restore from a cache scope no PR-triggered run could have written to. This breaks the [[github-actions-cache-poisoning]] → token-extraction bridge.

**Pin all actions to commit SHAs.**

- A floating `@v…` tag on a third-party action is itself an extraction vector (the tj-actions/changed-files mechanism). `pinact` and Dependabot's `pinned` mode automate this.

**Provenance-source-verification (registry-side, ecosystem-wide).**

- The structural fix is for registries to record which workflow file path and step ID produced the publish, and let publishers declare which step is allowed to mint. This is being discussed; it isn't shipped. Until it is, the publisher-side mitigations above are the whole story.

## See also

- [[supply-chain-security]]
- [[long-lived-keys]] — Trusted Publishing is the right direction; this concept is about the gap that remains
- [[pwn-request-pattern]] — common entry point that gives the attacker code execution
- [[github-actions-cache-poisoning]] — the bridge from one workflow's execution to another's runtime
- [[tanstack-npm-supply-chain-postmortem]] — the chain in action
- StepSecurity writeup of the tj-actions case: <https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised>
