BREAKING: CVE-2026-76850 - LMDeploy Pickle RCE in Disaggregated Serving

BREAKING: CVE-2026-76850 - LMDeploy Pickle RCE in Disaggregated Serving

CVE-2026-76850 is an unauthenticated pickle RCE in LMDeploy's disaggregated-serving peer connector. Versions >=0.9.2 and <0.16.0 are affected. Upgrade to 0.16.0.

4 min read816 words
Contents

TL;DR: LMDeploy's disaggregated-serving peer connector treats ZMQ peer messages as pickle. The handle_zmq_recv coroutine in lmdeploy/pytorch/disagg/conn/engine_conn.py calls recv_pyobj(), which is pickle.loads() on the received bytes, and only then checks isinstance(..., DistServeCacheFreeRequest). The peer address is caller-controlled: POST /distserve/p2p_connect passes remote_engine_endpoint_info.zmq_address into connect() on the engine's PULL socket. Those HTTP routes apply no authentication unless the server is started with api_keys, which defaults to None. A remote attacker who can reach a disaggregated engine can point it at a hostile ZMQ endpoint and execute code in the engine process. InternLM lmdeploy >=0.9.2 and <0.16.0 is affected. Upgrade to 0.16.0. Deployments that do not enable disaggregated serving are not affected. NVD has not listed this CVE yet. The VulnCheck CNA record scores it 9.3 Critical under CVSS 4.0 (9.8 under CVSS 3.1).

What happened

LMDeploy (PyPI package lmdeploy) is InternLM's toolkit for compressing, deploying, and serving large language models. Prefill-decode disaggregation uses a ZMQ P2P connector so engine nodes can exchange cache-free requests.

That connector had two stacked mistakes.

First, the wire codec was pickle. zmq.Socket.recv_pyobj() deserializes with pickle.loads(). Any object the peer sends is reconstructed, including objects whose __reduce__ method runs arbitrary callables. The isinstance check against DistServeCacheFreeRequest runs after that reconstruction, so it cannot prevent code execution.

Second, the engine does not bind the receive socket and wait for a trusted peer. p2p_connect connects the PULL socket to an address taken from the HTTP request body. POST /distserve/p2p_initialize and POST /distserve/p2p_connect in lmdeploy/serve/openai/api_server.py have no route-level auth and no validate_json_request dependency. AuthenticationMiddleware is installed only when api_keys is passed to serve().

The reachable chain is: an unauthenticated HTTP POST that supplies a zmq_address, the engine's PULL socket connecting outbound to that address, then handle_zmq_recv calling recv_pyobj() on whatever the attacker sends.

AAtomical reported the pickle sink in GitHub issue 4804 on 2026-07-29. A later review on that issue corrected the socket direction: the bound PUSH port is not an inbound pickle oracle. The HTTP-controlled connect path is.

Affected versions

  • Package: PyPI lmdeploy (vendor InternLM)
  • Affected: >=0.9.2 and <0.16.0 (the recv_pyobj() path is present in the v0.9.2 and v0.15.0 trees)
  • Fixed: 0.16.0, released 2026-08-19, includes PR 4812 / commit f05b4ad
  • CWE: CWE-502 (Deserialization of Untrusted Data)
  • CVSS: VulnCheck CNA, CVSS 4.0 9.3 Critical (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N). CVSS 3.1 9.8 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). NVD has no record yet. Not listed in CISA KEV.
  • Scope: only engines that enable disaggregated serving. The receive loop starts after the migration backend accepts the connection.

What to do

Upgrade the PyPI package to the fixed release:

pip install --upgrade lmdeploy==0.16.0

Confirm the installed version:

pip show lmdeploy

The Version field should be 0.16.0 or newer. Official install docs also accept pip install lmdeploy now that 0.16.0 is the current PyPI release.

If you cannot upgrade immediately and you run prefill-decode disaggregation, start the API server with API keys so AuthenticationMiddleware covers the /distserve/* routes (those paths are not in the middleware skip list):

lmdeploy serve api_server <model> --api-keys <secret>

--api-keys is a documented server option. Default is None, meaning no key is required. Setting it is a control on the HTTP entry that supplies zmq_address. It is not a substitute for the pickle-to-JSON fix, and InternLM did not publish it as a CVE-specific workaround.

If the cluster does not need disaggregation, leave it disabled. Hybrid / colocated serving does not start this receive loop.

After upgrading, review API-server access logs for unexpected POST /distserve/p2p_initialize and POST /distserve/p2p_connect calls, and keep those routes off the public internet even after the patch.

Technical details

Vulnerable receive path in v0.15.0 engine_conn.py:

self.p2p_receiver[conn_request.remote_engine_id].connect(
    conn_request.remote_engine_endpoint_info.zmq_address)
...
req: DistServeCacheFreeRequest = await self.p2p_receiver[remote_engine_id].recv_pyobj()
if isinstance(req, DistServeCacheFreeRequest):
    ...

The type annotation is cosmetic. Pickle can return any object. The isinstance branch never runs if __reduce__ already executed.

Commit f05b4ad (PR 4812, "fix(disagg): use JSON instead of pickle for P2P ZMQ requests") switches both send_pyobj / recv_pyobj to send_json / recv_json and validates with DistServeCacheFreeRequest.model_validate before use. Decoding is json.loads, which does not execute code. Off-schema messages are logged and skipped so a bad payload cannot tear down the detached receive loop.

That is the correct fix for this class of bug. Adding ZMQ CURVE/ZAP on its own would not close the HTTP-controlled connect path, because the engine would authenticate to whatever endpoint the request named.

No GitHub Security Advisory exists for CVE-2026-76850 as of this writing. HOL Guard's first-seen evidence pack recorded cvssScore: null because NVD has not ingested the record. The scores above come from the published VulnCheck CNA CVE document.

Track the CVE on HOL Guard as more sources land.

References

Continue reading

All posts