Coordinated Disclosure Timeline

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:

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

Source

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:

Source

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 }}

Source

          git push origin HEAD:${{ steps.check.outputs.head-ref }}

Source

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.

Source

Attack sequence:

  1. An attacker opens a pull request from a fork, modifying rebuild-dist.yml to drop the Dependabot gate and to emit a chosen head-ref, a matching real head-sha, and malicious dist/ contents.
  2. The unprivileged producer run completes successfully with event == pull_request, publishing the rebuilt-dist artifact.
  3. workflow_run starts commit-dist.yml from the trusted default-branch definition, which downloads that artifact.
  4. Validation and the head check both pass, because the attacker supplied a syntactically valid branch name and the actual current tip of that branch.
  5. The workflow checks out the chosen branch, replaces dist/, commits, and pushes with contents: 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.

CWEs

Resources

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.