A defender reads a config file to check that it works. An attacker reads the same file to find what the author took for granted, and what happens if that assumption doesn't hold.
That is most of what "thinking like an adversary" means: asking not how something is meant to be used, but how else it could be used. It is a habit worth borrowing if you work in defense, too. The weak point usually isn't a missing control. It's an assumption everyone relied on without checking. Protecting what matters starts with looking at your own systems the way an attacker would, and testing those assumptions before someone else does.
The bug below is a good example. Every assumption in the workflow was reasonable and written down in plain sight, and none of them held. The payoff was code execution on one of Mozilla's own Mac minis. It paid $xxxx, and I never ran a payload against Mozilla to prove it. All of it came from files anyone could read.
Where this hunt started
When the Trivy CI/CD compromise surfaced earlier in 2026, attackers had turned a GitHub Actions misconfiguration into a real supply-chain foothold. That was the prompt to threat model the same class of problem more widely. If it was already happening in the wild, the working assumption was that the same attack surface was sitting on other projects too, just unreported. So the search was narrow and deliberate. Look for public repositories that run fork pull requests on self-hosted runners. Mozilla's macos-vms is where it landed.
The workflow that trusts a file it doesn't own
The target was mozilla-platform-ops/macos-vms, a public repo Mozilla uses to build the macOS virtual machines their release engineering runs on. Two GitHub Actions workflows caught my eye immediately: build-mac.yaml and build-builder-arm64.yaml. Both had the same two lines in their heads that, together, are the entire bug:
on:
pull_request:
paths:
- 'mac/builder-arm64/**'
jobs:
build:
runs-on: self-hostedpull_request fires on PRs from forks. runs-on: self-hosted means the job does not run on a disposable, throwaway GitHub-hosted VM. It runs on Mozilla's actual hardware, a Mac mini they own and manage, a machine that keeps state, keeps files, and keeps living after the job ends.
On its own that pairing is dangerous but not automatically fatal. What made it fatal was the build step:
steps:
- uses: actions/checkout@v4
- run: |
chmod +x builder.sh
./builder.shbuilder.sh lives inside the repository. actions/checkout pulls the pull request's head, the fork's code, and then the job marks that file executable and runs it. So the sequence an attacker needs is not clever. It is: open a fork, edit builder.sh, open a pull request. When the job fires, Mozilla's Mac mini runs the attacker's shell script as the runner user. That is CWE-829, inclusion of functionality from an untrusted control sphere, in its most literal form. The trusted job executes a file whose contents an untrusted contributor controls.
And here is the first inversion of intuition. That paths: filter looks like a defensive narrowing, since the job only runs when files under mac/builder-arm64/** change. Read it as an adversary and it is a map. Mozilla is telling me exactly which path I have to touch to wake up the vulnerable job. I don't have to guess. The filter that was meant to save CI minutes is a signpost pointing at the trigger.
The decoy secrets file, and the line that gives it away
Whoever wrote this had thought about secrets. On pull request builds, the workflow pointed its secrets-file variable at a decoy (the real host paths below are redacted at Mozilla's request):
- name: Select secrets file
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "SECRETS_FILE=fake-secrets" >> "$GITHUB_ENV"
else
echo "SECRETS_FILE=$REAL_SECRETS" >> "$GITHUB_ENV"
fiThe intent is legible and it feels responsible: PRs get fake secrets, real secrets only on trusted builds. If you are asking "will this work?" it reads as a boundary. It is not a boundary. It is a decoy, and once you have arbitrary code execution on the host, a decoy is worth nothing.
The tell is somewhere else in the same workflow, in the step that stages the real secrets file:
install -m 600 "$REAL_SECRETS" "$GITHUB_WORKSPACE/secrets"No sudo. The ordinary runner user copies the real secrets file and sets its mode, and it succeeds. Which means the runner user can already read that file. Which means the SECRETS_FILE variable pointing at the decoy changes nothing about what an attacker can reach. My injected builder.sh never has to care which file the workflow intends to use. It does this:
cat "$REAL_SECRETS"The real secrets sat at a fixed, well-known location on the host. The decoy variable is a sign on a door that has no lock and no walls. This is the assumption-deletion move in its purest form. The workflow assumed "PR build" meant "no access to real secrets," and the install line without sudo quietly proves that assumption was never enforced by anything.
The approval gate is a speed bump
There is one thing standing between a stranger and that runner: GitHub's first-time-contributor approval. A brand-new contributor's workflow runs need a maintainer to click "Approve and run." That sounds like a wall.
It is first-time-only. So the play is patient, not loud:
- Open one small, genuinely useful PR. Fix a typo, tidy a comment. Something a maintainer approves and merges without a second thought.
- That single approval promotes you to a returning contributor. Your later PRs run automatically, no click required.
- Now open the second PR. This one quietly edits
builder.sh.
The gate never asked "is this the same person who fixed that typo, and are they still trustworthy?" It asked "have we run this account before?" Two different questions. The adversary lives in the gap between them.
Proving it without pulling the trigger
Here is the part I care most about, because it is where responsible CI/CD research separates from reckless CI/CD research. I did not detonate anything on Mozilla's machine. Firing a real payload at someone else's production hardware to "prove" a finding is how you turn a bug report into an incident. The whole point is to demonstrate the pathway using artifacts that already exist in public.
GitHub's Actions API exposes the run history of a public repo, including which runs came from fork heads and where they executed. Pulling that history showed exactly what I needed: pull requests originating from an outside fork had already run to completion on the self-hosted runner. A successful ARM64 builder run, from a fork head, had executed on Mozilla's Mac mini. The mechanism wasn't theoretical. The repo's own history proved fork-controlled code had reached the host.
The workflow source did the rest of the talking. It exposed enough internal detail about the runner and the infrastructure it was wired to for a report to establish reach and blast radius, none of it requiring me to run a single command against Mozilla. Prove the pathway with public evidence, and leave the target alone.
The discipline of not overclaiming
This is where a lot of CI/CD reports go wrong, and where I think the honest version earns more than the inflated one.
I could have written "remote code execution on Mozilla's release-engineering infrastructure, capable of signing malicious Firefox builds." It would have been a lie by omission, and a good triager would have caught it. So I scoped it truthfully.
The RCE primitive was real and High-severity. Arbitrary code on a persistent Mozilla-owned host is not in dispute. But the realized credential blast radius was bounded. The only Taskcluster pool ever provisioned on that runner was a Level-1 "try" pool, and its token had been rotated. Level 1 and try builds use a fake chain-of-trust signing key by design, so even a full compromise of that machine could not sign a shipping Firefox release. The Level-3 release pool was never on that box. There was no evidence anyone else had found or used the path.
So the sentence I actually wrote was closer to this: a latent High-severity RCE primitive, with realized credential exposure bounded to an L1 try pool that cannot sign production software. That honesty is why it landed as a High (8.5) and got paid, instead of getting downgraded for hand-waving or dismissed for crying wolf. Triagers can tell the difference between someone who understands the impact and someone inflating it, and the credibility you spend overclaiming one bug is charged against every bug you file after it.
Because the asset was technically outside the formal program scope, the $xxxx was a bonus. At Mozilla's request I refiled it through their bug tracker. They resolved it. They had already pulled the trigger, rotated the token, and taken the runner offline.
The fix is one sentence: never run fork pull-request code on a self-hosted runner. Use ephemeral, GitHub-hosted runners for anything triggered by pull_request. If self-hosted is genuinely unavoidable, gate it behind a reviewed allowlist and keep no reusable secrets on the host.
This is a trend, not a one-off
I want to be clear that the Mozilla bug is not a Mozilla problem. It is one instance of an attack surface that has been detonating across the whole ecosystem, and the same handful of assumptions keep failing in the same handful of ways. If you maintain a .github/workflows/ directory, these are the questions an adversary is already asking about it.
Is anything using pull_request_target? In a public repo, that trigger runs with the base repository's secrets regardless of where the PR came from, the "pwn request." The Trivy attack in March 2026 turned exactly this into a compromise, and attackers followed up by scanning GitHub specifically for repos using the trigger. If you use it, do not check out and run the PR's code in the same job.
Is any ${{ github.* }} value interpolated straight into a run: block? Branch names, PR titles, commit messages, and issue bodies are all attacker-controllable, all untrusted strings. When you paste them into a shell step, the runner parses them as syntax before the shell ever sees them as data. Ultralytics learned this in 2024 when a branch name carrying a curl command was interpolated into a run: step and executed. Nx and s1ngularity chained the same trick with pull_request_target to exfiltrate the GITHUB_TOKEN. The safe pattern is to route the value through the environment so the shell reads it as a runtime string, not as code:
- name: Safe
env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "$TITLE" # a string at runtime, not syntax at parse timeAre third-party actions pinned to a tag or to a commit SHA? Tags are mutable, and whoever controls the action can repoint @v1 at anything. The tj-actions compromise rode this into roughly 23,000 repositories. The March 2025 aquasecurity/trivy-action attack repointed 76 of 77 version tags to a single malicious infostealer commit. Pin to a full 40-character SHA and a moved tag can't touch you.
Is a self-hosted runner attached to a public repo? This is the core sin, and it is the one that maps straight back to Mozilla. PyTorch's supply-chain compromise started with a trivial PR that triggered a job on a self-hosted runner and walked away with root. Static runners carry state between jobs. A poisoned cache, a planted binary, or a lingering credential from one job can seed the next. Shai-Hulud went a step further and registered persistent self-hosted runners as its own command-and-control channel. Ephemeral runners are not a nicety here. They are the containment.
And is GITHUB_TOKEN scoped like it matters? Default it to read-only, then declare explicit least-privilege permissions: per job. Reference secrets through env: rather than inline ${{ secrets.* }} in a command line, where they can surface in /proc. Where you can, drop long-lived static secrets entirely and mint short-lived cloud credentials through OIDC.
You do not need to run down every item to be safe from the Mozilla bug. Two questions would have killed it: does fork code run on our own hardware, and can the runner user read the real secrets. But the reason to know the whole list is that the adversary does.
To its credit, GitHub has been closing this off at the platform level. Through mid-2026 it tightened the defaults. Newer releases of actions/checkout refuse the most dangerous fork pull request patterns out of the box, fork-triggered runs now receive read-only cache tokens so they cannot poison what a later job restores, and repository admins can require manual approval for every outside contributor rather than only first-timers. None of that repairs a self-hosted runner that already trusts fork code, but it raises the floor for everyone who never touched a setting.
Back to the mindset
None of this required a zero-day, a fuzzer, or a single exotic primitive. It required reading a workflow the way the person who wrote it could not, assuming nothing, and treating every comforting line (the fake secrets file, the paths: filter, the approval gate) as a claim to be tested rather than a fact to be trusted.
The defender writes SECRETS_FILE=fake-secrets and sees a boundary. The attacker reads the install -m 600 line without sudo three lines down and sees there was never a boundary at all. Same file, two different questions. It is worth learning to ask the second one about your own systems first.
Credit: Sujal Tuladhar (evilgensec / evilgenius01). Disclosed responsibly to Mozilla.