Gh Actions - Context Script Injections
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.[5][7][8]
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 [6][7]
Key points:
- Rendering happens before execution. The run script is generated with all expressions resolved, then executed by the shell.[5]
- 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/[5][8]
- 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.[5][8]
Vulnerable pattern โ RCE on runner
Vulnerable workflow (triggered when someone opens a new issue):[5]
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:[5]
echo "New issue $(id) created"
The command substitution runs id on the runner. Example output:[5]
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.[5][8]
Comment-state confusion: spoofed bot comments โ shell injection
A dangerous variant appears when a workflow searches comments and later treats the returned comment as trusted automation state. For example, peter-evans/find-comment can search by body-includes and expose the matching comment-body as a step output. If the workflow does not also restrict comment-author, any user who can comment may spoof the marker text expected from a bot.[1][2]
- uses: peter-evans/find-comment@v4
id: fc
with:
issue-number: ${{ github.event.issue.number }}
body-includes: "Opened a new issue in org/repo:"
If that output is later embedded into shell syntax, the workflow becomes exploitable even though the original source was "just a comment":[1][3]
- run: |
if [ '${{ steps.fc.outputs.comment-body }}' = '' ]; then
echo "new issue needed"
fi
An attacker can post a comment that both:[1]
- matches the searched marker string, and
- contains shell-breaking content such as
' ]; <cmd>; if [ 'x
After GitHub renders ${{ ... }}, Bash receives attacker-controlled syntax, not data. This creates a two-stage exploit:[1][5]
- Provenance confusion: the workflow mistakes attacker comments for bot state.
- Script injection: the returned
comment-bodyis pasted intorun:and executed.
TOCTOU race against bot comments
If the legitimate bot comment is created only after some earlier step, an attacker may race it by posting the spoofed comment first. If the search action returns the attacker's comment before the real bot comment exists (or before it is selected), a low-privilege public commenter can turn an issue_comment/issue workflow into privileged runner execution.[1]
Safer patterns for comment-driven automation
- When using
find-comment, require both content and provenance (comment-author, repository/App identity, or another strong binding).[1][2] - Do not use comments as state if a label, artifact, issue field, or external datastore can hold the same state more safely.
- Never paste
comment-body, issue titles, labels, or any workflow output derived from them directly intorun:.[1][3][8] - If you must consume comment text, pass it through
env:or a file and handle it as data only.[5][8]
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.[5][6]
# 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.[5]
- Prefer passing untrusted inputs via env: mapping and reference them with $VAR in run:.[5][6]
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:[5][8]
- 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/[8]
Local validation without touching the target repo
You can reproduce many GitHub Actions script injections safely with act: generate a synthetic event JSON, run the vulnerable workflow locally, and replace the external action output with a controlled value (for example a mocked comment-body). This is useful to debug payload structure, verify whether the injected text still leaves valid Bash syntax, and confirm harmless canary exfiltration before any live test.[1][4]
Practical tips
- Minimize use of expressions inside run:. Prefer env: mapping + $VAR.[5][6]
- 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.[8]
- For reusable workflows and composite actions, apply the same pattern: map to env then reference $VAR.[5][6]
References
- [1] Find Comment, Get Shell: Command Injection in dbtโs GitHub Actions
- [2] peter-evans/find-comment
- [3] GHSL-2023-109: GitHub Actions command injection in a TDesign Vue Next workflow
- [4] nektos/act
- [5] GitHub Actions: A Cloudy Day for Security - Part 1
- [6] GitHub workflow syntax
- [7] Contexts and expression syntax
- [8] Untrusted input reference for GitHub Actions
[!TIP] Learn & practice AWS Hacking:
HackTricks Training AWS Red Team Expert (ARTE)
Learn & practice GCP Hacking:HackTricks Training GCP Red Team Expert (GRTE)
Learn & practice Az Hacking:HackTricks Training Azure Red Team Expert (AzRTE)
Browse the full HackTricks Training catalog.Support HackTricks
- Check the subscription plans!
- Join the ๐ฌ Discord group or the telegram group or follow us on Twitter ๐ฆ @hacktricks_live.
- Share hacking tricks by submitting PRs to the HackTricks and HackTricks Cloud github repos.


