Coordinated Disclosure Timeline
- 2026-09-03: Reported through Private Vulnerability Reporting.
- 2026-09-14: Fixed in #488.
Summary
The commit-dist.yml workflow in the actions/attest project improperly trusts an artifact-supplied branch name. This allows attackers to compromise the workflow and push attacker-controlled dist/ content to a branch of their choosing, potentially affecting repository integrity.
Project
actions/attest
Tested Version
The default branch at 60c8019.
Details
Privileged commit-dist.yml trusts an artifact-supplied branch name, allowing attacker-controlled dist/ to be pushed to a chosen branch (GHSL-2026-225)
The repository automates rebuilding the bundled dist/ for Dependabot dependency bumps using a deliberate split-privilege design:
rebuild-dist.ymlis the unprivileged producer. It runs onpull_requestwithcontents: readand no secrets, and is gated to Dependabot by a job-level actor check.commit-dist.ymlis the privileged consumer. It runs onworkflow_runwithcontents: write, and commits the rebuilt bundle back to the pull request branch.
The intent is sound. The flaw is in how the two halves exchange the target branch.
The producer writes the branch name into artifact metadata:
echo "${{ github.event.pull_request.head.ref }}" > dist-meta/head-ref
echo "${{ github.event.pull_request.head.sha }}" > dist-meta/head-sha
An inline comment justifies this by stating that workflow_run cannot trust head_ref from its own event payload alone, so the value is handed over explicitly as artifact metadata. That premise is partly sound; github.event.workflow_run.pull_requests is genuinely unreliable.
head_branch and head_repository behave differently: both are always populated. Even so, head_branch on its own is not safe, and this is the legitimate half of the concern. For a pull request opened from a fork, the head branch name is chosen by the fork owner, who is free to name it main. A workflow that pushed to head_branch without inspecting the origin would therefore write to the base repository branch of that name.
The field that cannot be forged is head_repository.full_name. The correct remedy is to constrain the origin rather than to change the channel. Moving the branch name into an artifact does not remove attacker control over the value: it preserves that control, discards the one field that would allow the origin to be checked, and widens the range of reachable branches from names the attacker can create inside a fork to any branch name that already exists in the target repository.
The producer is attacker-controlled for fork pull requests. For pull_request events GitHub executes the workflow definition from the pull request merge commit, so a fork contributor can modify rebuild-dist.yml in their own pull request: keep name: Rebuild dist so the consumer workflows: filter still matches, remove the Dependabot actor gate at line 23, and upload an artifact named rebuilt-dist containing arbitrary dist/ bytes together with arbitrary head-ref and head-sha values.
The consumer guards do not constrain the branch. In commit-dist.yml:
- The validation step accepts any value matching
[A-Za-z0-9._/-]+, which includesmainand every existing branch name. It prevents workflow-command injection only, not branch selection. - The
Verify head is unchangedstep comparesgit rev-parse HEADagainst the artifact-suppliedhead-sha. Because the attacker supplies the true current tip of whichever branch they name, this check passes. It proves the branch did not move; it does not prove the branch belongs to the pull request that produced the artifact. - Nothing cross-checks
head-refagainstgithub.event.workflow_run.head_branch, and nothing rejects runs whosehead_repositoryis a fork.
The unvalidated value then reaches both a privileged checkout and a push:
- name: Checkout PR branch
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.check.outputs.head-ref }}
token: ${{ github.token }}
git push origin HEAD:${{ steps.check.outputs.head-ref }}
Between those two steps the attacker-supplied bytes replace the tracked bundle, so the commit that is pushed contains content taken directly from the artifact.
Attack sequence:
- An attacker opens a pull request from a fork, modifying
rebuild-dist.ymlto drop the Dependabot gate and to emit a chosenhead-ref, a matching realhead-sha, and maliciousdist/contents. - The unprivileged producer run completes successfully with
event == pull_request, publishing therebuilt-distartifact. workflow_runstartscommit-dist.ymlfrom the trusted default-branch definition, which downloads that artifact.- Validation and the head check both pass, because the attacker supplied a syntactically valid branch name and the actual current tip of that branch.
- The workflow checks out the chosen branch, replaces
dist/, commits, and pushes withcontents: write.
The default branch is protected, so a direct push to main is expected to be rejected by branch protection. The practical target is therefore a live Dependabot pull request branch, because an auto-committed dist/ on such a branch is precisely what a maintainer is primed to accept before merging. Pushes made with the default GITHUB_TOKEN do not re-trigger pull_request workflows, so check-dist.yml does not re-verify the committed bundle.
Impact
The current repository setting restricts pull requests to collaborators only. If pull request access is opened to everyone, this may allow an external contributor who has no write access to the repository to place attacker-chosen content into the tracked dist/ bundle on a branch of the repository, committed and pushed by a trusted automation identity.
- Escalation of privilege from fork pull request author to repository branch write, performed by the privileged workflow on behalf of the attacker.
- Code execution in downstream consumers if the poisoned bundle reaches a consumed ref, since
dist/is the compiled action entry point executed by every workflow that uses this action. - Integrity loss in review: the poisoned commit is authored by
github-actions[bot]with the messagebuild: rebuild dist/ for dependency bumpand is not re-verified bycheck-dist.yml.
CWEs
- CWE-349: “Acceptance of Extraneous Untrusted Data With Trusted Data”
- CWE-501: “Trust Boundary Violation”
- CWE-829: “Inclusion of Functionality from Untrusted Control Sphere”
Resources
- GitHub Security Lab: Preventing pwn requests
- GitHub Docs: Events that trigger workflows, workflow_run
Credit
This issue was discovered and reported by GHSL team member @JarLob (Jaroslav Lobačevski).
Contact
You can contact the GHSL team at securitylab@github.com. Please include a reference to GHSL-2026-225 in any communication regarding this issue.