Cloud Security Office Hours Banner

Ultralytics / PyPI 2024

Step-by-step kill chain mapped to MITRE ATT&CK Cloud, sourced from official post-mortems and primary technical analyses.

December 2024 Critical PyPI / GitHub Actions

Ultralytics - Fork Branch Name as Shell Injection → Actions Cache Poisoned → Malicious Build Published Through Trusted Publishing → XMRig, Then a Forgotten Token

Between December 4 and 7, 2024 the Ultralytics YOLO package - the most widely used computer-vision library on PyPI - shipped four malicious releases carrying an XMRig Monero miner. The mechanism is the one worth studying. A pull_request_target workflow checked out and ran code from a fork pull request with cache-write permission in scope, and the fork's branch name was interpolated into a shell step. That was enough to poison a GitHub Actions cache entry, which the release build then restored, so the payload entered a build the maintainers ran themselves. Versions 8.3.41 and 8.3.42 were published through the project's legitimate workflow using PyPI Trusted Publishing, which means they carried correct provenance. Then on December 7 the attacker published 8.3.45 and 8.3.46 directly, using an unrevoked API token left over from before the project adopted Trusted Publishing. This is the direct ancestor of Mini Shai-Hulud fourteen months later.

A branch name was the entire injection vector
Trusted Publishing did not prevent it - provenance was correct
4 versions: 8.3.41, 8.3.42, then 8.3.45 and 8.3.46 via a stale token
~12 hours exposure for the first version, ~1 hour for the second
📄 PyPI Blog - Supply-chain attack analysis: Ultralytics ↗ 📄 Socket - Compromised through GitHub Actions cache poisoning ↗
Initial Access - The Branch Name Was the Payload
01
A pull_request_target workflow interpolated the fork's branch name into a shell step, so naming a branch after shell metacharacters executed code with the base repository's permissions
T1059.004 - Unix Shell T1190 - Exploit Public-Facing Application

The same trigger and the same class of mistake as Nx / s1ngularity eight months later, through a different field. pull_request_target runs with the base repository's secrets and permissions against fork-controlled input, which is its entire purpose and its entire danger. Here the attacker-controlled value was the branch name: create a fork, name a branch something containing shell syntax, open a pull request, and the workflow interpolates it into a run: step. No approval was needed because pull_request_target does not require one, and nobody reviews a branch name. Every field a pull request carries - title, branch, body, author - is attacker-controlled free text, and each has now been used in a real attack.

Trigger: pull_request_target, running with base-repo secrets against fork input
Injection point: The fork's branch name, interpolated into a shell step
Approval needed: None - pull_request_target does not gate on it
Critical extra: Cache-write permission was in scope for that job
General rule: Every PR-supplied field is attacker-controlled text
pull_request_targetBranch Name InjectionT1059.004
🗄 Persistence - Poison the Cache, Wait for the Release Build
02
The injected code wrote a poisoned entry into the GitHub Actions cache, which a later legitimate release build restored and executed
T1195.001 - Compromise Software Dependencies and Development Tools

The Actions cache exists to make builds faster by carrying artifacts between runs, which means it is shared mutable state that crosses the boundary between untrusted pull-request jobs and trusted release jobs. Poisoning an entry from the low-trust side and waiting for the high-trust side to restore it converts a fork PR into code execution inside the maintainers' own release pipeline. Nothing in the release run looks wrong: it checks out clean source from the repository, runs the workflow the maintainers wrote, and produces a package - having silently restored an attacker's artifact on the way. This is precisely the technique Mini Shai-Hulud would industrialize in 2026, and the lesson is the same both times: a cache shared across trust levels is not a cache, it is a channel.

Mechanism: Write a poisoned entry to the Actions cache from an untrusted PR job
Effect: A later legitimate release build restores and executes it
Why the release run looks clean: Clean checkout, unmodified workflow, normal output
Structural problem: The cache is shared mutable state spanning trust boundaries
Repeated in: Mini Shai-Hulud / TanStack, 2026
Actions Cache PoisoningTrust BoundaryT1195.001
📦 Supply Chain - Published With Correct Provenance
03
Versions 8.3.41 and 8.3.42 went to PyPI through the project's own Trusted Publishing workflow, so their attestations were genuine
T1195.002 - Compromise Software Supply Chain

This is the uncomfortable part and PyPI's own analysis is admirably direct about it. Trusted Publishing binds a release to a specific workflow in a specific repository via OIDC, removing the long-lived token an attacker would otherwise steal. It worked exactly as designed. The malicious releases really were built by the real workflow in the real repository, so the attestation said so truthfully. Provenance answers "did this come from the pipeline it claims" and not "was the pipeline uncompromised when it ran." PyPI notes the compensating value clearly: attestations did not prevent the attack, but they made the forensics tractable, because it was possible to prove the first two versions came through the workflow rather than a token. Version 8.3.41 was live about twelve hours, 8.3.42 about one.

