Kai Ole Hartwig
24 min read
By

LiteLLM 1.107.0: One Line of Build Configuration Turns Every Python Start into a Credential Thief

Every time a Python interpreter starts, it executes lines in .pth files in site-packages that begin with import, and then loads a module called sitecustomize if it finds one. The compromised LiteLLM 1.107.0 wheels shipped exactly these two kinds of files, and a single extra line in the build configuration was enough to put them there. The application code was unchanged, the Git tag was clean, and the pipeline signed the result like any other release. Even so, the stealer ran in every Python process in the environment, including pip itself, without anyone ever writing import litellm. To understand why clean sources and signatures did not protect anyone here, you have to look at the path between the repository and site-packages.

TL;DR — 90 seconds

Startup hooks instead of an import path

LiteLLM is a gateway that forwards requests to many LLM providers through a single interface and manages their API keys centrally. Its wheels are built with maturin, as the [tool.maturin] section in pyproject.toml shows, which is exactly where the attacker made the change. A wheel is a ZIP archive with a defined structure. Besides the package code, it may contain a <name>.data directory whose subdirectories, such as purelib or scripts, map to installation paths. According to the wheel specification, the installer moves each of these subtrees to its destination. Whatever sits under purelib therefore lands at the top level of site-packages, on an equal footing with every other package. The maturin documentation accordingly describes the data option as the path to such a data directory.

Why this becomes dangerous is shown by the site module, which Python loads on every start. It scans the site-packages directories for files ending in .pth, sorted alphabetically by file name. Most of them only add search paths. Lines that begin with import followed by a space or tab, however, are executed according to the documentation. After that, site tries to import a module named sitecustomize. Both are documented extension points. They run before any application code and regardless of what the program imports later. The only way to switch this off is -S, which drops the import of site altogether, including its changes to the search path.

This is exactly where the attack hooked in. According to StepSecurity, cook.pth was twelve bytes long and contained only import cook. cook.py unpacked an embedded blob, the stealer “privcollect”, and ran it on a daemon thread. sitecustomize.py started a second component that detached from the calling process via fork and setsid and held the lock file /tmp/remote_code_loader.lock. Every hour, this service read a host name from a file domain.txt in a GitHub repository, fetched Python code from that host under the path /codepy and executed it without any check. According to StepSecurity, the file was empty at release time, so the second stage was not yet armed. The account and repository have since been deleted. Infected hosts keep polling the same address, though, and StepSecurity points out that anyone who re-registers the user name could push code to them.

According to StepSecurity, the stealer itself worked with 16 modules. It collected browser passwords, SSH, GPG and TLS keys, cloud and developer credentials, instance metadata, CI secrets, shell histories and configurations of AI coding tools. On Linux it also read the environment variables of all processes via /proc/<pid>/environ. There was a dedicated module for LiteLLM proxies: if DATABASE_URL was set, it queried the tables LiteLLM_CredentialsTable and LiteLLM_ProxyModelTable, where the proxy stores the API keys of upstream providers. It encrypted the result with AES-256-CBC, wrapped the key with RSA-4096 and sent everything over unencrypted HTTP to www.litellm.dev/healthcheck, a domain easily mistaken for the original. It wrote a marker file under /tmp only after a response with status 200. If the upload failed, it ran again on every subsequent Python start.

Two details show how hard it is to avoid the hook path. According to StepSecurity, some system Pythons ship their own sitecustomize.py earlier on the search path, which shadows the attacker's file. The path via cook.pth still runs. On Windows, fork and fcntl are missing, so the loader fails silently there, but the stealer runs anyway.

What the malicious commits change

There is no fix commit in a compromise; what is revealing here are the attacker's commits. According to Socket, 783c43b added the line data = "cook" under [tool.maturin] in pyproject.toml. A second commit, 20535d8, added COPY cook ./cook to the Dockerfiles. According to StepSecurity, the same files thereby ended up under /app/.venv/lib/python3.13/site-packages/ in the images. The cook/ directory was in the build tree but missing from the source distribution. According to Socket and StepSecurity, the sdist on PyPI therefore contained the tampered line but no malware. According to LiteLLM, the commit 9cf97e9 with the message “ci: release from a fork source repository” switched the build source in the release repository BerriAI/project-releaser.

