Safeguard
Vulnerability Analysis•

LMCache CVE-2026-105192: Securing vLLM's KV Cache Server After 0.5.6

LMCache CVE-2026-105192: one ZeroMQ message ran code via pickle. 0.5.6 fixes it, but example manifests and images lag. Safe setup steps and a checker.

Safeguard Security Team
Security Research
15 min read

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

DeploymentExposed?
LMCache inside the vLLM process (in-process mode)No, no port is opened
Multiprocess mode with the default --host localhostOnly to processes on the same host or in the same network namespace
Multiprocess mode with --host 0.0.0.0 or a node IPYes, to anything that can reach the port
The project's example Kubernetes DaemonSet, up to and including the v0.5.6 tagYes. 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:

Itemv0.5.6 tag and imagesdev branch
Pickle removed from device IPC descriptorsYesYes
HTTP frontend default bind127.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 USERNoYes
lmcache/standalone:v0.5.6 and lmcache/vllm-openai:v0.5.6 image userEmpty, so rootNightly builds from 16 October run as lmcache
Image tags the examples referencestandalone:nightly and vllm-openai:latest-nightlyThe 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

  1. 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.6 in the lockfile, not just the requirements file.
  2. Pin images. Use lmcache/standalone:v0.5.6 and lmcache/vllm-openai:v0.5.6, or better, their digests. Don't use nightly or latest-nightly.
  3. 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.
  4. 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.
  5. Run as non-root anyway. Set runAsNonRoot: true and a numeric runAsUser, or build from the dev Dockerfile, which adds an lmcache user. It doesn't stop the bug, but it limits what an attacker gets.
  6. Keep the HTTP frontend local. If you run 0.5.5 or older, its frontend listened on 0.0.0.0:8080 by default, and the maintainers say that RPC path has no authentication yet. Set --http-host 127.0.0.1 until 0.5.7.
  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.

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.