CVE case study

CVE-2026-55675: Fork PR Code Ran in a Privileged GitHub Actions Job

Untrusted pull-request code reached a CI job holding deployment secrets. Separating build from deploy removed that path.

Weakness
CWE-94 · CWE-829 · CWE-1357
Affected
Workflow state at and before d1ee8f2f90ad0143138cc69edf85679780e3154e
Remediation state
Workflow redesigned in commit 649c9ae; no package release applies
Advisory published
15 Jun 2026

Official vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Why it matters

Fork pull-request jobs do not receive base-repository secrets. The later workflow_run job did, and it trusted the pull-request commit SHA.

After checkout, the privileged job ran pip install -r requirements.txt. Setup.py or PEP 517 hooks could execute with deployment secrets and a write-capable token available.

How I found it

I checked whether a workflow triggered after an external pull request regained secrets and then executed material selected by that pull request. The repository used two workflows, with an artifact carrying data between them.

The first workflow stored the pull request number and head revision. The workflow_run consumer used that revision as its checkout target, then ran pip install -r requirements.txt. Python package installation can execute setup.py and PEP 517 backend hooks, so the pull-request revision selected code that ran inside the privileged job.

Because fork previews were intentional, the fix was to stop executing fork-controlled source in the secret-bearing job.

Root cause

The artifact carried the pull-request commit SHA into the privileged job. That SHA chose the code the job checked out and executed.

Fork previews were intentional. The fix had to keep fork-controlled code out of any job holding secrets or a write-capable token.

Source-to-sink trace

  1. 01
    External input.github/workflows/trigger-pr-build.yml

    The pull-request head revision is written into the pr_info workflow artifact.

  2. 02
    Boundary crossing.github/workflows/build-pr.yml · workflow_run

    The follow-on workflow runs in the base repository context and downloads the earlier artifact.

  3. 03
    Source selectionactions/checkout · ref from pr_info

    The artifact-controlled revision becomes the source tree used by the privileged job.

  4. 04
    Execution sinkpip install -r requirements.txt

    Python build hooks from that tree execute while deployment credentials and a write-capable token are available.

Safe proof of concept

Prerequisites

  • A disposable public GitHub repository and a fork or second account that you control.
  • Two lab workflows mirroring the public advisory's pull request, artifact, then workflow_run shape.
  • A repository secret DEMO_CANARY=owned-demo-only with no value outside the lab.

Step-by-step reproduction

  1. Create the unprivileged pull-request workflow. Have it save the PR number and head revision to an artifact; do not give this workflow secrets.
  2. Create the follow-on workflow_run job. For the vulnerable control, let it consume that revision, check out the PR tree, and install requirements.txt while exposing only DEMO_CANARY.
  3. In the controlled fork, add a local editable requirement that points to ./canary_pkg. The package hook below checks only whether the exact synthetic value is visible.
  4. Open a pull request to the disposable base repository and wait for both workflows. The vulnerable run prints the marker from the package-build process.
  5. Apply the architectural fix: build the site in the unprivileged PR job, upload static output, and make the privileged job deploy only that inert artifact. Repeat the pull request.

Disposable pull-request artifact workflow

name: Trigger PR Build (disposable lab)
on: pull_request
permissions:
  contents: read
jobs:
  capture:
    runs-on: ubuntu-latest
    steps:
      - name: Record controlled PR identity
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
          PR_SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          mkdir pr_info
          printf '%s' "$PR_NUMBER" > pr_info/PR_NUMBER
          printf '%s' "$PR_SHA" > pr_info/PR_SHA
      - uses: actions/upload-artifact@v4
        with:
          name: pr_info
          path: pr_info/

Intentionally vulnerable follow-on workflow

name: Privileged Canary Demo
on:
  workflow_run:
    workflows: ['Trigger PR Build (disposable lab)']
    types: [completed]
permissions:
  contents: read
  actions: read
jobs:
  demonstrate:
    runs-on: ubuntu-latest
    env:
      DEMO_CANARY: ${{ secrets.DEMO_CANARY }}
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: pr_info
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
          path: pr_info
      - id: pr
        run: echo "sha=$(cat pr_info/PR_SHA)" >> "$GITHUB_OUTPUT"
      - uses: actions/checkout@v4
        with:
          ref: ${{ steps.pr.outputs.sha }}
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: python -m pip install -v -r requirements.txt

Controlled fork content

# requirements.txt
-e ./canary_pkg

# canary_pkg/setup.py
import os
from setuptools import setup

visible = os.getenv("DEMO_CANARY") == "owned-demo-only"
print("CYBERKAREEM_POC_CANARY_VISIBLE=" + str(visible).lower())
setup(name="owned-canary-proof", version="0.0.1")

Fixed pull-request build workflow

name: Safe PR Build
on: pull_request
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: python -m pip install -v -r requirements.txt
      - run: mkdir -p site && printf '<h1>owned preview</h1>' > site/index.html
      - uses: actions/upload-artifact@v4
        with:
          name: built-site
          path: site/

Fixed inert-artifact deploy workflow

name: Safe Artifact Deploy
on:
  workflow_run:
    workflows: ['Safe PR Build']
    types: [completed]
permissions:
  contents: read
  actions: read
jobs:
  deploy:
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    env:
      DEMO_CANARY: ${{ secrets.DEMO_CANARY }}
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: built-site
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
          path: site
      - run: test -f site/index.html
      # Add the owned deployment action here.
      # No PR checkout, package manager, interpreter, or build hook runs in this job.

Evidence check after the lab run

gh run view LAB_RUN_ID --repo OWNER/DISPOSABLE_REPO --log \
  | rg 'CYBERKAREEM_POC_CANARY_VISIBLE'

# Vulnerable control:
# CYBERKAREEM_POC_CANARY_VISIBLE=true

# Redesigned build/deploy split:
# marker absent; no fork-controlled package hook runs in the deploy job

Expected evidence

  • The unprivileged pull-request job has no canary and cannot print a positive marker.
  • The intentionally vulnerable follow-on job prints CYBERKAREEM_POC_CANARY_VISIBLE=true, proving attacker-selected build code ran after the trust-boundary crossing.
  • No real secret is logged, transmitted, or retained by the PoC.
Negative control

Run the same package in the pull-request workflow before any privileged hand-off. The marker must be false. This separates ordinary untrusted code execution from execution in the privileged environment.

Fixed-version re-test

After moving package installation and site generation to the unprivileged job, the privileged deploy job should contain no checkout of the PR revision and no package/build command. Re-running the PR must produce only the prebuilt static artifact; the canary marker must be absent from the deploy logs.

Impact

The advisory scores this 10.0: no repository access or user interaction was required, and fork-controlled code ran in the deployment job.

An attacker could steal deployment credentials or use the repository token to alter infrastructure or published content.

Fix and retest

The patch moved dependency installation and site generation into the unprivileged pull-request workflow. The privileged follow-on job now receives only a prebuilt static artifact and deploys it; it does not check out or execute fork-controlled source.

Retest the workflow: no privileged job should check out a pull-request SHA or run a package manager, build hook, script, or interpreter over pull-request-controlled files.

Engineering lesson

Pass only built static files into the deploy job. Do not run fork-controlled code there.

References

Further reading

Evidence connected to this article.

Back to article start