← Back to Attack Research

When the tag lied: the tj-actions/changed-files supply-chain compromise

CVE-2025-30066 did not exploit a bug in the tj-actions/changed-files GitHub Action. It replaced the code the tag pointed at. One stolen token, a range of retagged versions, and a memory-scraping payload turned a popular convenience Action into a mass secret-exfiltration campaign that printed CI credentials into world-readable build logs. Here is the mechanism, the upstream chain, and the one habit that would have stopped it.

On 14 March 2025, tens of thousands of GitHub repositories ran a workflow step they had run hundreds of times before. Nothing in their own code changed. Nothing in their configuration changed. But the third-party Action they referenced by a friendly tag, tj-actions/changed-files@v45, no longer pointed at the code they had reviewed. Its git tags had been quietly force-updated to a single attacker-controlled commit that dumped the CI runner's memory, scraped every secret held in it, and printed those secrets, double-base64-encoded, straight into the build log. On public repositories, those logs are world-readable. The attacker did not need to break into anything. They retagged an Action, then waited for the victims to run it.

CVE-2025-30066 is not a bug in the intended logic of the tj-actions/changed-files GitHub Action. The Action did exactly what its distributed artifact told it to do. The problem is that the distributed artifact had been swapped underneath everyone who used it. This is CWE-506, Embedded Malicious Code: hostile instructions inserted into a trusted component's supply chain, rather than a flaw in that component's design. CISA added the issue to the Known Exploited Vulnerabilities catalog on 18 March 2025, four days after the compromise began, with a CVSS 3.1 base score of 8.6 High. Here is how a maintainer's stolen token turned a popular convenience Action into a mass secret-exfiltration event, and why the durable fix is a habit rather than a patch. Verifiable security.

What changed-files does, and why everyone used it

changed-files is one of the most widely adopted utility Actions in the GitHub ecosystem. Its job is mundane and genuinely useful: given a pull request or a push, it reports which files changed, so a workflow can run tests only for the affected code, skip a deploy when only documentation moved, or lint just the touched paths. That small convenience earned it a place in the pipelines of a very large number of projects, from hobby repositories to the continuous-integration setups of major open-source libraries. Popularity is the precondition for a supply-chain attack. A backdoor in an Action nobody uses is a curiosity. A backdoor in an Action referenced by tens of thousands of pipelines is a mass-exploitation event that only needs a trigger.

The Action is consumed the way almost all GitHub Actions are consumed. A workflow file names it and a version: uses: tj-actions/changed-files@v45. That single line is the entire trust relationship between a project and the third-party code it runs with its own credentials. And that single line is where this whole incident lives.

The root cause is a mutable reference

A GitHub Action reference has two forms. You can pin it to an immutable commit, uses: tj-actions/changed-files@a1b2c3d4..., naming exactly one commit that can never change beneath you. Or you can reference a mutable tag, @v45 or @v35 or @v1, which is a friendly label the maintainer points at whatever commit they consider current. Tags are convenient because you receive fixes and improvements automatically, without editing your workflow. Tags are dangerous for exactly the same reason: whoever controls the repository controls where the tag points, and therefore controls what code runs inside your pipeline the next time your workflow fires.

Most consumers of changed-files used the tag form. So when the attacker gained write access to the tj-actions repository, they did not need to persuade anyone to upgrade. They force-updated the tags, retagging a broad range of versions, from v1 through the v45.x line and individual version tags, so that all of them resolved to one malicious commit. Every workflow referencing a mutable tag would, on its next run, resolve that tag to the attacker's commit and execute it. No pull request. No notification. No review prompt. The victims pulled the payload themselves, on their own schedule, the moment their next build ran.

The victims did not get breached in the usual sense. They pulled the payload themselves, on schedule, the moment their next build ran.

MUTABLE TAG, MISPLACED TRUST BEFORE workflow: uses tj-actions/changed-files@v45 @v45 -----------------> reviewed, benign commit ATTACKER RETAGS (write access via a stolen PAT) @v1 @v2 ... @v35 ... @v45.x ----> ONE malicious commit NEXT WORKFLOW RUN (victim changed nothing) @v45 ----> malicious commit ----> injected build step | v read Runner.Worker process memory | v scrape in-memory secrets from the job | v print them double-base64 into the build log | public repo? -> log is world-readable

One structure: a mutable tag repointed to hostile code that turns the victim's own build log into the drop site. Illustrative of the mechanism, not a reproduction.

What the injected step actually did

The malicious commit added a build step to the Action that executed a base64-encoded Python payload. Decoded, it behaved like a memory-dump routine, a memdump.py in spirit: it located the CI runner's Runner.Worker process, read that process's memory, and scraped the secrets sitting there in the clear while the job ran. CI runners hold a great deal of sensitive material in memory during a workflow. The automatically provisioned GITHUB_TOKEN, cloud provider access keys, personal access tokens, npm publish tokens, private RSA keys. Anything a workflow needs to do its work is, at some point in the run, resident in the runner's memory where a co-resident step can reach it.

