CI Runner Token Extraction
- title
- CI Runner Token Extraction
- type
- concept
- summary
- Code on a CI runner reads the runner-process memory to extract OIDC tokens for direct registry use
- parent
- supply-chain-security
- tags
- security, github-actions, ci-cd, oidc
- created
- 2026-05-12
- updated
- 2026-05-12
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:
- Locate the
Runner.WorkerPID via/proc/*/cmdline - Read
/proc/<pid>/mapsto find the worker's address space layout - Read
/proc/<pid>/memto dump that memory - Pattern-match for a JWT (
eyJโฆbase64 prefix is a fast signal) - 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 hasid-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 addid-token: writeonly 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).pinactand Dependabot'spinnedmode 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