The Approval Bound the Wrong Commit: GitHub Actions TOCTOU
07:42:16Z: the release job prints Downloading artifact metadata. The pull request had maintainer approval. The producer workflow had no secrets and only a read token. The consumer workflow was defined on the default branch. Nothing in that summary sounds reckless.
Thirty seconds later, the consumer checks out a commit that was never reviewed.
This is a composite scenario based on disclosed GitHub Actions workflow vulnerabilities. The laboratory commands below use only local Git repositories, dummy files, and immutable test hashes.
Tuesday’s analysis of GitLab CVE-2026-97229 examined a native compiler failure inside the control plane. This GitHub Actions TOCTOU pattern sits one layer higher. The primitives behave as designed, but the workflow binds approval, artifacts, and privilege to different identities.
The approval did not bind the thing that ran
A common defensive pattern separates pull-request testing from privileged follow-up work. The first workflow handles attacker-controlled code with reduced permissions. A second workflow, triggered by workflow_run, downloads results and performs a sensitive action such as committing fixes, publishing a package, creating an attestation, or updating a release branch.
The separation is useful, but it creates a protocol. The producer emits data. The platform stores it. The consumer selects an artifact, recovers repository context, and decides which commit or pull request receives privilege. Every field in that handoff has a provenance requirement.
GitHub’s own workflow_run documentation warns that running untrusted code in this pattern can lead to cache poisoning or unintended access to secrets and write privileges. The event is not a trust verdict. It is only a trigger with context.
Three ways the handoff becomes attacker-controlled
1. Approval is checked against a mutable reference
A maintainer approves commit A. The privileged job later resolves a branch name, pull-request head, or merge reference and receives commit B. The approval is real, but its subject changed between check and use.
GitHub Security Lab documented this time-of-check to time-of-use pattern in marimo workflows. The operational mistake is subtle: store or inspect an approved SHA, then rebuild trust from a mutable ref when the privileged workflow begins.
2. Artifact selection is broader than the security decision
A consumer downloads artifacts without binding the request to an exact producer run and an exact expected name. Another run, fork, or artifact with a colliding name can enter the selection set. Extraction into a shared directory can then overwrite metadata that the privileged job treats as authoritative.
The actions/attest advisory describes artifact selection and poisoning weaknesses around attestation workflows. The lesson is broader than that repository: wildcard download is a data aggregation feature, not a provenance control.
3. Artifact data becomes control data
An artifact may legitimately contain test output, generated files, or scan findings. It should not silently choose the checkout ref, destination path, shell fragment, release target, or pull-request identity used by a more privileged job.
GitHub Security Lab’s nf-core/tools disclosure, published on October 1, 2026, covered multiple versions of this boundary failure, including artifact path overwrite, TOCTOU, and execution paths in privileged workflows. The specific YAML varied. The invariant did not: untrusted producer output influenced a consumer with more authority.

