Teardown: the LiteLLM PyPI compromise

A publish token stolen through a security scanner, and a stealer that ran every time Python started.

What happened on 24 March 2026, how a CI secret became a PyPI release, and where Chainsaw would and would not have stopped it.

Questions and answers

What happened?

On 24 March 2026, two malicious versions of the litellm Python package, 1.82.7 and 1.82.8, were published to PyPI with a credential stealer inside. LiteLLM’s security update says they were live from 10:39 UTC for about 40 minutes before PyPI quarantined them, though the same page lists 10:39 to 16:00 UTC as the window in which an install could have picked them up. The Python Packaging Authority published advisory PYSEC-2026-2 (also MAL-2026-2144) at 15:35 UTC that day. Researchers attribute the attack to TeamPCP, the group behind the Trivy compromise the week before.

LiteLLM says no malicious code reached its GitHub main branch, and that users of the official LiteLLM Proxy Docker image were not affected. The exposure was pip install litellm without a pinned version, directly or as a dependency of another tool.

How did the attacker get publish rights?

With a PyPI API token taken from LiteLLM’s own CI pipeline. The PyPA advisory says the token was exposed through an exploited Trivy dependency. LiteLLM’s CI ran the Trivy security scanner, and TeamPCP had already tampered with Trivy’s releases and its GitHub Action tags. Berri AI’s CEO told The Register that the PYPI_PUBLISH token was stored as an environment variable in the project’s CI, was sent to Trivy, and was taken from there.

The maintainers’ accounts had two-factor authentication, but an API token does not need it. LiteLLM’s update says the attacker skipped the official release workflow and uploaded the packages to PyPI directly.

What changed in the package?

The two malicious litellm versions published to PyPI on 24 March 2026, 1.82.7 and 1.82.8, carry the same payload in different places, according to StepSecurity:

  • 1.82.7: a base64 blob inside litellm/proxy/proxy_server.py, which runs when the proxy module is imported or started.
  • 1.82.8: a new 34,628-byte file, litellm_init.pth, listed in the wheel’s own RECORD with a valid hash. LiteLLM says 1.82.8 additionally kept the proxy_server.py payload.

Both versions used the same RSA public key and the same exfiltration endpoint, models.litellm[.]cloud, which is not a LiteLLM domain. The .pth file is the bigger change. Python executes lines in .pth files in site-packages at interpreter startup, so in practice 1.82.8 ran the payload every time Python started, with no import litellm needed.

What ran on import?

In litellm 1.82.8, the payload was triggered every time Python started, with no import litellm needed. Python executes lines in .pth files in site-packages at interpreter startup, and litellm_init.pth launched a detached Python process to decode and run the payload. 1.82.7 ran the payload when code imported the LiteLLM proxy.

Then, according to StepSecurity and the PyPA advisory, the payload:

  • Collected credentials, for example SSH keys, Git and Docker credentials, AWS, GCP and Azure config, Kubernetes configs and service-account tokens, .env files, ~/.npmrc, ~/.vault-token, ~/.pgpass, shell history, TLS keys, CI/CD files and crypto wallets.
  • Called cloud APIs live: queried the cloud metadata service at 169.254.169.254 and read secrets from AWS Secrets Manager and SSM Parameter Store.
  • Sent the data out: encrypted everything with AES-256, wrapped the key with RSA, and POSTed the result as tpcp.tar.gz to models.litellm[.]cloud, which is not a LiteLLM domain.
  • Stayed on the host: wrote ~/.config/sysmon/sysmon.py, ran the script as a systemd user service named “System Telemetry Service”, and polled checkmarx[.]zone/raw for more instructions.
  • Spread through Kubernetes: with a service-account token, created privileged pods named node-setup-<node-name> in kube-system, mounting each node’s root filesystem and installing the same backdoor there.

Would Chainsaw have stopped it?

Yes, for litellm installs that went through the Chainsaw proxy: 1.82.8 is refused on first download, and 1.82.7 is kept out by the release hold until the malware feed lists the version. The evidence is a re-run of 58 attacks from 2026 against production Chainsaw (v0.22.118) on 19 October 2026:

  • 1.82.8 is refused on day 0. A default rule refuses packages that execute code at import time in clearly malicious shapes. The .pth line in 1.82.8 launches a child Python interpreter on a base64-decoded payload, which is one of the refused shapes, so the first request for the wheel fails.
  • 1.82.7 is not refused on its bytes. The 1.82.7 payload is a bare exec of decoded code inside a module. A bare exec only adds to a package’s risk score, because a broader import-time rule flagged widely used packages such as pip, setuptools and numpy.
  • The release hold covers 1.82.7. Chainsaw holds every new PyPI version for 48 hours by default, so an unpinned pip install litellm resolved to 1.82.6. The known-malicious feed listed 1.82.7 about 11 hours after the attack date, well inside the hold, so 1.82.7 stays refused after the hold ends.
  • Installs that bypassed the proxy got the malware. A machine or CI runner pointed straight at pypi.org was not protected. Chainsaw therefore also gates CI, pins laptops through MDM and blocks direct registry traffic at the network.

What should you do?

Treat any machine, container build or CI runner where litellm 1.82.7 or 1.82.8 was installed as compromised, according to LiteLLM and the PyPA advisory. First confirm the install (step 1), then rotate every credential the host could reach from a clean machine (step 3).

  1. Check what you installed. Run pip show litellm, and search lockfiles, Docker builds and CI logs for the two versions. Look for litellm_init.pth in site-packages.
  2. Look for persistence. Check for ~/.config/sysmon/sysmon.py and ~/.config/systemd/user/sysmon.service, and stop and remove the service if present. In Kubernetes, run kubectl get pods -n kube-system | grep node-setup, delete any matches, and check each node for /root/.config/sysmon/sysmon.py.
  3. Rotate every credential the host could reach, from a clean machine, for example SSH keys, AWS, GCP and Azure credentials, Kubernetes tokens, database passwords, API keys in .env files and environment variables, and package registry tokens. Rotate secrets held in AWS Secrets Manager and SSM too, even without evidence of a read.
  4. Search network logs for models.litellm[.]cloud and checkmarx[.]zone.
  5. Pin litellm to 1.82.6 or to a later verified release. LiteLLM shipped 1.83.0 on 30 March 2026 from a rebuilt release pipeline.

For the next one:

  1. Pin CI tools and actions to a commit or digest, not a tag. Trivy reached LiteLLM’s pipeline through version tags repointed at malicious code.
  2. Keep long-lived publish tokens out of CI environment variables. Any tool in the job can read them. LiteLLM says it is moving to PyPI trusted publishing and signed releases.
  3. Pin your own dependencies. The official LiteLLM Docker image pinned its dependencies and never pulled the bad versions.
  4. Hold brand-new releases before installing. Both windows LiteLLM reports, 40 minutes and about five hours, are shorter than a one-day hold.

Sources

One teardown per notable attack

Get the next teardown by email

What happened, the install-path angle most coverage misses, and the rule that refuses it.