CVE-2026-105192 lets anyone who can reach an LMCache multiprocess server run code on it with one ZeroMQ message. LMCache shares the KV cache between vLLM instances, and in multiprocess mode it listens on an unauthenticated socket that unpickled part of every registration request. The fix shipped in LMCache 0.5.6 on 15 October 2026, and GitHub's advisory database added it on 16 October.
Upgrading the package isn't the whole fix, though. On 17 October, the project's example Kubernetes manifests at the v0.5.6 tag still bound the server to every interface on the node, and they pull image tags last built on 27 September, before the fix. The v0.5.6 release images still run as root. This guide is for teams running vLLM with LMCache, especially on Kubernetes. It covers who is exposed, what changed in 0.5.6 and what didn't, how to deploy multiprocess mode safely, and a script that checks dependencies, manifests and your own code for the same mistake.
The short answer
Upgrade every LMCache server and worker to 0.5.6 together, since the wire format changed. Then check how you deploy it. Multiprocess mode is exposed when the server binds a routable address with --host, which the project's example DaemonSet does with --host 0.0.0.0 on the host network. Bind it to loopback or a private interface, block port 5555 (or your configured port) at the node firewall, pin images to v0.5.6 or a digest rather than nightly, and run as a non-root user yourself, because the v0.5.6 images don't. If the port was ever reachable from an untrusted network, treat the host as compromised.
What the bug is
LMCache's multiprocess mode, also called distributed mode, runs a cache server that vLLM workers connect to over ZeroMQ. The advisory, GHSA-w5f2-9q9c-ff4h, says the socket has "no CURVE, ZAP, password, or message authentication". Messages are MessagePack. In versions 0.3.9 to 0.5.5, extension type 1 was registered for DeviceIPCWrapper, the object that describes a GPU memory handle, and its decoder was one line:
@staticmethod
def Deserialize(data: bytes) -> "DeviceIPCWrapper":
return pickle.loads(data)
That decoder runs from msgspec's ext_hook while the server is still decoding the arguments of a REGISTER_KV_CACHE request, before any handler checks them. So the code in the pickle runs first, and the type error comes after. The advisory rates it critical, with a CVSS 3.1 score of 9.8, for a server bound to a routable address. LMCache used inside a single vLLM process doesn't open the port, and the default bind is localhost.
Yuval Moravchick of JFrog Security Research reported it, and JFrog published its write-up with a proof of concept on 7 October. That's the day LMCache's own blog says it received the report, so the fix was written while the details were public. The maintainers merged it to the dev branch on 12 October and released 0.5.6 three days later.
Why "typed decoding" didn't help
msgspec decodes into declared types, which looks like it should stop this. It doesn't, because a hook for a type msgspec can't decode itself has to run before msgspec can check the result. This demo shows the order with msgspec 0.22.0. The hook only records that it was called, and the payload is a placeholder:
"""When does a msgspec ext_hook run, relative to type validation?
No pickle and no network: the hook only records that it was called.
"""
import msgspec
calls = []
class DeviceIPCWrapper:
"""Stands in for LMCache's wrapper type, which msgspec can't decode natively."""
class Register(msgspec.Struct, array_like=True):
worker_id: int
kv_caches: list[DeviceIPCWrapper]
def ext_hook(code, data):
calls.append((code, bytes(data))) # LMCache 0.3.9 to 0.5.5 called pickle.loads(data) here
return "whatever the payload returned"
decoder = msgspec.msgpack.Decoder(Register, ext_hook=ext_hook)
msg = msgspec.msgpack.encode([0, [msgspec.msgpack.Ext(1, b"PLACEHOLDER")]])
try:
decoder.decode(msg)
except msgspec.ValidationError as e:
print("decode rejected:", e)
print("ext_hook ran first:", calls)
# The fixed pattern: data-only tagged structs, no extension types at all.
class CudaIPCData(msgspec.Struct, tag="cuda", forbid_unknown_fields=True):
version: int
handle: bytes
class CpuShmData(msgspec.Struct, tag="cpu_shm", forbid_unknown_fields=True):
version: int
name: str
safe = msgspec.msgpack.Decoder(CudaIPCData | CpuShmData)
print("safe decoder:", safe.decode(msgspec.msgpack.encode(CpuShmData(version=1, name="lmc-0"))))
for bad in (msgspec.msgpack.Ext(1, b"PLACEHOLDER"), {"type": "os.system", "version": 1}):
try:
safe.decode(msgspec.msgpack.encode(bad))
except msgspec.ValidationError as e:
print("safe decoder rejected:", e)
$ python3 ext_hook_order.py
decode rejected: Expected `DeviceIPCWrapper`, got `str` - at `$[1][0]`
ext_hook ran first: [(1, b'PLACEHOLDER')]
safe decoder: CpuShmData(version=1, name='lmc-0')
safe decoder rejected: Expected `object`, got `ext`
safe decoder rejected: Invalid value 'os.system' - at `$.type`
The decoder rejected the message, but only after the hook had already run with attacker-supplied bytes. In LMCache 0.5.5 that hook was pickle.loads. The second half of the demo is the shape of the 0.5.6 fix: plain data structures with fixed tags and no extension types. Unknown tags and extension objects are rejected before any code is chosen. The real fix also checks tensor data types, shapes and strides, and picks the device wrapper class from a fixed local list.
Who is exposed
| Deployment | Exposed? |
|---|---|
| LMCache inside the vLLM process (in-process mode) | No, no port is opened |
Multiprocess mode with the default --host localhost | Only to processes on the same host or in the same network namespace |
Multiprocess mode with --host 0.0.0.0 or a node IP | Yes, to anything that can reach the port |
| The project's example Kubernetes DaemonSet, up to and including the v0.5.6 tag | Yes. It sets hostNetwork: true and --host 0.0.0.0 on port 6555 |
The Kubernetes case deserves attention because a NetworkPolicy may not protect you. Kubernetes documents that NetworkPolicy behavior for hostNetwork pods is undefined, and that the most common implementation ignores them and treats their traffic as node traffic. A DaemonSet on the host network that binds every interface is reachable on every node IP, and only a host or cloud firewall can block it.
What 0.5.6 changed, and what it didn't
The advisory says 0.5.6 also binds the example Kubernetes deployment to localhost and runs official images as a non-root user. Both changes were merged to the dev branch, but after the commit that was tagged v0.5.6. We checked on 17 October:
| Item | v0.5.6 tag and images | dev branch |
|---|---|---|
| Pickle removed from device IPC descriptors | Yes | Yes |
| HTTP frontend default bind | 127.0.0.1 (0.5.5 used 0.0.0.0:8080) | 127.0.0.1 |
| Example DaemonSet bind | --host 0.0.0.0, hostNetwork: true | --host 127.0.0.1, hostNetwork: true |
Dockerfiles set a non-root USER | No | Yes |
lmcache/standalone:v0.5.6 and lmcache/vllm-openai:v0.5.6 image user | Empty, so root | Nightly builds from 16 October run as lmcache |
| Image tags the examples reference | standalone:nightly and vllm-openai:latest-nightly | The same two tags |
The last row matters most. Both nightly and latest-nightly were last pushed to Docker Hub on 27 September, two weeks before the fix was merged. Newer nightly builds go to tags with a run number in the name. So anyone who applies the example manifests as written gets a server built before the fix, running as root.
The updated dev example has a second problem. The vLLM Deployment there doesn't use the host network but now connects to tcp://127.0.0.1:6555. Inside that pod, 127.0.0.1 is the pod's own loopback, not the node's, so as written it shouldn't reach the DaemonSet. We didn't run it on a cluster. But a team that copies it and finds it doesn't connect may go back to 0.0.0.0.
The maintainers also list two open items. The RPC between the LMCache server, the multiprocess coordinator and the frontend still has no authentication, which they plan to add in 0.5.7. And a pickle call remains on the engine-driven path, which is disabled by default and not reachable from the network, and is due to be removed before 0.5.8.
Deploy multiprocess mode safely
- Upgrade all of it at once. Servers and workers must all run 0.5.6, because older and newer versions can't talk to each other. Pin
lmcache==0.5.6in the lockfile, not just the requirements file. - Pin images. Use
lmcache/standalone:v0.5.6andlmcache/vllm-openai:v0.5.6, or better, their digests. Don't usenightlyorlatest-nightly. - Keep the server and vLLM in one network namespace. The simplest safe layout runs the LMCache server as a second container in the vLLM pod and binds it to
127.0.0.1, since containers in a pod share a loopback. If you need one server per node, run both on the host network with a loopback bind, and remember that every other host-network pod on that node can then reach the port. - If the port must cross hosts, bind a private interface, not
0.0.0.0, and allow only your worker addresses at the host or cloud firewall. Don't rely on a NetworkPolicy for host-network pods. - Run as non-root anyway. Set
runAsNonRoot: trueand a numericrunAsUser, or build from the dev Dockerfile, which adds anlmcacheuser. It doesn't stop the bug, but it limits what an attacker gets. - Keep the HTTP frontend local. If you run 0.5.5 or older, its frontend listened on
0.0.0.0:8080by default, and the maintainers say that RPC path has no authentication yet. Set--http-host 127.0.0.1until 0.5.7. - Check for compromise if you were exposed. LMCache's own guidance applies: the code runs during decoding, so a rejected request in the logs doesn't mean the attack failed. Look for unexpected child processes, shells or outbound connections from the LMCache process. Rotate credentials the container could reach, such as model registry and object storage tokens, and redeploy from clean images.
Check your repos and manifests
This script walks a directory. It flags lmcache versions from 0.3.9 to before 0.5.6, including 0.5.6 release candidates, in requirements files, pyproject.toml and lockfiles. In Kubernetes, Compose and shell files it flags multiprocess servers bound off loopback, host networking, vLLM connector settings that point at a non-loopback address, moving image tags, and LMCache containers with no non-root setting. It also looks for the same bug class in your own Python: a MessagePack or JSON decode hook that leads to pickle.loads, and pyzmq's recv_pyobj, which unpickles whatever arrives. It reads files only and uses the Python standard library.
#!/usr/bin/env python3
"""Check a tree for LMCache CVE-2026-105192 exposure and the same bug class.
Reads files only. Standard library only. Usage: lmcache_105192_check.py DIR
"""
import ast
import re
import sys
from pathlib import Path
FIXED = (0, 5, 6)
FIRST_AFFECTED = (0, 3, 9)
LOOPBACK = {"127.0.0.1", "localhost", "::1"}
SKIP = {".git", "node_modules", ".venv", "venv", "__pycache__"}
findings = []
UNPICKLERS = {} # "Class.func" -> file, for every function that unpickles
def report(path, level, msg):
findings.append((str(path), level, msg))
def parse_ver(s):
m = re.match(r"(\d+)\.(\d+)\.(\d+)(.*)", s)
if not m:
return None, ""
return tuple(int(x) for x in m.groups()[:3]), m.group(4)
def affected(ver):
v, rest = parse_ver(ver)
if v is None:
return None
if v < FIRST_AFFECTED:
return False
if v < FIXED:
return True
# 0.5.6rc1 to rc5 and dev builds of 0.5.6 predate the release
return v == FIXED and bool(re.match(r"\.?(rc|dev|a|b)", rest))
def check_python_deps(path, text):
pats = [
r"(?im)^\s*lmcache\s*==\s*([\w.]+)", # requirements, pip freeze
r'(?is)name\s*=\s*"lmcache"\s*\n\s*version\s*=\s*"([\w.]+)"', # poetry/uv lock
r'(?i)"lmcache\s*==\s*([\w.]+)"', # pyproject dependency arrays
]
for pat in pats:
for m in re.finditer(pat, text):
hit = affected(m.group(1))
if hit:
report(path, "HIGH", f"lmcache {m.group(1)} is affected, upgrade to 0.5.6")
elif hit is False:
report(path, "ok", f"lmcache {m.group(1)}")
def check_manifest(path, text):
if "lmcache" not in text.lower():
return
runs_server = re.search(r"lmcache\.v1\.multiprocess\.(server|http_server)", text)
for m in re.finditer(r"""--host["']?(?:=|\s+)(?:-\s+)?["']?([\w.:$/{}][\w.:\-$/{}]*)""", text):
host = m.group(1)
if runs_server and host not in LOOPBACK:
report(path, "HIGH", f"multiprocess server binds --host {host} (transport port reachable off-host)")
for m in re.finditer(r'lmcache\.mp\.host\\?"?\s*:\s*\\?"tcp://([^\\"]+)', text):
if m.group(1) not in LOOPBACK:
report(path, "MEDIUM", f"vLLM connects to LMCache over tcp://{m.group(1)}, so the server listens off loopback")
if runs_server and re.search(r"(?m)^\s*hostNetwork:\s*true", text):
if any(f[0] == str(path) and "binds --host" in f[2] for f in findings):
report(path, "MEDIUM", "hostNetwork: true, so that bind address is on the node's own interfaces")
else:
report(path, "LOW", "hostNetwork: true, so loopback is shared with every hostNetwork pod on the node")
for m in re.finditer(r"image:\s*[\"']?(lmcache/[\w.\-]+):?([\w.\-]*)", text):
repo, tag = m.group(1), m.group(2) or "latest"
hit = affected(tag.lstrip("v").split("-")[0])
if "nightly" in tag or tag.startswith("latest"):
report(path, "MEDIUM", f"{repo}:{tag} is a moving tag; pin v0.5.6 or a digest")
elif hit:
report(path, "HIGH", f"{repo}:{tag} is affected")
if not re.search(r"runAsNonRoot:\s*true|runAsUser:\s*[1-9]", text):
report(path, "LOW", f"{repo}:{tag} with no runAsNonRoot/runAsUser; LMCache release images through v0.5.6 run as root")
class BugClass(ast.NodeVisitor):
"""Network or IPC decode paths that end in pickle."""
UNPICKLE = {"loads", "load", "Unpickler"}
PICKLE_MODS = {"pickle", "cPickle", "cloudpickle", "dill", "_pickle"}
def __init__(self, path):
self.path = path
self.unpickling_funcs = set()
self.hooks = []
def is_unpickle(self, call):
f = call.func
return (isinstance(f, ast.Attribute) and f.attr in self.UNPICKLE
and isinstance(f.value, ast.Name) and f.value.id in self.PICKLE_MODS)
def visit_ClassDef(self, node):
self.cls = node.name
self.generic_visit(node)
self.cls = None
def visit_FunctionDef(self, node):
if any(isinstance(n, ast.Call) and self.is_unpickle(n) for n in ast.walk(node)):
self.unpickling_funcs.add(node.name)
UNPICKLERS[f"{getattr(self, 'cls', None) or ''}.{node.name}".lstrip(".")] = self.path
self.generic_visit(node)
def visit_Call(self, node):
f = node.func
name = f.attr if isinstance(f, ast.Attribute) else getattr(f, "id", "")
if name in ("recv_pyobj", "recv_serialized") or (name == "loads" and "zmq" in ast.unparse(f)):
report(self.path, "HIGH", f"line {node.lineno}: {name}() unpickles whatever the socket received")
for kw in node.keywords:
if kw.arg in ("ext_hook", "object_hook", "object_pairs_hook"):
self.hooks.append((node.lineno, kw.arg, ast.unparse(kw.value)))
self.generic_visit(node)
def finish(self, text):
for lineno, kind, target in self.hooks:
leaf = target.split(".")[-1]
if leaf in self.unpickling_funcs:
report(self.path, "HIGH", f"line {lineno}: {kind}={target} reaches pickle during decode")
continue
# The hook may call an unpickler defined elsewhere in the tree.
refs = [f"{q} ({where})" for q, where in UNPICKLERS.items()
if where != self.path and re.search(r"\b" + re.escape(q) + r"\b", text)]
if refs or self.unpickling_funcs:
names = refs or sorted(self.unpickling_funcs)
report(self.path, "REVIEW", f"line {lineno}: {kind}={target}, and this file references "
f"an unpickler: {', '.join(names)}; trace what the hook calls")
def main(root):
root = Path(root)
visitors = []
for p in sorted(root.rglob("*")):
if not p.is_file() or SKIP & set(p.parts):
continue
try:
text = p.read_text(errors="ignore")
except OSError:
continue
rel = p.relative_to(root)
if p.name.startswith("requirements") or p.name in ("pyproject.toml", "poetry.lock", "uv.lock", "Pipfile.lock"):
check_python_deps(rel, text)
elif p.suffix in (".yaml", ".yml", ".json", ".sh") or p.name.startswith("Dockerfile"):
check_manifest(rel, text)
elif p.suffix == ".py":
try:
tree = ast.parse(text)
except SyntaxError:
continue
v = BugClass(rel)
v.visit(tree)
visitors.append((v, text))
for v, text in visitors: # second pass, once every unpickler is known
v.finish(text)
order = {"HIGH": 0, "MEDIUM": 1, "REVIEW": 2, "LOW": 3, "ok": 4}
for path, level, msg in sorted(set(findings), key=lambda f: (order[f[1]], f[0])):
print(f"{level:7} {path}: {msg}")
if not findings:
print("nothing found")
return 1 if any(f[1] == "HIGH" for f in findings) else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "."))
We ran it on a test tree holding the project's real v0.5.5 example manifests, a patched copy of the dev DaemonSet, an inference service on 0.5.5 and a 0.5.6 release candidate, LMCache 0.5.5's own two source files, and a small internal service written with the same mistake:
$ python3 lmcache_105192_check.py fx
HIGH inference-svc/requirements.txt: lmcache 0.5.5 is affected, upgrade to 0.5.6
HIGH inference-svc/uv.lock: lmcache 0.5.6rc3 is affected, upgrade to 0.5.6
HIGH k8s/lmcache-daemonset.yaml: multiprocess server binds --host 0.0.0.0 (transport port reachable off-host)
HIGH kv-bridge/transport.py: line 19: recv_pyobj() unpickles whatever the socket received
HIGH kv-bridge/transport.py: line 18: ext_hook=decode_ext reaches pickle during decode
MEDIUM k8s/lmcache-daemonset.yaml: hostNetwork: true, so that bind address is on the node's own interfaces
MEDIUM k8s/lmcache-daemonset.yaml: lmcache/standalone:nightly is a moving tag; pin v0.5.6 or a digest
MEDIUM k8s/vllm-deployment.yaml: vLLM connects to LMCache over tcp://${HOST_IP}, so the server listens off loopback
MEDIUM k8s/vllm-deployment.yaml: lmcache/vllm-openai:latest-nightly is a moving tag; pin v0.5.6 or a digest
REVIEW vendor/lmcache-0.5.5/lmcache/custom_types.py: line 315: ext_hook=ext_hook, and this file references an unpickler: DeviceIPCWrapper.Deserialize (vendor/lmcache-0.5.5/lmcache/ipc_wrapper.py); trace what the hook calls
LOW k8s-patched/lmcache-daemonset.yaml: hostNetwork: true, so loopback is shared with every hostNetwork pod on the node
LOW k8s/lmcache-daemonset.yaml: lmcache/standalone:nightly with no runAsNonRoot/runAsUser; LMCache release images through v0.5.6 run as root
LOW k8s/vllm-deployment.yaml: lmcache/vllm-openai:latest-nightly with no runAsNonRoot/runAsUser; LMCache release images through v0.5.6 run as root
The REVIEW line is LMCache 0.5.5 itself. The hook there calls a deserializer looked up at runtime, so the script can't prove it reaches pickle and only notes that the file references a function that does. The code checks are pattern matches, so a clean result means none of these patterns appeared, not that the code is safe.
The fair view
The exposure is narrower than the 9.8 suggests. In-process mode, which many single-node vLLM deployments use, never opened the port, and the default bind was loopback. The maintainers merged a fix five days after the proof of concept went public, replaced pickle outright instead of filtering it, and published clear guidance on checking for compromise. They've also said what's left, starting with authentication for the RPC transport in 0.5.7.
But AI inference stacks keep repeating this pattern. Components built for trusted clusters use Python's most convenient serializer on a socket, and then an example manifest binds that socket to every interface so the demo works across nodes. Our earlier guides on the Python pickle security model and ML model serving infrastructure security cover the general case. The specific lesson here is that the fix is in the package, while the exposure is in the manifests and images most teams copied, and those hadn't caught up by 17 October.