Pipeline Hijacking! An Offensive Perspective on CI/CD and the Hunt for Credentials

Update (15 Oct 2026): since 8 December 2025, pull_request_target always takes the workflow file and the checkout commit from the default branch (changelog). actions/checkout v7 (June 2026; backported in July to v2 through v6) refuses by default to check out fork PR code in pull_request_target and in PR-triggered workflow_run, unless you set allow-unsafe-pr-checkout: true (changelog). If you pin checkout by SHA, you only get this behavior when you update.

On 14 and 15 March 2025, someone rewrote tags v1 through v45.0.7 of the tj-actions/changed-files action so they pointed at a malicious commit (CVE-2025-30066). Every workflow that ran it during that window dumped secrets from the Runner Worker process memory into its logs, where anyone could read them in repositories with public logs. Compromising one pipeline dependency was enough. That is why I treat a runner with deploy permissions as a tier-0 asset.

Why the pipeline is a priority target

The attacker’s reasoning is economic. If the runner holds the identity that deploys infrastructure, pushes images to the registry or updates the GitOps repository, stealing that identity, or slipping something into the artifact before it gets signed, costs less than attacking production directly. The OWASP Top 10 CI/CD Security Risks ranks these risks; this post mostly covers CICD-SEC-4 (Poisoned Pipeline Execution), CICD-SEC-6 (Insufficient Credential Hygiene) and CICD-SEC-9 (Improper Artifact Integrity Validation).

IncidentDateWhat happened
SolarWinds SUNSPOT2020 (disclosed in Dec)Malware that watched for MsBuild.exe, swapped a source file during the build to insert SUNBURST, then put the original back.
Codecov Bash UploaderJan–Apr 2021A flaw in how its Docker image was built exposed the credential needed to modify the script, which then sent customers’ CI environment variables to a third party.
UltralyticsDec 2024GitHub Actions cache poisoning through pull_request_target and injection via the branch name; a cryptominer reached PyPI in 8.3.41 and 8.3.42, published from the project’s own workflow (analysis).
tj-actions/changed-filesMar 2025Tags repointed to a commit that read secrets from the Runner Worker memory and wrote them to the logs.
Shai-Hulud (npm)Sep 2025A worm that stole GitHub PATs and AWS, GCP and Azure keys and published them to a public repository named Shai-Hulud.

Vector 1: stealing credentials from the runner

The first target is the credential that already lives in the job. Where it lives depends on the platform and the runner.

On GitHub Actions, every hosted runner is a fresh virtual machine, except single-CPU runners, which are a container on a shared VM (docs). Secrets only reach a step if the workflow references them (env: or with:), and GITHUB_TOKEN is read-only for pull requests from a fork (docs). That token does get persisted: actions/checkout stores it by default (persist-credentials: true), and since v6 it writes it to a file under $RUNNER_TEMP instead of .git/config (README). In August 2024, Unit 42 showed (ArtiPACKED) how it ended up in uploaded artifacts along with the .git folder. On a persistent self-hosted runner, you do find files such as ~/.aws/credentials, ~/.kube/config or Docker’s config.json, and state carries over from one job to the next.

On GitLab CI/CD, variables (and CI_JOB_TOKEN) arrive as environment variables; File-type variables are written to a temporary file and the variable holds its path (docs). The documentation warns that masking does not reliably stop a malicious user from reading a variable, and that malicious code in the pipeline can compromise masked and protected variables alike.

The way to get attacker code running in the first place is Poisoned Pipeline Execution. On GitHub, a pull_request workflow from a fork gets no secrets and no write token. What is exploitable is the pwn request (a pull_request_target or workflow_run workflow that checks out and runs the PR’s code), expression injection when a value such as ${{ github.event.pull_request.title }} is interpolated into a run: step, and self-hosted runners on public repositories. The official guidance is to pass untrusted input through an intermediate environment variable. Ultralytics combined these pieces: pull_request_target plus a github.head_ref expanded without quotes in a composite action’s shell.

Two things make it worse. First, mutable tags: the tj-actions retagging and the tampering with reviewdog/action-setup@v1 (CVE-2025-30154, March 2025) worked because a tag points wherever the person who controls the repository wants it to. With reviewdog, actions that called action-setup@v1 internally were compromised even if you had pinned them by SHA, because pinning does not cover transitive dependencies. Second, the cache: pull_request_target and workflow_run share the main branch’s cache, so untrusted code can poison it for a privileged workflow.

