Safeguard
Vulnerability Research•

Pickle on the Wire: 22 Advisories Show AI Inference Engines Trusting Their Own Network

22 advisories since 2025, 18 critical: AI inference engines unpickle data from network ports. The pattern, why internal ports leak, and a host check.

Safeguard Security Team
Security Research
10 min read

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 hostNetwork for 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.