The Secret the Mask Missed: Reading a Double-Base64 CI Leak

Digital Forensics & IR Level 2/4 ~3 min November 14, 2026

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

Encoding recognitionCI/CD incident responseSecret triage

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_token and system.github.token) because it is the job's own GITHUB_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.

Full solution

Pro and Max members unlock the complete step-by-step walkthrough.

Go Pro

Community stats

185 completions
91% success rate
M2F14M3 First blood

Related Daily Hacks

38,000+ Hackers Real labs Free
Start Hacking Free or solve today's hack, no account needed