The Secret the Mask Missed: Reading a Double-Base64 CI Leak
The challenge
A hijacked CI step printed this in a public log. Peel the base64 that dodged secret masking. Recover the npm publish token it leaked.
What you'll learn
- Explain how the tj-actions/changed-files compromise leaked CI secrets
- Recognise decoded output that is itself base64
- Explain why a second encoding layer defeats GitHub's log masking
- Tell a short-lived job token from a long-lived publish token
Skills tested
Prerequisites
- What base64 is
- What a CI pipeline does
How it works
GitHub Actions hides secrets in job logs: before a line is written, the runner replaces every registered secret value with ***. It also masks the value's base64 forms, at all three byte offsets, because echo $TOKEN | base64 is the oldest trick in the book.
In March 2025 an attacker repointed every tag of tj-actions/changed-files, used by about 23,000 repositories, to a malicious commit. The new code read the memory of the runner process, grepped every "name":{"value":"...","isSecret":true} entry, and printed them with base64 -w 0 | base64 -w 0. The second layer changes every character, so nothing in the line matches a masked pattern. On public repositories, anyone could open the run and read the secrets. A log in this hack's fictional repository looked like this:
##[group]Run ridgeway-actions/changed-paths@v4 with: separator: , files: src/** env: NPM_TOKEN: *** ##[endgroup] SWtOUFJFVkRUMVpmVkU5TFJVNGlPbnNpZG1Gc2RX...
The env: block shows the mask working on the plain value. The last line is the same secret, wrapped twice, and the mask never saw it.
Common mistakes
- Stopping after one decode. The first result is still only letters, digits,
+,/and=: that is base64 again. Decode until you see readable text. - Answering the
ghs_token. It appears twice (github_tokenandsystem.github.token) because it is the job's ownGITHUB_TOKEN. GitHub mints it per job and it expires when the job ends. The goal is the npm publish token, which keeps working after the run. - Answering the Codecov value. The UUID belongs to
CODECOV_TOKEN, an upload token, not the npm one. - Pressing base64 a third time. JSON is not base64, so the step fails: that failure tells you that you already reached the plain text.
How to defend against it
- Pin third-party actions to a full 40-character commit SHA, not a tag: tags can be moved, which is exactly what happened to tj-actions.
- Publish to npm with trusted publishing (OIDC), so no long-lived npm token sits in your secrets at all.
- Give a secret only to the job or environment that needs it: a job that lists changed files has no reason to see
NPM_TOKEN. - Watch runner egress (for example with StepSecurity Harden-Runner, which spotted the tj-actions payload download).
- After an incident, rotate every secret the affected runs could read and delete the public logs.