Key Takeaways
- Hades shipped .pth startup hooks, so the payload fired every time Python started rather than once at install.
- The same trick had already shipped inside LiteLLM eleven weeks earlier, so the technique was not new.
- Wheels skip the source build step, but they can still drop a file that runs code at every interpreter start.
- Auditing your own environment is a handful of commands, and most of the .pth files you turn up will be legitimate.
A developer runs pip install, the command exits cleanly, and the CI job goes green. Nothing got built and no source build step ran, because the package shipped as a wheel, and wheels were supposed to be the safer choice.
Then the next Python process starts. Something reads ~/.aws/credentials.
That is roughly the shape of the Hades campaign, disclosed by Orca Security on June 8, 2026 across 26 PyPI packages. Socket, looking at the same 37 malicious wheel files, counts 19 packages, so the headline number depends on whose inventory you read. Either way it slides past several controls built specifically to reduce install-time code execution, not by exploiting a bug but by using a feature that has been sitting in the standard library since before most of those controls existed.
The Anatomy of the Hades Campaign
What sets this one apart from routine typosquatting isn’t any single trick. It’s the combination.
These were not fake lookalike names waiting for somebody to fat-finger an install. The publishing pattern looks like maintainer account takeover followed by a burst of consecutive patch releases across that maintainer’s portfolio, concentrated in scientific Python, bioinformatics, and ML tooling. Packages teams already trusted started shipping poisoned new releases.
Each compromised release dropped a file matching *-setup.pth straight into site-packages. Instead of chasing crypto wallets or browser cookies the way a lot of infostealers do, the payload went after cloud credentials, developer secrets, and CI/CD tokens. The startup hook is only stage one. It pulls down the Bun JavaScript runtime from GitHub and runs an obfuscated JavaScript payload called _index.js. A Python startup hook bootstrapping a JavaScript runtime to steal AWS keys is a strange sentence to write and an accurate description of what happened.
Some samples also carried a persistence daemon called gh-token-monitor that threatens destructive action if the stolen tokens get revoked. Malware designed to punish you for doing correct incident response is novel enough to sit with for a second.
Hades didn’t come out of nowhere. It follows the LiteLLM compromise from March 2026 and the durabletask package compromise reported in May 2026, which puts three notable PyPI supply chain attacks inside a window of under three months. All three have been linked to the same broader campaign lineage, tracked under names including TeamPCP, Mini Shai-Hulud, and Miasma.
| Incident | Reported | Mechanism |
| LiteLLM | Mar 2026 | Malicious .pth file shipped after the LiteLLM publishing pipeline was compromised |
| durabletask (Microsoft) | May 2026 | Stolen PyPI publishing token used to push three releases directly |
| Hades | Jun 2026 | .pth startup hooks across 26 packages, credential theft, worm-like spread |
That LiteLLM row is the uncomfortable part. The startup hook trick was not new in June. It shipped in a package pulling millions of downloads a day back in March, in the form of a file named litellm_init.pth, and eleven weeks later the technique was still getting malicious wheels past controls built around install-time execution.
Why a .pth File Runs Before Your Code Does
Here’s the mechanism, and it’s kind of impressive in the way ugly things sometimes are.
During normal startup, Python’s site module processes the .pth files it finds in its configured site directories. Officially that’s for adding directories to sys.path, one per line. But there’s a second, much less advertised behavior in the same module. Any line beginning with import followed by a space or a tab gets executed as Python code.
So one line is all it takes.
import os,base64;exec(base64.b64decode(‘…’))
Drop that into a file with a .pth extension inside site-packages and nothing needs to import it or call it. It runs every time a Python process starts in that environment. On a busy CI runner or a dev laptop that’s dozens of times a day, and it fires for pip, for python -c, and for whatever your editor’s language server is doing in the background.
Did You Know: This isn’t a bug or a clever exploit. It’s documented behavior in Python’s site module, it predates modern packaging tooling by years, and MITRE now tracks it as a named adversary technique, Python Startup Hooks, sub-technique T1546.018 under Event Triggered Execution.
Setuptools uses .pth files for some editable installs, and coverage.py can use the same mechanism to start coverage automatically inside subprocesses. That’s exactly why it works so well as an attack vector. A scanner trying to flag a hostile .pth file has to somehow tell it apart from distutils-precedence.pth, which ships with setuptools and does structurally the same thing for entirely legitimate reasons.
Why Wheels Were Supposed to Be the Safe Format
For years the standard advice has been to prefer wheels over source distributions, and the reasoning holds up fine. Installing an sdist normally means building it first, and that build runs package-controlled code through a PEP 517 backend, which is arbitrary code execution however you dress it up. A wheel is already built, so installing one is mostly unpacking and placing files.
That advice is still reasonable, and this campaign worked anyway. A wheel can contain a .pth file, and unpacking a wheel drops any top-level .pth file straight into site-packages.
The execution just happens on a delay.
| Mechanism | When the code runs | Executes during install or build |
| sdist build backend | During install, at build time | Yes |
| npm postinstall | During install | Yes |
| Wheel .pth hook | Every interpreter start, after install | No |
| Rust build.rs | During compile | Yes |
Look at the wheel row. An install-time check that inspects the environment and finds nothing isn’t malfunctioning. At install time, nothing hostile has run yet. The payload waits, and it fires on the next python invocation, which in a container build might not happen until the image is already pushed and serving traffic.
Watch Out: Install-time sandboxing and a wheels-only policy are both useful controls, and neither one on its own stops a malicious .pth hook from running later, when Python starts up outside that sandbox. If your threat model assumed those two had arbitrary code execution covered, that assumption needs another look.
One of the strongest control points sits before the artifact reaches the environment at all, which means inspecting and verifying what a package actually contains rather than trusting install-time behavior to report it. Rebuilding packages from source and reading what comes out the other side is one way to get there.
What the Payload Was Hunting For
Cloud credentials, mostly. And the search space is a lot smaller and more predictable than most teams assume, because a developer machine or CI runner tends to expose valuable secrets through a short list of well-known locations. The credentials and config files under ~/.aws. Application default credentials under ~/.config/gcloud. The kubeconfig at ~/.kube/config. Registry auth in ~/.docker/config.json. Whatever environment variables the CI platform injects. And on cloud hosts, the instance metadata service at 169.254.169.254. The Hades reporting also puts GitHub, npm, PyPI, and RubyGems tokens and SSH keys near the top of the take, which is how a credential stealer turns into something that spreads on its own.
Where it’s reachable, the metadata service is the real prize. It’s what can turn a popped laptop into compromise of a much larger slice of the surrounding cloud environment. On a build agent with an attached IAM role, a payload with real code execution rather than just SSRF can retrieve temporary credentials for whatever that role is permitted to do, a straightforward GET on older IMDSv1 setups or a PUT then GET token exchange where IMDSv2 is enforced. Either way, nothing has to exist on disk.
That’s the strategic logic of the whole thing. Credentials pulled from a build environment are worth more than credentials pulled off somebody’s laptop, and CI runners tend to get less direct human attention than workstations while holding unusually valuable secrets.
The Part Aimed at AI Scanners
Multiple reports on Hades identified indirect prompt injection inside the packages, text written specifically to manipulate AI-assisted security review rather than to fool the human reading the pull request.
That’s a notable new wrinkle, and it only makes sense as a tactic if attackers now assume there’s an LLM sitting between the package and the engineer. In mid-2026 that isn’t a bad assumption. Some teams already have a model summarizing dependency diffs before a human looks at anything.
Which creates an uncomfortable problem. A model reading untrusted package contents is, by definition, processing attacker-controlled input. It needs the same handling as any other untrusted input path. What it must not get is the role of independent judge on whether the thing it just read is safe. Treating that verdict as authoritative is a circular dependency dressed up as a control.
Expert Tip: If a model reviews dependency contents anywhere in your pipeline, treat the package as untrusted input and keep its text out of the reviewer’s instruction context. Pair it with deterministic static checks, and don’t let the model’s verdict be the only gate.
How to Audit Your Environment Right Now
Worth doing whether or not you think you were hit, and it’s a handful of commands rather than a project. If you want the fast answer on exposure, the affected package inventories are published in the Orca and Socket write-ups, and diffing those against your lockfiles beats guessing.
First, find the site directories that actually get processed. This matters because the per-user site-packages directory counts too, and a system-only list will quietly miss it.
python -m site
Then, on Linux or macOS, look for .pth files carrying executable lines. Windows needs the same sweep through the site-packages directories under your Python installs and virtual environments.
find / -path “*site-packages*” -name “*.pth” \
-exec grep -lE “^import[[:space:]]” {} \; 2>/dev/null
That whitespace class matters, because site only executes lines where import is followed by a space or a tab, and a bare ^import will happily match a perfectly innocent line starting with importlib. While you’re in there, the Hades-specific pass is a search for anything matching *-setup.pth, and sitecustomize.py and usercustomize.py deserve a look too, since they belong to the same startup hook family. Some Hades samples reportedly injected code into __init__.py instead of shipping a .pth file at all, so a .pth-only sweep is not a clean bill of health.
You’ll get hits, and that’s normal. Expect distutils-precedence.pth from setuptools, __editable__*.pth from editable installs, maybe one or two from scientific libraries. Read them anyway. Legitimate .pth files usually hold path entries or small import hooks generated by tooling you can name. Encoded blobs, exec() calls, subprocess spawns, or anything reaching for the network deserve a much closer look, though no single pattern proves a file is hostile either way.
To work out whether site processing is behind some behavior you’re chasing, start the interpreter with it switched off.
python -S -c “print(‘site processing skipped’)”
If the behavior disappears under -S, site initialization is implicated and .pth hooks are one thing to investigate. That narrows the search. It does not prove which startup mechanism was responsible, because -S disables considerably more than .pth execution.
Checking a wheel before it touches an environment is two commands.
pip download –no-deps somepackage
unzip -l somepackage-*.whl | grep ‘\.pth’
A wheel that ships a .pth file isn’t automatically malicious, and a .pth file doesn’t automatically execute anything, since only lines beginning with import plus whitespace ever run. But a wheel carrying one has earned a look at the contents of that file, and if there’s an executable import line in there, it deserves a conversation before it lands in your lockfile rather than after.
If you do turn something up, remove the package and then purge the wheel cache, because a poisoned wheel sitting in pip’s or uv’s cache will happily reinstall itself the next time something resolves that version. Then rotate everything the environment could reach. Cloud keys, GitHub and PyPI and npm tokens, SSH keys, kubeconfigs. Orca’s guidance on Hades treats package removal and credential rotation as required rather than optional, and that’s the right read.
Why pip install Is the Wrong Place to Start Trusting
LiteLLM, durabletask, and Hades differ in the details, but the pattern underneath is identical. None of them needed a vulnerability in the application consuming the package. Each one abused a part of the packaging pipeline that the system treats as trustworthy by design, whether that was a compromised release process at LiteLLM, a stolen publishing token at durabletask, or hijacked publishing identities plus a documented startup hook in Hades.
One thing that pattern rules out is hash pinning. The malicious litellm_init.pth was correctly declared in the wheel’s own RECORD with a matching hash, so pip install –require-hashes would have sailed straight through. Hashes prove that the artifact you downloaded matches the artifact you approved. They don’t prove the approved artifact was benign, or that its publisher hadn’t been compromised.
Practically, the response splits by how much Python you’re running.
A handful of services? Audit your .pth files, and put a short quarantine window on brand-new releases before adopting them, because these campaigns got much of their initial reach from newly published malicious releases, before defenders had time to react.
Running Python across a large fleet? The audit is a fine starting point, but the stronger architectural move is shifting package screening upstream of installation so a hostile artifact is less likely to reach site-packages in the first place.
And if your CI runners are holding cloud role credentials, start there. Keep those roles narrowly scoped, get rid of long-lived static credentials, and use short-lived ones wherever the platform lets you, because that’s where the blast radius lives when a bad install turns into an incident.
Securing the Python Supply Chain
Shifting that screening upstream is the whole premise behind SecureChain for Python. Packages get rebuilt from source and checked before they ever reach your environment, rather than scanned after they’re already running on every interpreter start. And the coverage doesn’t stop the day you install, either.
New CVEs get disclosed against packages you’re already running, and eventually a maintainer walks away entirely. SecureChain keeps patching what’s actually in your stack, and for packages in the catalog that coverage continues past end of life instead of leaving you stuck choosing between an unpatched dependency and a forced rewrite. If a wheels-only policy or a post-install scan is currently doing the work of a line of defense, Hades is a reasonable prompt to check where that line actually sits, at install and every day after.
FAQ
What is PyPI malware?
Malicious code published to the Python Package Index, usually hiding inside a package that impersonates a legitimate one, or inside a legitimate package whose publisher account got compromised. It often runs during install or at interpreter startup rather than waiting for your application code to call it.
What is a .pth file and why is it dangerous?
A .pth file sitting in one of Python’s site directories gets processed by the site module during startup. Lines that begin with import followed by a space or a tab get executed, which means a .pth file can run arbitrary code on every single Python process start. It’s documented, intended behavior, and setuptools and editable installs both use it legitimately, which is precisely what makes hostile ones awkward to spot.
Do wheels protect against malicious Python packages?
Only partly. Wheels avoid the source build step that installing an sdist requires, and that build step can execute package-controlled code. They don’t stop a package from shipping a .pth file with an executable import line that runs at every interpreter start.
How do I check if a package installed a startup hook?
Search the site directories Python actually uses for .pth files with lines starting with import followed by whitespace, then read them properly. Running the interpreter with -S disables automatic site initialization, including .pth processing, so it helps establish whether startup behavior is involved without proving which mechanism caused it.
Does pip-audit or a scanner catch this?
Depends on the tool. Advisory-based scanners lean on their underlying vulnerability and malicious-package feeds, so a newly published malicious release may not get flagged straight away. Behavioral and artifact-based tooling sometimes catches things faster, and coverage varies a lot between vendors.




