# 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):

```yaml
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:

```sh
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.

```yaml
# 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

- [GitHub Actions: A Cloudy Day for Security - Part 1](https://binarysecurity.no/posts/2025/08/securing-gh-actions-part1)
- [GitHub workflow syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions)
- [Contexts and expression syntax](https://docs.github.com/en/actions/learn-github-actions/contexts)
- [Untrusted input reference for GitHub Actions](https://securitylab.github.com/resources/github-actions-untrusted-input/)

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