The source itself is the most telling part. According to LiteLLM, the build came from Berri-AI/litellm, a fork with a hyphen in its name, not from BerriAI/litellm. Because GitHub serves a fork's commits through the repository it was forked from, the attacker's commit shows up under the official repository's URL. According to LiteLLM and StepSecurity, the official v1.107.0 tag does not contain the files. The pipeline then ran normally, signed and published, and according to Socket the attached provenance of the container image truthfully records the source Berri-AI/litellm. So the flaw lay neither in the code nor in the signature, but in the question of which source the pipeline is allowed to build at all. If you only check the repository, you see nothing. What runs on the server is the wheel.

From a leaked key to a signed release

According to LiteLLM's root cause analysis, the chain began months before the release. On 27 May 2026, a configuration file containing the master key of an internal alpha service for agents landed in the public repository LiteLLM-Labs/lite-harness. The commit was overwritten with a force push, but the key was never revoked. On 10 October, the attacker used it to query this service's API and received the configuration of every managed agent. It contained a GitHub CLI OAuth token belonging to an organisation owner, not tied to a service account and not scoped down. According to LiteLLM, such tokens are also not subject to the 2FA re-authentication GitHub requires for particularly sensitive actions. The first notice had still suspected the token came from the logs of an internal LiteLLM deployment. The later analysis explicitly corrects this.

With the owner token, the attacker started throwaway workflows that sent repository secrets to a webhook service. Among them were GH_TOKEN and RELEASE_PLEASE_TOKEN, which held maintainer tokens. He deleted the runs after a few minutes. On 21 October, a push to the release repository was first blocked by branch protection, whereupon the owner token overrode the protection and forced the change through with a force push. The second stolen token satisfied the production approval gate. According to LiteLLM, this rule had been relaxed the same week so that any two engineers could trigger and approve a release. Before that, it required an account bound to a physical device in the office. With the third token, the attacker deleted the run logs.

Affected is anyone who resolved an unpinned version during the window. That covers pip install litellm without a version, ranges such as litellm>=, lockfile updates during those hours and deployments that pull latest or main-stable. In Kubernetes this is treacherous, because according to the documentation a container with the latest tag and no imagePullPolicy set gets the policy Always and pulls again on every start. Wiz counts around 49,000 downloads, according to ClickPy. What I infer from this: even if you only use litellm as a transitive dependency of an agent framework, you were affected as soon as the resolver picked 1.107.0 in the window. And any Python process in the same environment is enough to trigger it, even a test run or a pip list.

Not affected, on the other hand, is anyone who installs against a lockfile with older versions and hashes, references images by digest or builds from the official tag themselves. According to LiteLLM, enterprise customers using the private registry were also spared. A LiteLLM update in the same week does not by itself mean you are affected. All that matters is whether 1.107.0 or one of the affected images was actually installed.

The damage reaches far beyond LiteLLM. Wiz observed the attackers using AWS instance roles from compromised CI runners and Kubernetes nodes, first with sts:GetCallerIdentity, then with bedrock:ListFoundationModels. That fits LLMjacking, the resale or abuse of other people's model access. According to Wiz, IAM reconnaissance followed in a small number of environments, for example with ListRoles, ListUsers and ListAccessKeys. On proxy hosts, add the provider keys from the database, which the stealer's dedicated module read out.

IncidentAffectedFixPreconditionCVSS
LiteLLM compromise of 21 Oct 2026 (no CVE)litellm==1.107.0, all seven wheels, on PyPI from 11:39:51 to 13:23 UTCPin to 1.106.2, rebuild the environmentInstallation during the window via an open version specifier or lockfile updatenot scored
Container images of the same versionTags v1.107.0, latest, main-latest, main-stable on Docker Hub (until 14:20 UTC) and GHCR (litellm, litellm-database, litellm-non_root, unavailable by 13:51 UTC at the latest)Image v1.106.2 by digestPull during the window via floating tagsnot scored

Without a CVE there is neither a KEV entry nor an EPSS score. No exploit is needed here; the malware is the attack. According to StepSecurity and Wiz, the second stage was never active, whereas Wiz has documented the use of harvested credentials. Socket flagged the wheels seven minutes after publication, Chainguard after 16 minutes. They were only removed from PyPI after almost two hours.

Clean versions and the way back

There is no patch in the classic sense. LiteLLM, StepSecurity, Wiz and Chainguard all name 1.106.2 as the last clean version. StepSecurity also lists the release candidates 1.107.0rc1 and rc2 as clean. Socket notes, however, that this does not mean every older artifact has been checked. Since 24 and 25 October, two newer versions, 1.106.3 and 1.106.4, have been on PyPI. None of the analyses has examined them, and the release notes for 1.106.4 mention no security fix. So you pin litellm==1.106.2 or the image v1.106.2 by digest and rebuild virtual environments and images instead of cleaning them in place. According to StepSecurity, that includes caches, internal mirrors and pull-through registries that cached the wheels or images.