Vector 2: a signature does not prove the content is benign

The second target is the artifact. If the build is compromised, the pipeline signs the backdoored binary and ships it with a valid signature; SUNSPOT is the canonical example. The terms need to be kept apart:

  • A signature (Cosign) proves who signed a digest, not that the signed content is harmless.
  • An SBOM is an inventory. If a compromised pipeline generates it, it will faithfully list the tampered contents, and a binary injected outside the package manager may not show up at all. It speeds up impact analysis; it does not detect tampering.
  • Provenance describes how and where the artifact was built. SLSA Build L3 requires a build platform that isolates runs from each other and prevents user-defined steps from reaching the material used to sign the provenance. Verifying provenance lets you reject artifacts that did not come from the expected builder, repository and workflow; it does not catch malicious code that was already in the source or in a dependency.

A signature becomes useful when you verify it against the identity of the workflow that should have produced the artifact, with verifiable provenance and pinned digests. GitHub artifact attestations give you SLSA Build L2 on their own; for L3, the build has to run in a reusable workflow isolated from the caller. Inventory, scanning, signing and attestations are covered in depth in Hardening the supply chain.

Shutting the door: short-lived federated identity

The control that breaks Vector 1 is to stop storing long-lived credentials on the runner and federate identity over OIDC instead: the CI system requests a JWT from its issuer (on GitHub, https://token.actions.githubusercontent.com) and exchanges it for temporary credentials. On AWS that is sts:AssumeRoleWithWebIdentity; on GitLab, id_tokens: (CI_JOB_JWT and CI_JOB_JWT_V2 were removed in GitLab 17.0, docs); on Azure, Entra federated credentials; and on Google Cloud, Workload Identity Federation. IRSA and EKS Pod Identity only apply if the runner itself runs inside EKS (with Actions Runner Controller, for example).

Here is a deployment workflow with OIDC, minimal permissions and actions pinned by SHA (each SHA checked against the official release):

name: deploy
on:
  push:
    branches: [main]

# Read-only by default; each job raises only the permissions it needs.
permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-24.04
    environment: production        # required reviewers and allowed branches
    permissions:
      contents: read               # for actions/checkout
      id-token: write              # to request the OIDC JWT
    steps:
      # audit logs egress; block drops anything not in allowed-endpoints.
      # Free only for public repos on GitHub-hosted runners; everything else needs Enterprise.
      - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1
        with:
          egress-policy: audit

      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::123456789012:role/deploy-production
          role-session-name: gha-${{ github.run_id }}
          role-duration-seconds: 900
          aws-region: eu-west-1

      - name: Deploy
        run: |
          set -euo pipefail
          aws sts get-caller-identity
          ./deploy.sh

The control that really matters is the role’s trust policy, which pins aud and, above all, sub to a specific repository and environment:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:org/repo:environment:production"
        }
      }
    }
  ]
}

