LMCache's advisory on 16 October was the 22nd since January 2025 with the same root cause. A Python service in an AI or distributed-computing stack listens on a network port and calls pickle.loads, or a close relative, on whatever arrives. Anyone who can reach the port can run code on the GPU host. Eighteen of the 22 advisories are rated critical, and for ten of them the advisory lists no fixed version.
This isn't a run of bad luck. Inference engines have grown internal network planes, for KV cache transfer, prefill and decode disaggregation, multi-node coordination and RPC between a frontend and its engine processes. Those planes were written as if the network were trusted. This post is for platform and security teams that run self-hosted inference. It lays out the 22 advisories, the three design choices behind them, why "it's an internal port" doesn't hold up, and a script that shows which serving processes on a host are listening on all interfaces.
The short answer
Between January 2025 and 16 October 2026, GitHub's advisory database added 22 PyPI advisories where a network listener deserializes pickle, cloudpickle or dill data. Nineteen are in AI and ML projects, including vLLM (7), SGLang (4), LMDeploy (2), LMCache, BentoML, Horovod, Ludwig and Pipecat. The recurring pattern is pickle as the wire format, a listener that binds to all interfaces or is easy to expose, and no authentication, because the plane was meant to be internal. Patch where fixes exist, but also treat every port an inference process opens as an external API: bind internal planes to localhost or a private interface, put a default-deny network policy around inference workloads, and turn on authentication wherever the engine offers it.
The 22 advisories
We went through OSV's PyPI data for 2025 and 2026 and kept the advisories where untrusted bytes reach pickle, cloudpickle or dill through a socket, RPC, HTTP or ZeroMQ endpoint. We left out pickle-scanner bypasses, malicious model files and local cache files, which are a different problem. The script below rebuilds the table from OSV:
#!/usr/bin/env python3
"""Summarize 2025-2026 PyPI advisories where a network listener unpickles its input.
The list was picked by hand from OSV's PyPI data: pickle, cloudpickle or dill
deserialization reachable from a socket, RPC, HTTP or ZeroMQ endpoint. Pickle-
scanner bypasses, model files and local caches are excluded. Queries OSV.dev.
"""
import json, urllib.request
IDS = """GHSA-3x5x-fw77-g54c GHSA-x3m8-f7g5-qhm7 GHSA-5vqr-wprc-cpp7 GHSA-cj47-qj6g-x7r4
GHSA-pgr7-mhp5-fgjp GHSA-7v4r-c989-xh26 GHSA-hj4w-hm2g-p6w5 GHSA-9pcc-gvx5-r5wm GHSA-hjq4-87xh-g4fv
GHSA-g8c6-8fjj-2r4m GHSA-f74j-gffq-vm9p GHSA-xv5p-fjw5-vrj6 GHSA-rgq9-fqf5-fv58 GHSA-jx93-g359-86wm
GHSA-gwv6-pq6m-p3rq GHSA-36m8-w8qf-g76p GHSA-c2jg-5cp7-6wc7 GHSA-mf8f-x4r3-jm8c GHSA-xp5q-5q7g-q26r
GHSA-5h8j-6crg-7rmw GHSA-2vh9-42vm-xmv2 GHSA-w5f2-9q9c-ff4h""".split()
rows = []
for i in IDS:
d = json.load(urllib.request.urlopen(f"https://api.osv.dev/v1/vulns/{i}", timeout=30))
aff = d["affected"][0]
events = [e for r in aff.get("ranges", []) for e in r.get("events", [])]
fixed = sorted({e["fixed"] for e in events if "fixed" in e})
cve = next((a for a in d.get("aliases", []) if a.startswith("CVE-")), "no CVE")
sev = (d.get("database_specific") or {}).get("severity", "?")
rows.append((d["published"][:10], aff["package"]["name"], cve, sev, ", ".join(fixed) or "none listed"))
print(f"{'published':10} {'package':16} {'CVE':16} {'severity':9} fixed in")
for r in sorted(rows):
print(f"{r[0]:10} {r[1]:16} {r[2]:16} {r[3]:9} {r[4]}")
print(f"\n{len(rows)} advisories, {sum(r[3] == 'CRITICAL' for r in rows)} critical, "
f"{sum(r[4] == 'none listed' for r in rows)} with no fixed version listed, "
f"{len({r[1] for r in rows})} packages")
$ python3 wire_pickle_advisories.py
published package CVE severity fixed in
2025-03-05 dgl no CVE HIGH none listed
2025-03-19 vllm CVE-2025-29783 CRITICAL 0.8.0
2025-03-20 vllm CVE-2024-11041 CRITICAL none listed
2025-03-20 vllm CVE-2024-9052 CRITICAL none listed
2025-03-20 vllm CVE-2024-9053 CRITICAL none listed
2025-04-09 bentoml CVE-2025-32375 CRITICAL 1.4.8
2025-04-29 vllm CVE-2025-32444 CRITICAL 0.8.5
2025-05-06 vllm CVE-2025-30165 HIGH 0.10.0
2025-05-20 vllm CVE-2025-47277 CRITICAL 0.8.5
2025-10-07 python-socketio CVE-2025-61765 MODERATE 5.14.0
2025-10-17 pyquokka CVE-2025-62515 CRITICAL none listed
2025-11-25 fugue CVE-2025-62703 HIGH none listed
2026-03-12 sglang CVE-2026-3059 CRITICAL 0.5.10
2026-03-12 sglang CVE-2026-3060 CRITICAL 0.5.10
2026-04-23 pipecat-ai CVE-2025-62373 CRITICAL 0.0.94
2026-05-12 horovod CVE-2026-31234 CRITICAL none listed
2026-05-12 ludwig CVE-2026-31238 CRITICAL none listed
2026-05-18 sglang CVE-2026-7301 CRITICAL none listed
2026-05-18 sglang CVE-2026-7304 CRITICAL none listed
2026-09-16 lmdeploy CVE-2025-59953 CRITICAL 0.10.2
2026-09-18 lmdeploy CVE-2025-66455 CRITICAL 0.16.0
2026-10-16 lmcache CVE-2026-105192 CRITICAL 0.5.6
22 advisories, 18 critical, 10 with no fixed version listed, 12 packages
The dates are when GitHub's database published each advisory, which can lag the CVE by months. Three vLLM entries carry 2024 CVE numbers but reached the database in March 2025. LMDeploy's two carry 2025 numbers and arrived in September 2026.
The planes involved are the internal plumbing of a serving stack. vLLM's entries include the Mooncake KV transfer pipe, the PyNccl communication pipe, its multi-node configuration and an older engine RPC server. SGLang's cover a ZeroMQ broker in its multimodal generation module, its encoder disaggregation module, a multimodal scheduler socket and a custom logit processor option that deserializes dill payloads from requests. LMDeploy's are a ZeroMQ RPC and the control plane of its prefill and decode disaggregation. LMCache's is its multiprocess transport. Horovod's is a key-value store used to coordinate distributed training.
Three design choices behind them
Pickle as the wire format. Engine processes need to pass Python objects, tensors and metadata to each other quickly, and pickle does that with one line. ZeroMQ's Python binding has recv_pyobj built in, which is pickle.loads on the received bytes. LMDeploy's advisory for CVE-2025-59953 suggested replacing pickle with safetensors or msgpack. That's the durable fix, and it's also the expensive one, because the wire format touches every message.
Listeners that bind to all interfaces. SGLang's advisory for CVE-2026-7301 says the scheduler's socket binds to 0.0.0.0 by default. vLLM's PyNccl fix forced the underlying store to bind to a specified private interface. LMDeploy's 0.10.2 fix changed its RPC server to bind to localhost. Pyquokka's and Fugue's advisories both describe a server started on 0.0.0.0. Two of the fixes that shipped were changes to the bind address, not to the deserialization.
No authentication on internal planes. The vLLM Mooncake advisory notes there were no network or authentication controls in front of the payload. Horovod's says the key-value store lacks authentication and authorization. LMDeploy's advisory for CVE-2025-66455 says its API key isn't enabled unless the operator configures it. None of that is surprising in code meant for traffic between processes in one job. It becomes a problem the moment the port is reachable from anywhere else.
Why "it's an internal port" doesn't hold
Internal planes end up reachable more often than their authors expect:
- Flat pod networks. By default, a Kubernetes pod can reach every other pod in the cluster. A port that only the engine's sibling process should use is open to every workload in the cluster, including a compromised web app.
- Host networking. GPU workloads often run with
hostNetworkfor performance. Then a listener on all interfaces is on the node's own network. - Shared GPU clusters. Research and platform clusters are often multi-tenant, and the person on the next node is inside the "trusted" network.
- Server-side request forgery. Not every one of these listeners is plain HTTP. But where one is, an SSRF bug elsewhere in the cluster can reach it.
And the fix may not be coming. For ten of the 22, the advisory lists no fixed version. Some of those projects have shipped releases since, so a missing fixed version doesn't always mean the bug is still there. But Horovod's last PyPI release is 0.28.1 from June 2023, which is the last affected version in its advisory. For those cases the network is the only control you have.
Check what your serving hosts expose
This script lists TCP listeners that belong to model-serving processes and flags the ones bound to all interfaces. It uses ss on Linux and lsof elsewhere, needs no extra packages, and you can run it on a host or inside a container:
#!/usr/bin/env python3
"""List TCP listeners of model-serving processes and flag those on all interfaces.
Uses `ss -ltnpH` on Linux or `lsof -nP -iTCP -sTCP:LISTEN` elsewhere. Run it on
each inference host or inside the container. Standard library only.
Usage: listen_audit.py [--match REGEX]
"""
import re, shutil, subprocess, sys
MATCH = re.compile(sys.argv[sys.argv.index("--match") + 1] if "--match" in sys.argv else
r"python|ray|torchrun|vllm|sglang|lmdeploy|lmcache|triton|bentoml|horovod", re.I)
WILD = {"*", "0.0.0.0", "::", "[::]"}
def listeners():
if shutil.which("ss"):
for line in subprocess.run(["ss", "-ltnpH"], capture_output=True, text=True).stdout.splitlines():
parts = line.split()
host, _, port = parts[3].rpartition(":")
m = re.search(r'users:\(\("([^"]+)",pid=(\d+)', line)
yield (m.group(1), m.group(2)) if m else ("?", "?"), host, port
else:
out = subprocess.run(["lsof", "-nP", "-iTCP", "-sTCP:LISTEN"], capture_output=True, text=True).stdout
for line in out.splitlines()[1:]:
parts = line.split()
host, _, port = parts[8].rpartition(":")
yield (parts[0], parts[1]), host, port
seen, wide = set(), 0
for (cmd, pid), host, port in listeners():
if not MATCH.search(cmd) or (pid, host, port) in seen:
continue
seen.add((pid, host, port))
exposed = host in WILD
wide += exposed
print(f"{'EXPOSED' if exposed else 'local '} {cmd}[{pid}] {host}:{port}")
print(f"{len(seen)} matching listener(s), {wide} on all interfaces")
sys.exit(1 if wide else 0)
Here it is on a test host with two Python servers, one bound to all interfaces and one to localhost:
$ python3 listen_audit.py --match '^Python$|^python'
EXPOSED Python[89898] *:18091
local Python[89900] 127.0.0.1:18092
2 matching listener(s), 1 on all interfaces
On a real inference host, run it with the default pattern while the engine is serving traffic, because some planes only open their ports once a worker joins. Each EXPOSED line is a port to account for: either it's meant to take outside traffic and has authentication in front of it, or it should move to localhost or a private interface. A localhost bind isn't a fix for pickle, but it shrinks who can reach it from the whole network to code already on the box.
What to do
- Patch the ones with fixes. vLLM, SGLang (for CVE-2026-3059 and CVE-2026-3060), LMDeploy, LMCache, BentoML, Pipecat and python-socketio all list fixed versions in the table above.
- Default-deny around inference workloads. Give inference namespaces a network policy that allows only the API port from the gateway, and the internal ports only between the engine's own pods. Our Kubernetes network policies guide walks through the setup.
- Bind internal planes privately. Most engines expose host and port settings for KV transfer, RPC and coordination. Set them to localhost or a private interface explicitly instead of relying on defaults.
- Turn on authentication where it exists, such as LMDeploy's API key, and don't enable options that deserialize code from requests, like SGLang's custom logit processor, on a shared or exposed server.
- Watch the advisories with no fixed version. For Horovod, Ludwig, pyquokka, Fugue, DGL and the open SGLang entries, decide whether the component is reachable and fence it off if it is.
- Ask for a non-pickle wire format. It's the only fix that removes the class, and maintainers prioritize what users ask for.
For the deserialization side, our Python pickle security guide explains why no allowlist or scanner makes untrusted pickle safe.
The fair view
Pickle on an internal socket is a reasonable engineering choice inside a trust boundary that really exists. The engine's processes are one program split across processes, and a stricter wire format costs development time and some speed. The maintainers here mostly responded quickly, and several advisories came with clear guidance on how to deploy safely. These projects also move at research speed, and the internal planes are new features that didn't exist two years ago.
But the boundary is usually assumed, not enforced. Clusters are flat, GPU nodes run with host networking, and an internal port is one misconfigured service away from the internet. Twenty-two advisories in under two years, 18 of them critical, say the boundary fails often enough to plan for. Until the wire formats change, the network has to be the boundary, and that's a configuration you can check today.