Open a pull request against google/adk-samples and a few minutes later your code is running inside a Google Cloud project, executing whatever you want. No review, no merge, or contributor status. The PR is still sitting there, unread.
That was true in October 2025. The repo is public under the official google org, so anyone can fork it and open a PR. One workflow turned that into code execution as the project’s CI service account on adk-devops, with read and write across the project.
Google VRP 453730416 · GitHub Actions
pull_request_targetto keyless Workload Identity Federation. No CVE or CVSS published. Reported 2025-10-20, fixed 2025-11-12. Honorable Mention, no bounty.
CI running PR code isn’t the story. The bridge is: a short-lived GitHub OIDC token, traded to Google for live Application Default Credentials, handed to code a stranger just wrote.
The trigger
GitHub has two pull-request triggers. pull_request runs in the fork’s context with a read-only token and no secrets, the safe one. pull_request_target runs in the base repo’s context, with its secrets and a writable token the moment a PR opens. It exists for triaging PR metadata, not for running PR code. Run the PR’s code under it and the trust boundary is gone.
The workflow .github/workflows/test_templated_agent.yaml did exactly that. Four lines carry the whole vulnerability:
on:
pull_request_target: # untrusted trigger, base-repo context
permissions:
id-token: write # can mint a GCP federation token
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }} # checks out fork code
- run: make test && make backend # runs it
id-token: write lets the job request a GitHub-signed JWT describing the run. The google-github-actions/auth action hands that JWT to Google’s STS, where a workload identity pool trusts tokens matching a condition like assertion.repository == "google/adk-samples". STS trades it for short-lived credentials written as ADC.
On a pull_request_target run the JWT honestly says repository: google/adk-samples even though the checked-out tree came from a fork. The pool gated on which repo asked, not whether the code about to run was trusted. Then checkout pulls head.sha, the fork’s commit, and make test runs uvx agent-starter-pack create plus the generated agent: attacker code, in the same job that holds the credentials.
What came back
Inside the job, the ADC resolves with no arguments:
credentials, project = google.auth.default() # -> the federated identity, project adk-devops
The leaked environment confirmed it:
GITHUB_EVENT_NAME=pull_request_target
GOOGLE_APPLICATION_CREDENTIALS=/github/workspace/gha-creds-*.json
ACTIONS_ID_TOKEN_REQUEST_TOKEN=***
ACTIONS_RUNTIME_TOKEN=***
The token read the project’s IAM (21 role bindings) and held storage.objects.create/delete/get/list across 31 buckets, several holding Terraform state, including the project’s own OIDC trust config in github-oidc/default.tfstate. The identity it trusted could read the config that decided what to trust. The roles in reach (run.developer, cloudbuild.builds.builder, owner) mean a real attacker would deploy a Cloud Run service to keep the identity long after the short-lived token expired.
The fix
Don’t run untrusted code under pull_request_target. Use plain pull_request for anything that builds PR contents, and if a step genuinely needs base-repo trust, split it so an unprivileged job runs fork code and a separate gated job holds the credentials. Then scope the federation down, so even leaked credentials can’t read Terraform state or write across the project. Google split the jobs and shipped about three weeks after the report.
Disclosure
Reported to the Google OSS VRP on 2025-10-20, fixed 2025-11-12, public disclosure approved 2026-06-01 (VRP 453730416). The panel declined a reward and offered an Honorable Mention; the repo is out of OSS VRP scope and the impact was graded as free compute. The pipeline that flagged it is its own writeup, Red-Teaming GitHub Actions with Claude Skills.