Published via: The project's genuine Trusted Publishing workflow, over OIDC
Attestation: Correct - the build really did come from that workflow
What provenance proves: Origin of the build, not integrity of the build environment
8.3.41: Published Dec 4 20:51 UTC, removed Dec 5 09:15 UTC (~12 hours)
8.3.42: Published Dec 5 12:47 UTC, removed within about an hour
Compensating value: Attestations made it provable which path each release took
Trusted PublishingValid ProvenancePyPIT1195.002
🔑 Credential Access - The Token Nobody Revoked
04
On December 7 the attacker published 8.3.45 and 8.3.46 directly, using an unrevoked PyPI API token still available to the workflow from before the project adopted Trusted Publishing
T1552.007 - Unsecured Credentials: Container API

The second phase is the more ordinary failure and the more common one. Ultralytics had migrated to Trusted Publishing, which is the right move, but the old API token was still present in the workflow and still valid. Migrating to a better credential mechanism does not revoke the worse one - somebody has to do that explicitly, and it is exactly the kind of cleanup that gets deferred because everything already works. So the attacker skipped the elaborate cache-poisoning route entirely and just published, bypassing GitHub Actions altogether. A migration is not finished when the new path works; it is finished when the old path is dead.

December 7: Versions 8.3.45 and 8.3.46 published directly to PyPI
Credential: An unrevoked API token predating the Trusted Publishing migration
Bypassed: GitHub Actions entirely - no cache poisoning needed
Root cause: Adopting a better credential mechanism without revoking the old one
Rule: A migration is finished when the old path is dead, not when the new one works
Stale API TokenIncomplete MigrationT1552.007
⛏ Impact - A Cryptominer in a Computer-Vision Library
05
The payload ran XMRig to mine Monero on machines that installed the package - which for a computer-vision library means GPU hosts and training clusters
T1496 - Resource Hijacking

Cryptomining is the least imaginative payload available, and the target selection makes it rational. Ultralytics YOLO is installed on exactly the machines an attacker mining cryptocurrency wants: GPU workstations, training clusters and inference servers, running heavy sustained compute where an extra process consuming resources is easy to miss. The restraint is worth noticing though. The same access could have exfiltrated model weights, training data or cloud credentials from those hosts, and it did not - which means the next actor to find this technique may not be so unambitious. Read it as a demonstration, and note it happened in an AI library at a point when AI infrastructure was becoming an obvious target, a trajectory that continues through LiteLLM and the Hugging Face chain.

Payload: XMRig, mining Monero
Why this target: YOLO installs onto GPU workstations, training clusters and inference servers
Concealment: Sustained heavy compute makes one more busy process unremarkable
What it did not take: Model weights, training data, cloud credentials - all reachable
Read it as: A demonstration of access, not the limit of what the access allowed
XMRigCryptominingAI InfrastructureT1496

🛡 How to Defend Against This Chain

Never interpolate pull-request-supplied values into shell steps, and keep pull_request_target away from fork content. Branch name here, PR title in Nx - the field changes and the mistake does not. Pass any PR-derived value through the env: block rather than inlining ${{ }} into run:, and reserve pull_request_target for jobs that need base-repo secrets and never touch fork-controlled code.
Do not share an Actions cache across trust boundaries. The cache carried the payload from an untrusted PR job into a trusted release build. Scope cache keys so pull-request jobs cannot write entries that release jobs restore, deny cache-write permission in fork-triggered workflows, and treat any cache restored during a publish as untrusted input. This single control also breaks the 2026 Mini Shai-Hulud chain.
Finish credential migrations by revoking the old credential. Trusted Publishing was in place and the stale API token still worked, which is how the second wave shipped. When you move to OIDC-based publishing, delete the old tokens the same day, then verify from the registry side that no other credential can publish the project.
Understand what provenance does and does not prove, and consume it accordingly. The first two malicious releases had correct attestations, because they genuinely came from the right workflow. Provenance establishes origin, not integrity of the build environment - it is still worth having, and PyPI's point stands that it made this incident forensically tractable. On the consuming side, pin by hash so a valid-but-malicious release cannot enter your builds silently, and add a publication delay so a version live for twelve hours never reaches you.
Constrain egress and watch resource use on build and AI hosts. A miner needs to reach a pool, and a poisoned build often needs to fetch a stage. Default-deny egress from runners and from training infrastructure with a narrow allow-list. Then baseline what sustained compute looks like on GPU hosts, because "the machine is busy" is the normal state there and the reason a miner survives.

Related defense topics