DEV Community

Cover image for Understanding "Pwn Requests" in GitHub Actions: How to Prevent Repository Compromise
Prashant Singh
Prashant Singh

Posted on

Understanding "Pwn Requests" in GitHub Actions: How to Prevent Repository Compromise

Automation through GitHub Actions makes continuous integration effortless, but misconfigured workflows can inadvertently hand full write access or secret keys to malicious actors.

One of the most dangerous patterns in GitHub CI/CD security is known as a "Pwn Request."

📌 Credit & Reference: This article summarizes key insights from the original research published by Jaroslav Lobačevski on the GitHub Security Lab Blog.


🔍 What is a "Pwn Request"?

A Pwn Request occurs when an attacker submits a Pull Request (PR) from a public fork that triggers an automated workflow running with elevated privileges (write permissions or access to repository secrets).

Because build and test automation inherently runs code defined within the repository, untrusted PRs can manipulate that execution context. An attacker can achieve code execution by:

  • Modifying build scripts (e.g., Makefile, PowerShell, or package.json scripts).
  • Adding malicious package pre-install/post-install hooks (npm install).
  • Writing arbitrary payload code within test suites executed during CI.

🚨 The Vulnerable Pattern: pull_request_target + Explicit Checkout

To protect repositories, GitHub's default pull_request trigger strips write permissions and secret access from PRs originating from external forks.

However, developers often need workflows to comment on PRs or label them. To allow this, GitHub introduced pull_request_target, which:

  1. Runs with write access to the target repository and access to secrets.
  2. Evaluates in the context of the target repository rather than the untrusted fork.

The Deadly Mistake

The vulnerability happens when developers explicitly check out the untrusted head commit of the incoming PR inside a pull_request_target workflow:

# ❌ INSECURE EXAMPLE
on: pull_request_target

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # Explicit checkout of untrusted PR code in a privileged context!
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - run: npm install
      - run: npm build

Enter fullscreen mode Exit fullscreen mode

When npm install or npm build executes, it runs scripts provided by the untrusted PR while holding a write-capable GITHUB_TOKEN in memory!


🛡️ The Secure Architecture: pull_request + workflow_run

To safely process PRs from external forks when elevated actions (like commenting or labeling) are required, separate the unprivileged build step from the privileged reaction step using artifacts.

Step 1: Unprivileged Build (ReceivePR.yml)

Runs in an isolated environment without write permissions or secrets access.

name: Receive PR
on:
  pull_request:
    permissions:
      contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: /bin/bash ./build.sh
      - name: Save PR number as artifact
        run: |
          mkdir -p ./pr
          echo ${{ github.event.number }} > ./pr/NR
      - uses: actions/upload-artifact@v4
        with:
          name: pr
          path: pr/

Enter fullscreen mode Exit fullscreen mode

Step 2: Privileged Reaction (CommentPR.yml)

Triggers only after the unprivileged workflow completes, running safely in the base repository context.

name: Comment on PR
on:
  workflow_run:
    workflows: ["Receive PR"]
    types: [completed]

jobs:
  comment:
    runs-on: ubuntu-latest
    if: >
      github.event.workflow_run.event == 'pull_request' &&
      github.event.workflow_run.conclusion == 'success'
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: pr
          path: ${{ runner.temp }}/pr
          run-id: ${{ github.event.workflow_run.id }}

      - name: Comment on PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const issue_number = Number(fs.readFileSync('${{ runner.temp }}/pr/NR', 'utf8').trim());
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: issue_number,
              body: 'All checks passed successfully! Thank you for your contribution.'
            });

Enter fullscreen mode Exit fullscreen mode

💡 Key Takeaways for Developers

  • Treat external PRs as untrusted input: Never execute code or scripts from external contributors inside a privileged runner.
  • Avoid checking out untrusted refs under pull_request_target: Use standard pull_request whenever code compilation or testing is required.
  • Decouple tasks: Use the workflow_run event pattern to pass safe, passive data (like coverage text or PR numbers) from unprivileged workflows to privileged ones.

*For full technical details, refer to the original research by GitHub Security Lab: Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests*.


Enter fullscreen mode Exit fullscreen mode

Uploading image

Top comments (0)