The biggest pitfall is the cleanup itself. Every Python start runs the hooks again, and that applies to pip uninstall just as much as to a quick pip list. StepSecurity therefore recommends first killing, from the shell, the process holding /tmp/remote_code_loader.lock, and then deleting cook.pth, cook.py, sitecustomize.py and the matching .pyc files under __pycache__ from every affected site-packages. LiteLLM's own guide lists the same steps in reverse order. What matters in both is that everything happens from the shell and no Python process starts beforehand. Only then do you reinstall. If your system Python ships its own sitecustomize.py, do not conclude that everything was harmless, because the path via the .pth file ran anyway.

According to StepSecurity, a missing marker file proves nothing either. If the host had outbound internet access, LiteLLM says you should assume the upload succeeded. According to StepSecurity, you then need to rotate instance and task roles, cloud keys, SSH and GPG keys, registry tokens for GitHub, npm and PyPI, Kubernetes and Vault tokens, CI secrets and the tokens of AI tools. On proxy hosts, add the master key, salt key and virtual keys, as well as the provider keys from the database and the password from DATABASE_URL, both of which the stealer read.

One point deserves attention over the next few days. According to LiteLLM, the private enterprise registry already holds a release for a vulnerability whose disclosure is scheduled for 27 October. As of this article, I have not found a publication about it. If you pin to 1.106.2 today, you will soon have to move to a new version, and it comes from a pipeline that was compromised only recently. I would therefore not adopt it blindly, but check the wheel's provenance and contents before rolling it out, as described in the next section.

Hardening that makes the startup hook miss

Before looking at individual measures, some context helps. The malware does not break out of the container and makes no unusual syscall. It does what any Python process may do: read files and environment variables it has access to, and open an outbound connection. Four classes of measures are therefore effective. The first keeps the artifact out of the environment in the first place. The second limits what the process can see. The third blocks the way back out. The fourth makes startup hooks visible before they do damage.

Lockfile with hashes instead of open version ranges

The attack only reached environments that resolved a new version during the window. pip has offered hash-checking mode for this since version 8.0. As soon as a requirement carries a --hash or --require-hashes is set, pip demands an exact version with == and a matching hash for every package and every dependency. A new version can then no longer slip in silently, and a swapped artifact with the same version number fails the check. uv achieves the same through its lock file.

The limit is obvious. If you updated your lockfile in exactly those two hours, you recorded the hash of the tampered version and even locked it in. The lockfile protects against silent drift, not against a deliberate update. That is why it belongs together with the next measure. The operational cost is low as long as updates go through merge requests anyway.

Cooldown for new versions

The tampered wheels sat on PyPI for almost two hours and were flagged after seven to 16 minutes. A rule that only accepts new versions after a few days would therefore never have selected 1.107.0. uv offers exclude-newer for this, which limits candidates to files uploaded before a given point in time and, according to its documentation, also accepts relative values such as 1 week or P7D. With exclude-newer-package you set a separate limit for individual packages or exempt them. Since 26.1, pip has the option --uploaded-prior-to, which expects a duration in days such as P4D. Renovate applies the same principle at the merge request level with minimumReleaseAge, and according to its documentation security updates bypass the waiting period there.

Two details from the Renovate documentation are worth knowing. Renovate only reads the age from registry timestamps. For PyPI that is the earliest upload of a version; for containers Renovate only supports Docker Hub, not GHCR or other registries. With the default behaviour timestamp-required, which applies since Renovate 42, digest updates there are therefore held indefinitely. The documentation also recommends setting the cooldown in the package manager as well, because transitive dependencies otherwise come in without a waiting period when the lockfile is updated.

Images by digest instead of floating tags

On Docker Hub, the tags latest, main-latest and main-stable pointed to the infected image for around two and a half hours. If your deployment references image: …@sha256:…, you always get exactly the artifact that was tested. A new image then only reaches the cluster through a merge request with a new digest, and that is exactly where cooldown and pipeline tests apply. Renovate takes care of updating the digests. Combined with a cooldown, however, this only works for Docker Hub according to the documentation; for GHCR you need a separate rule, or the updates will stall.

Egress lock for Python workloads