Having scraped those secrets, the payload did something both crude and effective. It printed them into the workflow's own build log, double-base64-encoded so the output would not obviously read as credentials to a casual glance. On a private repository, that log is visible to people with repository access, which is bad enough. On a public repository, that log is world-readable, which is the catastrophic case. Anyone, the attacker included, could open the Actions run of an affected public project, read the log, decode the blob twice, and recover live secrets. The payload also referenced a remote gist, consistent with staging or exfiltration infrastructure.

The elegance, from the attacker's point of view, is that no callback was required. There was no need to exfiltrate data to a server that defenders could block, sinkhole, or trace. The attack turned the victim's own public build log into the drop site and let the open internet perform the collection. Passive harvesting of world-readable logs leaves far less of a footprint than an outbound connection to attacker infrastructure.

The foothold came from upstream: CVE-2025-30154

The tj-actions compromise did not begin at tj-actions. The initial foothold traces back to a separate compromise of a related upstream Action, reviewdog/action-setup, tracked as CVE-2025-30154. That earlier compromise yielded a token, which was used to reach a personal access token that held write access to the tj-actions repository. With write access in hand, retagging the Action was trivial. This is the characteristic shape of a modern supply-chain attack: a chain, not a single break. One compromised Action's credential becomes the key to the next Action, which fans out to everyone downstream of it. It is worth telling CVE-2025-30066 alongside its root-cause CVE-2025-30154, because the pair is the actual lesson. The blast radius of any credential in your CI is every project that trusts the thing that credential controls.

Blast radius: exposure versus loss

The compromise window touched on the order of 23,000 repositories that referenced the Action during the affected period. It is important to read that number correctly: it is the exposure, not the confirmed loss. Far fewer repositories actually leaked live, usable secrets into public logs. The payload only harvested whatever happened to be in the runner's memory at the time, many affected repositories are private rather than public, many individual runs did not carry high-value credentials, and the maintainers and the platform responded quickly to contain the incident. Even so, a small fraction of 23,000 is still a large number of pipelines, and the secrets that did leak were not low-stakes.

Consider what a single leaked credential buys. A leaked GITHUB_TOKEN can push code, alter releases, or tamper with a repository's automation. A leaked cloud access key can reach into production infrastructure and its data. A leaked npm publish token can poison a published package, which becomes the seed of the next supply-chain attack one layer further down. Stolen CI credentials are rarely the end of an intrusion. They are the pivot into it. That is what makes a build-log leak so much worse than it first sounds: the secrets a pipeline holds are, by design, the keys to the things the pipeline is trusted to change.

Is a compromised artifact really a “vulnerability”?

There was genuine community debate about whether this incident warranted a CVE at all. A CVE traditionally describes a flaw in software, and here the software ran exactly as written. The injury was a compromised distribution channel and a stolen credential, not a defect in intended logic. The counter-argument, and the one that carried, is that CWE-506, Embedded Malicious Code, exists precisely for this situation: a trusted artifact that now contains hostile code is a security defect its consumers must act on, whatever the mechanism by which the hostile code was inserted. From the perspective of a downstream project, the distinction between “the vendor wrote a bug” and “an attacker rewrote the vendor's artifact” is academic. In both cases the trusted component now runs code that harms you.

Whatever position you take in that taxonomy debate, the operational fact is settled. CISA added CVE-2025-30066 to the Known Exploited Vulnerabilities catalog on 18 March 2025, which makes it a mandated-remediation item for federal agencies under the relevant directive and a strong signal for everyone else. When something is in KEV, the argument about naming is over and the only remaining argument is about your patch window.

What to do about it

Close the mutable-dependency exposure in your pipelines

How Celvex catches this

Find. Prove. Fix. Verify.

Find

A read-only inventory of your workflow files enumerates every third-party Action and flags each one referenced by a mutable tag rather than a pinned commit SHA, surfacing exactly the trust relationships an upstream compromise would ride.

Prove

A flagged Action becomes an Ed25519-signed Proof Capsule recording the workflow file, the unpinned reference, and the KEV status of the dependency, reproducible offline by you or your auditor.

Fix

The capsule's remediation block names the commit SHA to pin to, the clean version to upgrade toward, and the specific secrets whose rotation the exposure window demands.

Verify

A fresh sweep confirms the reference is pinned and the dependency is clean. The finding closes and the verified-fix event is recorded for the audit trail.

The uncomfortable lesson of CVE-2025-30066 is that the vulnerability lived in a habit, not a program. The code of changed-files was fine. The workflows that ran it were syntactically correct. The gap was a convention, near-universal and rarely questioned, of trusting a mutable label to keep meaning tomorrow what it meant yesterday. Supply-chain security is largely the discipline of refusing that convenience: pin what you depend on to something that cannot change under you, know which credentials your pipeline holds and where they are resident, and treat a build log as the sensitive surface it actually is. The attackers understood the trust model better than most of the defenders did. The work is to understand it first.

Verifiable security. Find it. Prove it. Fix it. Verify the fix held. That is what we ship.

Sources

Which of your pipelines trust a tag that can change under you?

Free Exposure Check, no signup required. We inventory your workflow files, flag every third-party Action referenced by a mutable tag instead of a pinned commit, cross-reference the known-exploited catalog, and ship a signed Proof Capsule for the highest-confidence finding.

Run a Free Scan →