google / google/oss-fuzz

The PR helper workflow in google/oss-fuzz is triggered by the pull_request_target event with paths: ['projects/**']. Any unauthenticated external user can trigger this workflow by opening a pull reque

Open
#16,085 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Shell
Stars
12.6k
Forks
2.9k
Avg merge
2d 2h
Merged PRs (30d)
62

Description

Summary: The PR helper workflow in google/oss-fuzz is triggered by the pull_request_target event with paths: ['projects/**']. Any unauthenticated external user can trigger this workflow by opening a pull reque

Program: OSS VRP

URL: github.com/google/oss-fuzz

Vulnerability type: Other

Details
Summary The PR helper workflow in google/oss-fuzz is triggered by the pull_request_target event with paths: ['projects/**']. Any unauthenticated external user can trigger this workflow by opening a pull request that touches a file under projects/ — no write access, no approval, no maintainer interaction required.
The workflow runs in the repository's trusted context and is granted a GITHUB_TOKEN with pull-requests: write and contents: read permissions. While the workflow (correctly) does not check out the pull request's head commit — so it is not the classic pull_request_target RCE — the script it executes, infra/pr_helper.py, processes attacker-controlled content from the PR and reflects part of it into a comment posted by the official OSS-Fuzz automation bot, and causes the runner to make an outbound request to an attacker-chosen URL.

Concretely, for a PR that adds a new project, the script fetches the PR's own project.yaml (attacker's version), extracts the main_repo field, and:

Echoes that URL verbatim into a bot comment (rendered as a clickable link by GitHub's markdown), and Passes it to criticality_score, which fetches/processes the attacker-chosen repository URL from the GitHub runner. This gives an outside attacker a content-injection primitive into a verified Google-owned bot's comments (trusted-channel phishing / malware distribution vector) plus a runner-side outbound-request primitive, at zero cost and with no authentication.

Affected component File: .github/workflows/pr_helper.yml (current master) Script: infra/pr_helper.py (current master) yaml
on: pull_request_target: # runs in TRUSTED context, triggerable by anyone types: [opened] branches: - master paths: - 'projects/**'

jobs: build: permissions: contents: read pull-requests: write # privileged token 3. Technical analysis 3.1 Attacker-controlled input reaches the trusted-context script GithubHandler.get_integrated_project_info() (infra/pr_helper.py, lines 235–245) fetches the PR's changed files via the API and, for any file whose path contains project.yaml, downloads the PR's version of that file (via the file's contents_url, which points at the fork's blob):

python

def get_integrated_project_info(self): """Gets the new integrated project.""" response = requests.get(f'{BASE_URL}/pulls/{self._pr_number}/files', ...) for file in response.json(): file_path = file['filename'] if 'project.yaml' in file_path: return self.get_yaml_file_content(file['contents_url']) # attacker's content return {} The main_repo field of that attacker-supplied YAML is then passed through _sanitize_repo_url() (lines 68–81), which only strips \n, \r, \x00 and checks that the scheme is http, https or git (or that the URL starts with git@). Any attacker-chosen web URL survives sanitization.

3.2 Injection into the trusted bot's comment The sanitized-but-arbitrary URL is embedded into the MESSAGE written to GITHUB_ENV (lines 84–92, 118–127), which the next workflow step posts verbatim as a comment on the PR using the repository's official automation identity:

yaml

name: Leave comments if: env.IS_INTERNAL == 'FALSE' uses: actions/github-script@v8 with: github-token: ${{secrets.GITHUB_TOKEN}} script: | github.rest.issues.createComment({ ..., body: process.env.MESSAGE }) # contains attacker-chosen URL Result: a comment from the official OSS-Fuzz bot containing an attacker-chosen clickable link, e.g. https://attacker.example/oss-fuzz-login — a credible phishing/delivery channel, because the link appears inside a comment authored by Google's trusted repository automation, not by the attacker's account.
3.3 Runner-side fetch of an attacker-chosen URL (outbound request primitive) get_criticality_score() (lines 40–58) passes the attacker-controlled URL to the criticality_score binary on the runner:

python

report = subprocess.run([ CRITICALITY_SCORE_PATH, '--format', 'json', '-gcp-project-id=clusterfuzz-external', '-depsdev-disable', repo_url ], capture_output=True, text=True) (subprocess.run is list-form, so no shell injection — noted positively; but the runner still performs an outbound request/clone against an endpoint fully chosen by an unauthenticated requester, which is an SSRF-shaped primitive from the build runner and a resource-exhaustion vector via arbitrarily large repositories.)

3.4 Risk-pattern assessment (what is done right / what remains risky) To be precise and fair:

✅ actions/checkout is used without a ref:, so the code executed (infra/pr_helper.py, infra/ci/requirements.txt) comes from master, not the PR — the classic pull_request_target RCE is not present today. ✅ ${{ github.event.pull_request.user.login }} is passed via env:, not interpolated into run: — no script injection. ✅ GITHUB_ENV writes use a random UUID heredoc delimiter — no env-file injection. ⚠️ However, the workflow remains one careless edit away from full RCE in the trusted context: any future change that adds ref: ${{ github.event.pull_request.head.sha }} to the checkout (a natural "improvement", e.g. to read the PR's project.yaml locally instead of via API) instantly executes untrusted PR code with pull-requests: write token access. pull_request_target on a path (projects/**) that external contributors routinely modify is a recognized high-risk GitHub Actions anti-pattern (see the tj-actions/changed-files March 2025 supply-chain incident for how this class of pattern gets weaponized). ⚠️ go install github.com/ossf/criticality_score/cmd/criticality_score@latest — builds an unpinned @latest dependency in the trusted-context job. ⚠️ The Ready to merge auto-label logic trusts "author previously committed to this path" (login-based) and compares the PR's first commit email (attacker-settable in git) against project.yaml contact lists; only the GPG verified bit protects against email spoofing. A weak authorization signal feeding an automated merge-readiness label in a security-critical repository. 4. Steps to reproduce (PoC) No special tooling required. Performed by an unauthenticated external GitHub account:

Fork google/oss-fuzz. In the fork, create projects/poc-demo/project.yaml with attacker-chosen content: yaml

main_repo: "https://attacker.example/poc" Open a pull request to google/oss-fuzz:master adding that file (satisfies paths: projects/**, types: [opened]). The PR helper workflow fires in the trusted context within seconds. Observed results: The runner makes an outbound request involving https://attacker.example/poc (the criticality_score invocation) — controllable server-side beacon, confirmed in the attacker's server logs. The official OSS-Fuzz bot posts a comment on the PR of the form: poc-demo is integrating a new project:

Main repo: https://attacker.example/poc The attacker-chosen URL is rendered as a clickable link in a comment authored by the repository's official automation.
Impact Trusted-channel content injection: arbitrary attacker-chosen links published through Google's official OSS-Fuzz bot identity on google/oss-fuzz PRs — a phishing / payload-delivery channel that inherits the bot's trust, and which can be mass-triggered (open many PRs → many bot comments) at zero authentication cost. Outbound request primitive from the CI runner to attacker-chosen URLs (SSRF-shaped; also a resource-exhaustion vector). Supply-chain risk posture: a pull_request_target workflow on externally-triggerable paths plus unpinned @latest dependency installation in the trusted job — the exact anti-pattern class that has produced real OSS supply-chain compromises (e.g. tj-actions/changed-files, 2025). oss-fuzz CI integrity is security-critical for the broader OSS ecosystem it fuzzes. What is not claimed: RCE or secret exfiltration in the current workflow state (checkout is base-ref only; no repository secrets beyond the scoped GITHUB_TOKEN are exposed to PR-controlled data).
Recommended remediation Split the workflow into an unprivileged pull_request job (computes everything, uploads result as artifact) + a privileged workflow_run job (posts the comment/labels). This removes the trusted-context execution for unauthenticated triggers entirely. Allowlist main_repo against known forges (e.g. github.com, gitlab.com, codeberg.org hostnames) before echoing it into bot comments or fetching it from the runner; or render it as plain (non-linked) text. Pin the Go tool dependency (@latest → tagged version + checksum). Require a verified signature/email before any automated merge-readiness signal is emitted.
References Workflow: https://github.com/google/oss-fuzz/blob/master/.github/workflows/pr_helper.yml Script: https://github.com/google/oss-fuzz/blob/master/infra/pr_helper.py GitHub docs — keeping your GitHub Actions and workflows secure ("pull_request_target" risks): https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions tj-actions/changed-files supply-chain incident (2025): https://www.stepsecurity.io/blog/handle-vulnerable-github-actions CWE-79 (injection into rendered content), CWE-918 (server-side request to attacker-chosen URL)
Attack scenario
3.1 Attacker-controlled input reaches the trusted-context script GithubHandler.get_integrated_project_info() (infra/pr_helper.py, lines 235–245) fetches the PR's changed files via the API and, for any file whose path contains project.yaml, downloads the PR's version of that file (via the file's contents_url, which points at the fork's blob):

python

def get_integrated_project_info(self): """Gets the new integrated project.""" response = requests.get(f'{BASE_URL}/pulls/{self._pr_number}/files', ...) for file in response.json(): file_path = file['filename'] if 'project.yaml' in file_path: return self.get_yaml_file_content(file['contents_url']) # attacker's content return {} The main_repo field of that attacker-supplied YAML is then passed through _sanitize_repo_url() (lines 68–81), which only strips \n, \r, \x00 and checks that the scheme is http, https or git (or that the URL starts with git@). Any attacker-chosen web URL survives sanitization.

3.2 Injection into the trusted bot's comment The sanitized-but-arbitrary URL is embedded into the MESSAGE written to GITHUB_ENV (lines 84–92, 118–127), which the next workflow step posts verbatim as a comment on the PR using the repository's official automation identity:

yaml

name: Leave comments if: env.IS_INTERNAL == 'FALSE' uses: actions/github-script@v8 with: github-token: ${{secrets.GITHUB_TOKEN}} script: | github.rest.issues.createComment({ ..., body: process.env.MESSAGE }) # contains attacker-chosen URL Result: a comment from the official OSS-Fuzz bot containing an attacker-chosen clickable link, e.g. https://attacker.example/oss-fuzz-login — a credible phishing/delivery channel, because the link appears inside a comment authored by Google's trusted repository automation, not by the attacker's account.
3.3 Runner-side fetch of an attacker-chosen URL (outbound request primitive) get_criticality_score() (lines 40–58) passes the attacker-controlled URL to the criticality_score binary on the runner:

python

report = subprocess.run([ CRITICALITY_SCORE_PATH, '--format', 'json', '-gcp-project-id=clusterfuzz-external', '-depsdev-disable', repo_url ], capture_output=True, text=True) (subprocess.run is list-form, so no shell injection — noted positively; but the runner still performs an outbound request/clone against an endpoint fully chosen by an unauthenticated requester, which is an SSRF-shaped primitive from the build runner and a resource-exhaustion vector via arbitrarily large repositories.)

3.4 Risk-pattern assessment (what is done right / what remains risky) To be precise and fair:

✅ actions/checkout is used without a ref:, so the code executed (infra/pr_helper.py, infra/ci/requirements.txt) comes from master, not the PR — the classic pull_request_target RCE is not present today. ✅ ${{ github.event.pull_request.user.login }} is passed via env:, not interpolated into run: — no script injection. ✅ GITHUB_ENV writes use a random UUID heredoc delimiter — no env-file injection. ⚠️ However, the workflow remains one careless edit away from full RCE in the trusted context: any future change that adds ref: ${{ github.event.pull_request.head.sha }} to the checkout (a natural "improvement", e.g. to read the PR's project.yaml locally instead of via API) instantly executes untrusted PR code with pull-requests: write token access. pull_request_target on a path (projects/**) that external contributors routinely modify is a recognized high-risk GitHub Actions anti-pattern (see the tj-actions/changed-files March 2025 supply-chain incident for how this class of pattern gets weaponized). ⚠️ go install github.com/ossf/criticality_score/cmd/criticality_score@latest — builds an unpinned @latest dependency in the trusted-context job. ⚠️ The Ready to merge auto-label logic trusts "author previously committed to this path" (login-based) and compares the PR's first commit email (attacker-settable in git) against project.yaml contact lists; only the GPG verified bit protects against email spoofing. A weak authorization signal feeding an automated merge-readiness label in a security-critical repository. 4. Steps to reproduce (PoC)

Contributor guide

Open the contributing guide

Research direction

Review .github/workflows/pr_helper.yml and infra/pr_helper.py, starting with get_integrated_project_info(), _sanitize_repo_url(), and get_criticality_score(). Reproduce the workflow behavior with a project.yaml main_repo value, then trace how the value reaches the bot comment and runner. Done means the trusted workflow no longer exposes attacker-controlled links or fetches arbitrary repositories, with the dependency pinning and authorization concerns addressed.

Written by the indexing model from the issue text.

Assessment

Tech stack
github-actions, python
Domain
ci-cd, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.