A realistic attack chain
- An external contributor opens a pull request. The unprivileged workflow executes tests and uploads an artifact.
- A maintainer reviews commit
Aand approves the run or leaves an approval comment. - The contributor pushes commit
B, changes artifact metadata, or creates a colliding artifact before the privileged consumer resolves its inputs. - The
workflow_runconsumer starts withcontents: write,packages: write,id-token: write, repository secrets, or an environment credential. - The consumer downloads by pattern, trusts a PR number from the artifact, or checks out the current branch head instead of the triggering
head_sha. - The sensitive action now applies to attacker-controlled state: a commit is written, a package is published, a container is signed, or an attestation is created for the wrong bytes.
This is the same structural error seen in GitLost: low-trust input crosses into a workflow that can reach a private or privileged resource. The exploit does not need to break the token. It only needs the token-bearing workflow to accept the wrong identity.
A local lab that isolates the race
The following control is intentionally small. It models a maintainer approving one commit while a branch moves before a consumer resolves it:
approved_sha="$(git rev-parse HEAD)"
# Approval happens here.
runtime_sha="$(git ls-remote origin refs/heads/pr-17 | cut -f1)"
if [ "$approved_sha" != "$runtime_sha" ]; then
echo "abort: approved SHA no longer matches"
exit 1
fi
git checkout --detach "$approved_sha"
An isolated run produced two different hashes and the vulnerable consumer would have built the later commit:
approved_sha=a3af57c5f16dde18d73bfda72725fdddaf0fe99f
runtime_branch_sha=24da6f58231da9821cea62bffcf8a1717e638fd0
vulnerable_consumer=would_build_unreviewed_commit
fixed_consumer=abort_sha_mismatch
The important control is not the comparison alone. The privileged workflow should consume the immutable SHA from the triggering event, verify that any approval refers to that SHA, and check out that exact object in detached mode. A branch name is navigation. It is not evidence.
Bind the consumer to immutable evidence
Start with the event, not the artifact. Treat github.event.workflow_run.id, head_sha, source repository, triggering workflow path, event type, and actor as the authoritative envelope. If the artifact contains the same values, compare them. Never let it supply the only copy.
Select one artifact from one run. Pass an explicit run ID and exact artifact name. Reject zero matches and multiple matches. Do not merge producer artifacts in a privileged workspace unless the design proves that every producer belongs to the same trust domain.
Extract into an empty directory. Reject absolute paths, parent traversal, symlinks, hard links, and unexpected filenames. Resolve every output path with realpath and require it to remain below the extraction root. The Nuclei template trust analysis reaches the same conclusion from a different toolchain: a reviewed engine does not make its inputs trustworthy.
Keep artifacts as data. A test report may influence a pass or fail decision. It should not become shell syntax, a checkout expression, a destination path, or a release identifier without strict parsing and an allowlist.
Minimize the privileged workflow. Declare explicit permissions, split package publication from repository writes, place production credentials behind protected environments, and avoid sharing writable caches across fork and trusted contexts. GitHub’s security hardening guidance also recommends pinning third-party actions to full commit SHAs and using CODEOWNERS to protect workflow changes.
Attest after trust is established. Artifact attestations can record where and how an artifact was built, as described in GitHub’s provenance documentation. They do not repair an ambiguous input selection. If the release job signs the wrong bytes, the resulting provenance can accurately describe the wrong build.
Detection: query for privilege transitions
Static review should prioritize workflows where workflow_run or pull_request_target meets a powerful permission. Search for these combinations:
workflow_runpluscontents: write,packages: write,pull-requests: write, orid-token: write.actions/download-artifactwithout an explicit producer run ID and exact artifact name.actions/checkoutwhererefcomes from an artifact, issue comment, PR body, branch name, or API lookup performed after approval.- Artifact fields interpolated directly into
run:, file paths, release names, environment names, or API endpoints. - Fork PR jobs and privileged jobs sharing the same writable cache key.
Runtime telemetry should record the triggering run ID, repository, event, actor, immutable head SHA, artifact ID, artifact digest, consumer workflow SHA, and final release digest. Alert when the checked-out SHA differs from workflow_run.head_sha, when more than one artifact matches, or when the producer repository is a fork but the consumer reaches a protected environment.
The older GitHub Security Lab guidance on preventing pwn requests remains operationally useful because it asks the right question: where does untrusted pull-request state first meet secrets, write permissions, or a trusted runner?
Incident handling starts with the handoff record
If a privileged workflow may have consumed attacker-controlled state, preserve the producer and consumer run metadata before rerunning anything. Capture artifact IDs and digests, event payloads, resolved SHAs, environment deployments, package versions, attestations, cache keys, and token permission sets.
Then revoke or rotate exposed credentials, quarantine packages and images produced by the affected runs, and compare the published digest with the digest expected from the approved commit. An SBOM and VEX pipeline helps only when the evidence chain can connect the released object to a specific trusted build.
Strategic takeaways
- Maintainer approval is a decision about an object. Preserve the object’s immutable identity.
workflow_runis a capability transition, not an automatic sanitization boundary.- Artifact names, paths, PR numbers, and branch refs are attacker-controlled until independently bound to event evidence.
- Provenance begins when inputs are selected. Signing the output is too late if the consumer chose the wrong input.
- The safest privileged workflow is small, deterministic, minimally permissioned, and unable to execute producer-supplied code.
Primary sources
- GitHub Security Lab: multiple vulnerabilities in nf-core/tools GitHub Actions workflows, published October 1, 2026.
- GitHub Security Lab: improper artifact selection and poisoning in actions/attest, published September 16, 2026.
- GitHub Security Lab: multiple vulnerabilities in marimo workflows, published September 16, 2026.
- GitHub Docs: workflow_run event.
- GitHub Docs: security hardening for GitHub Actions.
💜 Enjoyed this content? Support the blog with USDT (TRC20):
TX7obcjHQbDUXb4mGqoASEu1QFTKT2CFGG