The stealer sent its loot over HTTP to a lookalike domain, and the loader asked GitHub for a host name. Outbound, a LiteLLM proxy mainly needs the endpoints of its LLM providers, its database and, depending on configuration, a cache or logging services. A NetworkPolicy with policyTypes: [Egress] that allows exactly these destinations would have prevented the upload to litellm.dev. Because the stealer only writes its marker after a successful upload, it would have tried again on every start. If your CNI logs denied connections, these attempts would have shown up there.

The limits are the same as for any egress rule. The standard NetworkPolicy works with IP blocks and label selectors; for provider APIs with changing addresses you need FQDN rules from your CNI, for example Cilium. It only works if your CNI enforces NetworkPolicies. And it does not protect you if an attacker exfiltrates through an allowed destination. CI runners are harder to fence in because they need to reach package sources, registries and Git hosts. There it is worth at least routing direct internet access through a proxy with an allowlist.

What the process can reach

A stealer is worth what it finds. According to Wiz, the abused AWS roles came from CI runners and Kubernetes nodes. For pods that do not need the node role, the EKS best practices recommend enforcing IMDSv2 and setting the hop limit to 1, for example with aws ec2 modify-instance-metadata-options --instance-id <value> --http-tokens required --http-put-response-hop-limit 1 or, in Terraform, with http_put_response_hop_limit = 1 in the metadata_options block of the launch template. An ordinary pod can then no longer reach the node's metadata. The documentation warns that pods without IRSA or Pod Identity then lose the node role, and advises against disabling IMDS entirely.

Inside the pod, automountServiceAccountToken: false helps for workloads that do not need the Kubernetes API, because what is not mounted cannot be stolen. Moving secrets from environment variables into files, on the other hand, is no reliable protection, because the stealer specifically collects credential files in addition to environment variables. What cannot be avoided: the proxy needs its provider keys and its database. These keys are lost in a compromise. Spending limits and separate keys per environment at the provider keep the damage small and make it visible quickly.

Check provenance, but ask the right question

Signed images and provenance make sense; in this case they just answer the wrong question. The signature proves that the real pipeline did the build, and that was true. But the image's provenance named Berri-AI/litellm as the source. A check that compares the source repository in the attestation against the expected value, in addition to the signature, would have rejected the image. What I infer from this: an admission policy, for example with Kyverno, should check the source repository from the attestation in addition to the signature for images from third-party projects. For the wheels on PyPI that would not have helped. No source reports that they carried provenance of their own, and according to the reporter of GitHub issue #47826, LiteLLM has never published any.

The most consistent approach is not to use third-party wheels at all and to build from the verified tag yourself. According to Chainguard's own account, that is exactly how it was spared, because the build from source did not contain the files. That does mean build effort of your own, and you have to pick up every new version yourself.

Inventory startup hooks

A pipeline step that lists every .pth file with lines starting with import in the finished image, plus every sitecustomize.py outside the distribution, and compares them against an allowlist would have found these three files before rollout. That is my own inference, not a vendor recommendation. In the long run Python itself helps: since 3.15 such lines are deprecated, and according to the documentation Python silently ignores them in 3.18 and 3.19. As a replacement, 3.15 introduced .start files with an explicit entry point. Until then, the old lines keep running.

What does not help here

The obvious recommendation to avoid building source distributions with --only-binary :all: was useless in this case, because it was precisely the wheels that had been tampered with. The hashes in a wheel's RECORD file do not protect you either, because the pipeline generated them itself when building the tampered wheels. According to the documentation, python -I only removes the script directory and the user site-packages from the search path; the .pth files in the regular site-packages keep running.

Container hardening remains right but barely applies here. seccomp with RuntimeDefault, dropped capabilities, runAsNonRoot, allowPrivilegeEscalation: false and the Pod Security Standard “restricted” prevent neither reading files the process may read anyway nor an HTTP connection. readOnlyRootFilesystem could get in the way of the lock and marker files under /tmp. Whether the malware then fails is not documented, though, and if you mount a writable volume for /tmp, you lose that effect anyway. A CVE gate in the pipeline never fires, because there is no CVE. And a code review of the repository finds nothing, because the tag is clean.

More on egress rules, admission policies and image provenance can be found in the series TYPO3 on Kubernetes under IT-Grundschutz.

Detection and verification