IAM rejects the policy if the GitHub sub condition is missing or is a bare wildcard, because without it workflows from unrelated organizations could assume the role (docs); repo:org/* passes validation and is still far too broad. On top of that, use one role per repository or environment, a read-only role for plan that is separate from the apply role, permissions scoped to the stack’s resources and its state bucket, and short sessions (the action defaults to 3600 seconds). No credential can be restricted to “only terraform apply”: IAM scopes by actions, resources and conditions, not by command.

What OIDC does not fix: with id-token: write, any step in the job, third-party actions included, can request the ID token through ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN (docs) and use the resulting credentials for as long as they last. OIDC shrinks the time window and the blast radius, but it does not prevent abuse while the job is running.

Verifying signature and provenance before deployment

Before deploying, check that the image was produced by the expected release workflow (here .github/workflows/release.yml, kept separate from the deploy workflow). cosign verify ties the keyless signature to that workflow’s identity, and gh attestation verify checks its SLSA provenance:

#!/usr/bin/env bash
set -euo pipefail

IMAGE="${1:?usage: verify.sh registry.example.com/app@sha256:<digest>}"
WORKFLOW="org/repo/.github/workflows/release.yml"

# 1) Keyless signature: who signed this image, and from which workflow.
cosign verify "$IMAGE" \
  --certificate-identity "https://github.com/${WORKFLOW}@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com"

# 2) SLSA provenance: expected repo, workflow and branch (gh needs GH_TOKEN).
gh attestation verify "oci://${IMAGE}" \
  --repo org/repo \
  --signer-workflow "$WORKFLOW" \
  --source-ref refs/heads/main \
  --deny-self-hosted-runners

The attestation that step 2 checks comes from the actions/attest-build-provenance step in the release workflow (Hardening the supply chain); without it, gh attestation verify finds nothing and the script fails.

Blocking in the cluster is the job of admission control, not runtime. In Kyverno, the current policy type is ImageValidatingPolicy; use Kyverno 1.18 or later. Kyverno 1.17 published the policies.kyverno.io/v1 API and announced the retirement of ClusterPolicy (announcement); according to the policy types table, ImageValidatingPolicy is stable since 1.18, while ClusterPolicy (with its verifyImages rules) was deprecated in 1.19 and will be removed in 1.20. Sigstore policy-controller is an alternative.

The full policy (signature, SBOM attestation, allowed-registry list, and exclusions for kube-system and kyverno) is in Hardening the supply chain. It binds the registry.example.com/app* path to the release workflow’s exact identity, https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main, the same one verify.sh checks, and it verifies initContainers too.

A common misunderstanding: Argo CD does not verify image signatures. What it verifies is that Git commits are signed with GnuPG (the AppProject’s .spec.sourceIntegrity, which replaces the deprecated signatureKeys). Flux verifies OCI sources with spec.verify (provider: cosign or notation), and it does not check pod images either. Requiring signed workloads is an admission concern; admission and runtime hardening are covered in Defensive strategies in Kubernetes.

Detection and separation of duties

On AWS, the CloudTrail entry for AssumeRoleWithWebIdentity includes the token’s sub (STS); alert when it is not one you expect. On the runner, harden-runner in block mode cuts egress to anything outside allowed-endpoints. Before merging, run actionlint and zizmor, whose template-injection, cache-poisoning, artipacked and unpinned-uses audits cover the patterns in this post, plus a secret scanner such as gitleaks (where it runs and what it blocks is covered in the tool stack post).

Linters flag patterns; they do not tell you which ones are exploitable. To prioritize a finding, trace the path. Does a third party control the input (a fork PR, a comment, a branch name)? Does the job hold secrets, id-token: write or a write-scoped GITHUB_TOKEN, or does it write something that a privileged job reads, such as the cache or an artifact? If both answers are yes, it is an attack path and it goes first.

Separation of duties rests on GitHub Environments: up to six users or teams as required reviewers and, if you turn on the option that prevents self-review, whoever triggers a deployment cannot approve it (in private repositories, required reviewers are not available on the Free, Pro or Team plans). Keep build and signing apart from deployment, use ephemeral runners (--ephemeral: they take one job and then deregister) and never attach self-hosted runners to public repositories. The “Require actions to be pinned to a full-length commit SHA” setting (changelog) makes workflows that use unpinned actions fail, and workflow dependency lockfiles (.github/workflows/actions.lock, in public preview) pin transitive dependencies as well, except those of reusable workflows.

Limits: what this does not cover

  • Neither signatures nor provenance prove that the source code or its dependencies are benign.
  • In audit mode, harden-runner only blocks StepSecurity’s global list of malicious domains; without an Enterprise plan, on self-hosted runners the step does nothing even though the job still passes.
  • The checkout v7 block does not cover a manual git or gh command that fetches untrusted code, nor triggers such as issue_comment.

If you only do three things

  1. Replace static keys with OIDC, with sub pinned to the repository and environment, and short sessions.
  2. Pin every third-party action to a full commit SHA and turn on the setting that enforces it.
  3. Verify the release workflow’s identity at admission, in addition to signing.

A note on responsible use: audit your own pipelines offensively (if you can get hold of the credentials, so can an attacker), but probing someone else’s infrastructure, for example with gato-x, is only legitimate with explicit authorization.

References

Blog

Let's talk security

Talks, press or partnerships: get in touch.