CVE case study

CVE-2026-55675: When a Fork PR Crosses into a Privileged Workflow

How untrusted pull-request code reached a secret-bearing CI job—and how separating build from deploy closed the trust-boundary failure.

This is a defensive case study of CVE-2026-55675, based on the coordinated public advisory. It explains the trust boundary, the failure mode, and the engineering fix without reproducing a weaponized exploit.

Severity
Critical · 10.0
Scoring
CVSS 3.1
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

GitHub Actions deliberately gives fork pull requests a restricted execution context. The danger appeared when a second workflow_run job—running in the base repository with deployment secrets and write permissions—trusted a pull-request commit reference produced by the first workflow.

The privileged job checked out that untrusted tree and installed its Python requirements. Package installation is executable behavior: build backends and setup hooks can run code. The result was unauthenticated code execution inside the deployment trust zone, not merely a malicious documentation preview.

How I discovered it

I started with a pwn-request question: does any workflow triggered after an external pull request regain secrets and then execute material selected by that pull request? The repository used two workflows, so I treated the artifact between them as a trust-boundary message rather than harmless build metadata.

The first workflow recorded the pull request number and head revision. I followed that revision into the workflow_run consumer, where it became the checkout target. The decisive observation came one step later: the privileged job ran pip install -r requirements.txt after checking out that tree. Package installation can execute setup.py and PEP 517 backend hooks, so the revision was not merely read—it selected code that would run with the deployment environment in scope.

My first report overemphasized the repository-fork guard. The coordinated public advisory corrected that framing: the preview flow intentionally supported fork pull requests, and the guard was not the security boundary. The actual invariant was simpler and stronger: a secret-bearing job must never execute the fork-controlled source tree.

Trust boundary and root cause

The flaw was a provenance failure between two otherwise legitimate workflows. An artifact carried attacker-influenced identity data across the boundary, but the receiving job treated it as authorization to fetch and execute that code.

A repository-fork guard did not solve this problem because the workflow intentionally supported previews from forks. The missing invariant was stronger: no untrusted source tree may execute in a job that holds secrets or write-capable tokens.

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.

Privileged workflow attack pathThe security boundary failed at the transition from an untrusted build request to a trusted deployment job.
  1. 01
    Fork contribution

    An external contributor controls the pull-request tree and dependency metadata.

  2. 02
    Artifact hand-off

    The unprivileged workflow records the pull-request revision for a later workflow.

  3. 03
    Trusted checkout

    A secret-bearing workflow_run job consumes that revision and checks out the external tree.

  4. 04
    Executable install

    Dependency installation evaluates attacker-controlled build logic inside the privileged environment.

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 → 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 in context

The public advisory scored the issue 10.0 because exploitation required no repository privilege or victim interaction and crossed into a separate security scope. Secrets available to the deployment job and write-capable repository automation were exposed to attacker-controlled code.

The broader risk was supply-chain compromise: a stolen deployment credential or repository token could affect infrastructure and published content beyond a single workflow run.

Remediation and verification

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.

Verification should assert that privileged jobs never run package managers, build hooks, scripts, or interpreters over pull-request-controlled files. Token permissions should be read-only by default, unused secrets should be removed, and artifact consumers should validate both provenance and expected inert content.

Engineering lesson

A two-stage pipeline is secure only when the hand-off is inert. Moving execution out of the privileged stage is more robust than trying to enumerate every dangerous file or package-manager behavior that could appear in an untrusted tree.

Primary public references

Disclosure boundary: only already-public technical detail is included here. Unpublished validation material, private communications, secrets, and weaponized payloads remain excluded.