
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.

The timeline
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):
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:
${{ 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:
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
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
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
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 workflowsSources: 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.