Gh Actions - Context Script Injections

{{#include ../../../banners/hacktricks-training.md}}

Understanding the risk

GitHub Actions renders expressions ${{ ... }} before the step executes. The rendered value is pasted into the step’s program (for run steps, a shell script). If you interpolate untrusted input directly inside run:, the attacker controls part of the shell program and can execute arbitrary commands.

Docs: https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions and contexts/functions: https://docs.github.com/en/actions/learn-github-actions/contexts

Key points:
- Rendering happens before execution. The run script is generated with all expressions resolved, then executed by the shell.
- Many contexts contain user-controlled fields depending on the triggering event (issues, PRs, comments, discussions, forks, stars, etc.). See the untrusted input reference: https://securitylab.github.com/resources/github-actions-untrusted-input/
- Shell quoting inside run: is not a reliable defense, because the injection occurs at the template rendering stage. Attackers can break out of quotes or inject operators via crafted input.

Vulnerable pattern β†’ RCE on runner

Vulnerable workflow (triggered when someone opens a new issue):

name: New Issue Created
on:
  issues:
    types: [opened]
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      issues: write
    steps:
      - name: New issue
        run: |
          echo "New issue ${{ github.event.issue.title }} created"
      - name: Add "new" label to issue
        uses: actions-ecosystem/action-add-labels@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          labels: new

If an attacker opens an issue titled $(id), the rendered step becomes:

echo "New issue $(id) created"

The command substitution runs id on the runner. Example output:

New issue uid=1001(runner) gid=118(docker) groups=118(docker),4(adm),100(users),999(systemd-journal) created

Why quoting doesn’t save you:
- Expressions are rendered first, then the resulting script runs. If the untrusted value contains $(...), ;, "/', or newlines, it can alter the program structure despite your quoting.

Safe pattern (shell variables via env)

Correct mitigation: copy untrusted input into an environment variable, then use native shell expansion ($VAR) in the run script. Do not re-embed with ${{ ... }} inside the command.

# safe
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: New issue
        env:
          TITLE: ${{ github.event.issue.title }}
        run: |
          echo "New issue $TITLE created"

Notes:
- Avoid using ${{ env.TITLE }} inside run:. That reintroduces template rendering back into the command and brings the same injection risk.
- Prefer passing untrusted inputs via env: mapping and reference them with $VAR in run:.

Reader-triggerable surfaces (treat as untrusted)

Accounts with only read permission on public repositories can still trigger many events. Any field in contexts derived from these events must be considered attacker-controlled unless proven otherwise. Examples:
- issues, issue_comment
- discussion, discussion_comment (orgs can restrict discussions)
- pull_request, pull_request_review, pull_request_review_comment
- pull_request_target (dangerous if misused, runs in base repo context)
- fork (anyone can fork public repos)
- watch (starring a repo)
- Indirectly via workflow_run/workflow_call chains

Which specific fields are attacker-controlled is event-specific. Consult GitHub Security Lab’s untrusted input guide: https://securitylab.github.com/resources/github-actions-untrusted-input/

Practical tips

  • Minimize use of expressions inside run:. Prefer env: mapping + $VAR.
  • If you must transform input, do it in the shell using safe tools (printf %q, jq -r, etc.), still starting from a shell variable.
  • Be extra careful when interpolating branch names, PR titles, usernames, labels, discussion titles, and PR head refs into scripts, command-line flags, or file paths.
  • For reusable workflows and composite actions, apply the same pattern: map to env then reference $VAR.

References

{{#include ../../../banners/hacktricks-training.md}}