This repository demonstrates GitHub Actions cache poisoning attack vector that bypasses npm 2FA using OIDC Trusted Publishing.
File: .github/workflows/vulnerable-pr-check.yml
on: pull_request_target # ⚠️ Runs in base repo context with secrets
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # ⚠️ Executes PR code
- uses: actions/cache@v3
with:
path: ~/.local/share/pnpm/store
key: pnpm-v10-${{ hashFiles('pnpm-lock.yaml') }} # ⚠️ Predictable key
- run: pnpm install # ⚠️ Uses cached binaries
- run: pnpm run build # ⚠️ Executes esbuild from cacheThe cache key is predictable and stable:
# Anyone can calculate the exact key:
cache_key="pnpm-v10-$(sha256sum pnpm-lock.yaml | cut -d' ' -f1)"- Cache written by
pull_request_targetfrom forked PR - Same cache accessible to
releaseworkflow onmainbranch - Cache lives for 7 days by default
┌─────────────────────────────────────────────────────────────────┐
│ PHASE 1: Cache Poisoning (from forked repository) │
└─────────────────────────────────────────────────────────────────┘
1. Attacker forks this repository
2. Attacker creates malicious PR with:
- Modified esbuild binary in pnpm store
- Same pnpm-lock.yaml (cache key unchanged)
3. PR triggers pull_request_target workflow
4. Workflow writes POISONED cache under key:
pnpm-v10-<hash-of-pnpm-lock.yaml>
5. PR can be closed immediately (cache already written)
┌─────────────────────────────────────────────────────────────────┐
│ PHASE 2: Cache Activation (on main branch) │
└─────────────────────────────────────────────────────────────────┘
6. Maintainer merges DIFFERENT PR (days later)
7. Release workflow runs on main branch
8. actions/cache restores POISONED cache (cache hit!)
9. pnpm install uses cached (malicious) esbuild binary
10. pnpm run build executes MALICIOUS esbuild
11. Malicious binary:
- Reads /proc/<pid>/mem to extract OIDC token
- Calls npm OIDC endpoint for publish token
- Publishes to npm registry (bypassing 2FA)
┌─────────────────────────────────────────────────────────────────┐
│ Result: Package published to npm without any credentials! │
└─────────────────────────────────────────────────────────────────┘
-
pull_request_target runs in base repo context:
- Has access to
secrets.* - Has
GITHUB_TOKENwith write permissions - Has OIDC
id-token: writecapability
- Has access to
-
Cache sharing between workflows:
- PR workflow writes cache
- Release workflow reads same cache
- No integrity verification
-
OIDC Trusted Publishing:
- npm trusts GitHub's OIDC identity
- No npm token required
- 2FA doesn't apply to OIDC flow
# The cache key is deterministic:
$ sha256sum pnpm-lock.yaml
abc123def456... pnpm-lock.yaml
# Cache key becomes:
pnpm-v10-abc123def456...| Required | Not Required |
|---|---|
| GitHub account (free) | npm credentials |
| Ability to open PR | PR to be merged |
| Knowledge of pnpm-lock.yaml | Maintainer access |
| Wait for another merge | Repository secrets |
BAD:
on: pull_request_target
steps:
- uses: actions/checkout@v4
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/cache@v3
- run: pnpm install && pnpm buildGOOD:
on: pull_request # NOT pull_request_target
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v3
- run: pnpm install && pnpm buildkey: ${{ github.event_name }}-pnpm-v10-${{ hashFiles('pnpm-lock.yaml') }}
# PR: pull_request_target-pnpm-v10-abc123...
# Main: push-pnpm-v10-abc123...- name: Verify esbuild binary
run: |
sha256sum node_modules/.pnpm/esbuild*/node_modules/esbuild/bin/esbuild
# Compare with known good hashpnpm install --frozen-lockfile --ignore-scripts
# Then verify installed binaries match expected hashes# Clone
git clone https://github.com/sparkfinderoven/vulnerable-cache-target.git
cd vulnerable-cache-target
# Install (clean)
pnpm install
pnpm run build
pnpm run testSee ATTACK-DEMO.md for step-by-step attack simulation.
MIT - Educational purposes only