Whether you are affected shows up first in your dependency files. Search lockfiles, requirements files and pyproject.toml for litellm at version 1.107.0, but also for open ranges, and check the transitive dependencies of your agent frameworks. For containers, compare the image IDs your pods are actually running against the published digests, for example sha256:1bbcb1443bc9… for the main image. A tag alone tells you nothing, because it now points to a clean image again. On hosts and in containers, search every site-packages for cook.pth, cook.py and any sitecustomize.py that does not belong to the distribution.

According to StepSecurity and Wiz, several traces reveal whether the malware ran. They include the files /tmp/.pc_once_72129118c59f5636c0bb and /tmp/remote_code_loader.lock, temporary directories with the prefixes .pc_sq_ and .pc_e_, and the environment variable PRIVCOLLECT_ACTIVE=1. In DNS, proxy and egress logs, look for litellm.dev and www.litellm.dev, for requests to raw.githubusercontent.com/letters9813/ and for any path ending in /codepy. According to StepSecurity, a hit on /codepy means attacker code ran on that host. StepSecurity and Phoenix Security list the hashes of the three files.

In AWS CloudTrail, look at GetCallerIdentity calls from instance or task roles around the install time and at any use of those roles from outside your infrastructure. Wiz also names bedrock:ListFoundationModels and IAM calls such as ListRoles as observed follow-up activity.

If you suspect a compromise, isolate the host, preserve logs and follow the order described above: kill the process holding the lock file, delete the files, rebuild the environment, rotate credentials from a clean environment. Also block litellm.dev at DNS and egress level, so that hosts you have not found yet can no longer get data out.

Recommendation for operators

Act now if litellm is installed without a fixed pin in your environments, if a deployment uses a floating tag of the LiteLLM image, or if a lockfile was updated on 21 October. Check the versions and image digests that are actually installed, clean up in the right order and rotate everything the processes could reach, starting with the provider keys on proxy hosts.

Monitoring is enough if you keep litellm on an older version through a lockfile with hashes and reference images by digest. Still, look at how the next version would reach your environment. Since the incident, 1.106.3 and 1.106.4 have appeared, which none of the analyses has checked, and the announced release for the vulnerability with disclosure on 27 October will come from the same pipeline.

Regardless of the individual case, what I say about every vulnerability applies: patches and updates should be automated. When Renovate or Dependabot propose new versions of Python packages, images and Helm charts as merge requests on their own and the pipeline tests them against a staging environment, a security update is a matter of hours rather than weeks. This case shows the other side of the same process. A cooldown of a few days for routine updates would never have let 1.107.0 into a merge request, while security updates stay exempt and arrive immediately. If an update breaks something, the test catches it, not production. If you update by hand instead, you may well reach for pip install -U exactly when the wrong version is on top, and during holidays you go weeks without a patch, followed by a large batch update where nobody can say any more what changed.

Frequently asked questions about the LiteLLM compromise

I don't import litellm directly. Am I affected?+

Possibly. The hooks run on every Python start in the environment, no matter what you import. What matters is whether 1.107.0 was installed, including as a transitive dependency.

Is pip uninstall litellm enough?+

No. pip is itself a Python process and starts the stealer again. First kill the process holding the lock file, delete the three files and then rebuild the environment.

The release was signed. Why didn't that protect anyone?+

The pipeline signed what it built, and it built from a fork. The signature proves the build path, not the source. So check the source repository in the provenance as well.

Does a cooldown help if I need security updates right away?+

Yes, because you exempt security updates from it. For routine updates, a single day would have been enough here: PyPI removed the wheels after almost two hours, and scanners flagged them after seven to 16 minutes.

Conclusion

For LiteLLM this was already the second incident of its kind this year. After the first one in March, the project says it moved releases to a private repository, made Docker tags immutable and started signing images. Even so, an unrevoked key, an unscoped owner token and a relaxed approval rule were enough. The controls were only as strong as the credentials that could switch them off. For you as an operator, the transferable lesson lies elsewhere. With .pth and sitecustomize, Python gives every installed package a door that opens before any import, and what comes through that door is decided by the wheel alone, not by the repository. Trust therefore belongs at the point where an artifact enters your environment: pinned, with a waiting period, with verified provenance and behind a network boundary that keeps a mistake from becoming expensive right away.

Sources

I review your Python and container pipelines for open version ranges, floating tags and overly broad credentials, and set up cooldowns, digest pinning and egress rules.

Supply-chain audits for PyPI, Composer and npm, lockfiles with hashes, Renovate with cooldowns, egress policies for Kubernetes and IMDS hardening on AWS.

Platform operations instead of advice on paper: I check, patch and harden your infrastructure continuously.

About the author