All posts
Unproven Execution

prt-scan: pull_request_target as Unproven Execution

Six waves of malicious PRs hijacked GitHub Actions runners whose pull_request_target workflows executed fork-supplied code with secret scope

Securityv0 Intelligence Team OWASP: ASI05 sv0 finding: unproven_execution
prt-scan github-actions unproven-execution supply-chain ci-cd asi05

The Incident

On 2026-04-02, security researcher Charlie Eriksen of Aikido Security publicly disclosed an automated GitHub Actions supply-chain campaign tracked as prt-scan. Wiz Research subsequently consolidated the activity as a single six-wave operation that began on 2026-03-11 — three weeks before public disclosure — and clustered six attacker accounts under one operator. Across the waves, the operator opened more than 500 malicious pull requests against public repositories whose workflows use the pull_request_target trigger. Across roughly 450 analyzed exploit attempts, the campaign achieved an under-10% success rate but compromised approximately 50 repositories’ AWS, Cloudflare, and Netlify credentials and produced two confirmed npm package compromises.

The mechanic was repetitive and statically detectable. The attacker forked a target repo, created a branch named prt-scan-{12-hex-chars}, injected a payload into a CI-relevant file (conftest.py, package.json, Makefile, or build.rs), and opened a PR titled ci: update build configuration to blend in. Inside the privileged pull_request_target runner, the payload extracted GITHUB_TOKEN from git config and emitted base64-encoded credential blocks to the workflow log for later harvesting. Wave six escalated to per-repository, language-aware payloads that mimicked each target’s coding conventions — agentic reconnaissance attached to an offensive supply-chain pipeline.

MITRE ATT&CK coverage: T1195.002 (Compromise Software Supply Chain), T1059 (Command and Scripting Interpreter), T1552.001 (Credentials In Files).

The Authority Path That Failed

The identity carrying execution authority at the moment of failure is the GitHub Actions runner of the base repository, executing under the privileged context that pull_request_target confers. The scope it held is the full GITHUB_TOKEN for the base repo plus every secret referenced by the workflow — AWS access keys, Cloudflare API tokens, Netlify deploy tokens, npm publish credentials. The scope it exercised in success cases was running attacker-authored code from the head of an untrusted fork PR — conftest.py invoked by pytest, package.json lifecycle scripts run by npm install, Makefile recipes, build.rs invoked by cargo — with that full secret context attached.

The trust anchor that failed first is the maintainer’s reading of pull_request_target. The trigger was designed to let workflows label or comment on fork PRs from the privileged base context; many workflows compounded that with an actions/checkout of ${{ github.event.pull_request.head.sha }} and then ran build, lint, or test steps that load attacker-controlled files. No credential needed to be stolen — the runner spawned the binaries on the attacker’s behalf. The auditable gap is in the workflow YAML itself: any combination of pull_request_target plus checkout-of-PR-head plus a step that reads or executes repo files is the canonical anti-pattern, and it is detectable before a single PR is filed.

SecurityV0 Perspective

This fits unproven_execution / ASI05. The base-repo runner is a non-interactive identity that the operator wired up for narrow purposes — labeling, commenting, status checks — and that the workflow definition then handed broad code-execution powers it was never explicitly granted. The non-human identity fallout (stolen GITHUB_TOKEN, AWS keys, Cloudflare and Netlify tokens, npm publish rights, two compromised npm packages) is the consequence; the root failure is that the runner executed code the operator never authorized.

The evidence pack SecurityV0 would produce inventories every workflow in the org that uses pull_request_target, identifies which of those workflows check out PR-head code, captures the secret references each workflow expands at runtime, and resolves the file paths the runner will execute (test entry points, build scripts, lint configs, package lifecycle hooks). Before any PR is filed, the pack answers the operator question: which workflows would let an external fork’s HEAD execute against base-repo secrets, and what is each one’s blast radius? After the fact, it answers the forensic question: which pull_request_target runs in the past N days received a fork-supplied workflow file, ran a checkout of fork HEAD, and emitted a base64-encoded secret blob into the run log?

What To Do

  • Audit every workflow that uses pull_request_target. For each, check whether actions/checkout is invoked with ref: ${{ github.event.pull_request.head.sha }} (or any equivalent fork-HEAD reference) followed by a step that reads or executes repo files. That triple — privileged trigger, fork checkout, code execution — is the campaign’s entire prerequisite. Replace with pull_request for any workflow that actually needs to run PR code.
  • Strip secret access from pull_request_target runs by default. Pin permissions: to the minimum needed (pull-requests: write, issues: write); do not expose GITHUB_TOKEN write scopes or environment-level secrets in PR-triggered workflows. If a labeling step needs a token, scope it to that step alone via env: rather than the workflow.
  • Require approval for fork-PR workflow runs. Enable “Require approval for all outside collaborators” in repository Actions settings so first-time fork PRs cannot trigger workflows automatically. Combined with branch protection on the workflow files themselves, this denies the campaign its quiet auto-fire.
  • Monitor for the campaign’s signature. Alert on PR titles containing ci: update build configuration and head-branch names matching prt-scan-[0-9a-f]{12} against repositories with pull_request_target workflows. Even after the campaign retunes, the workflow-log emission of base64-encoded credential blocks is a higher-fidelity detection than scanning PR diffs.
  • Rotate every credential that lived in a pull_request_target-exposed workflow. If the workflow ran in the affected window — repo by repo — assume the secret context was readable by any fork PR that landed during that period. Rotate GITHUB_TOKEN scopes, AWS access keys, Cloudflare API tokens, Netlify deploy tokens, and any npm publish tokens; re-issue from short-lived OIDC where possible.

Sources