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.
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 permissionsThe 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.
pull_request_target, running with base-repo secrets against fork inputInjection point: The fork's branch name, interpolated into a shell step
Approval needed: None -
pull_request_target does not gate on itCritical extra: Cache-write permission was in scope for that job
General rule: Every PR-supplied field is attacker-controlled text
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.
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
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.
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
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.
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
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.
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
🛡 How to Defend Against This Chain
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.