Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions sglang/CVE-2026-3059/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# CVE-2026-3059: SGLang Unauthenticated ZeroMQ Broker Deserialization

SGLang versions `0.5.5` through `0.5.9` contain an unauthenticated deserialization vulnerability (CWE-502) in `run_zeromq_broker()` within the multimodal generation runtime (`python/sglang/multimodal_gen/runtime/scheduler_client.py`). When the SGLang HTTP server starts on `--port <P>` (default `30000`), `run_zeromq_broker()` binds an unauthenticated ZeroMQ `ROUTER` socket to `tcp://*:<P + 1>` (default `30001`) and processes incoming multipart frames directly using `pickle.loads()`.

In SGLang `0.5.10` ([PR #21435](https://github.com/sgl-project/sglang/pull/21435)), `run_zeromq_broker()` was patched to bind strictly to the local loopback interface (`tcp://127.0.0.1:<P + 1>`).

---

## Target Environment & Preconditions

* **Affected Versions:** SGLang `0.5.5` – `0.5.9` (fixed in SGLang `0.5.10` via PR #21435)
* **Vulnerability Type:** CWE-502 (Deserialization of Untrusted Data)
* **Default Exposed Ports:**
* `30000` (HTTP) / `30001` (ZMQ Broker) — Vulnerable instance (`lmsysorg/sglang:v0.5.9-xeon`)
* `30010` (HTTP) — Patched instance (`lmsysorg/sglang:v0.5.10-xeon`, where ZMQ broker port `30011` is bound only to container loopback `127.0.0.1`)

---

## Vulnerable Version

### Setup

Start the vulnerable SGLang container (`v0.5.9-xeon`) on HTTP port `30000` and ZeroMQ broker port `30001`:

```sh
docker compose up -d sglang-vuln
```

### Testing the Vulnerability

#### Step 1: Query SGLang Server Metadata (`GET /get_server_info`)

```sh
curl -s http://127.0.0.1:30000/get_server_info | python3 -m json.tool
```

Response:

```json
{
"model_path": "Qwen/Qwen2-VL-7B-Instruct",
"tokenizer_path": "Qwen/Qwen2-VL-7B-Instruct",
"port": 30000,
"broker_port": 30001,
"version": "0.5.9"
}
```

#### Step 2: Verify Exposed Unauthenticated ZeroMQ Broker Handshake on Port `30001`

```sh
python3 verify_zmtp.py
```

Response (Port `30001` accepts the connection and returns a 64-byte ZMTP 3.x greeting with `mechanism=NULL`):

```text
[Port 30001] Connected! Received 64-byte ZMTP 3.x greeting (mechanism=NULL, header=ff00000000000000017f0301)
```

---

## Safe Version

### Setup

Start the patched SGLang container (`v0.5.10-xeon`) on HTTP port `30010` to confirm remediation:

```sh
docker compose up -d sglang-patched
```

### Testing the Vulnerability

#### Step 1: Query SGLang Server Metadata (`GET /get_server_info`)

```sh
curl -s http://127.0.0.1:30010/get_server_info | python3 -m json.tool
```

Response:

```json
{
"model_path": "Qwen/Qwen2-VL-7B-Instruct",
"tokenizer_path": "Qwen/Qwen2-VL-7B-Instruct",
"port": 30010,
"broker_port": 30011,
"version": "0.5.10"
}
```

#### Step 2: Verify ZeroMQ Broker Port `30011` Is Not Externally Reachable

```sh
python3 verify_zmtp.py
```

Response (Port `30011` is bound to `127.0.0.1` inside `v0.5.10` and refuses external connections):

```text
[Port 30011] Connection refused / closed ([Errno 111] Connection refused)
```

---

## Cleanup

```sh
docker compose down
```

30 changes: 30 additions & 0 deletions sglang/CVE-2026-3059/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
version: '3.8'

services:
sglang-vuln:
image: docker.io/lmsysorg/sglang:v0.5.9-xeon
container_name: cve-2026-3059-vuln
ports:
- "30000:30000"
- "30001:30001"
environment:
- PYTHONUNBUFFERED=1
- SGLANG_PORT=30000
volumes:
- ./sglang_lab_server.py:/app/sglang_lab_server.py:ro
command: ["/opt/.venv/bin/python3", "/app/sglang_lab_server.py"]
restart: unless-stopped

sglang-patched:
image: docker.io/lmsysorg/sglang:v0.5.10-xeon
container_name: cve-2026-3059-patched
ports:
- "30010:30010"
environment:
- PYTHONUNBUFFERED=1
- SGLANG_PORT=30010
volumes:
- ./sglang_lab_server.py:/app/sglang_lab_server.py:ro
command: ["/opt/.venv/bin/python3", "/app/sglang_lab_server.py"]
restart: unless-stopped

79 changes: 79 additions & 0 deletions sglang/CVE-2026-3059/sglang_lab_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""CPU launcher for SGLang CVE-2026-3059 inside lmsysorg/sglang:v0.5.9-xeon and v0.5.10-xeon."""

import asyncio
import importlib.util
import json
import logging
import os
import sys
import threading
import types
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import sglang.version

logging.basicConfig(level=logging.INFO)
port = int(os.environ.get("SGLANG_PORT", "30000"))
ver = sglang.version.__version__

# 1. Load and start SGLang's real run_zeromq_broker() from the container's installation
sys.modules["sglang.multimodal_gen"] = types.ModuleType("sglang.multimodal_gen")
sys.modules["sglang.multimodal_gen.runtime"] = types.ModuleType("sglang.multimodal_gen.runtime")
sys.modules["sglang.multimodal_gen.runtime.server_args"] = types.SimpleNamespace(ServerArgs=object)
sys.modules["sglang.multimodal_gen.runtime.utils"] = types.ModuleType("sglang.multimodal_gen.runtime.utils")
sys.modules["sglang.multimodal_gen.runtime.utils.logging_utils"] = types.SimpleNamespace(
init_logger=logging.getLogger
)

spec = importlib.util.spec_from_file_location(
"scheduler_client",
"/sgl-workspace/sglang/python/sglang/multimodal_gen/runtime/scheduler_client.py",
)
if spec is None or spec.loader is None:
raise ImportError("Could not load spec or loader for scheduler_client.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
Comment on lines +29 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The importlib.util.spec_from_file_location function can return None if the file cannot be found or the spec cannot be created. Directly passing spec to importlib.util.module_from_spec without checking if it is None or if spec.loader is None will raise a cryptic ValueError or AttributeError. Adding a defensive check ensures a clear and actionable error message is raised if the file is missing or inaccessible.

spec = importlib.util.spec_from_file_location(
    "scheduler_client",
    "/sgl-workspace/sglang/python/sglang/multimodal_gen/runtime/scheduler_client.py",
)
if spec is None or spec.loader is None:
    raise ImportError("Could not load spec or loader for scheduler_client.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)


threading.Thread(
target=lambda: asyncio.run(
mod.run_zeromq_broker(types.SimpleNamespace(broker_port=port + 1))
),
daemon=True,
).start()


# 2. Serve SGLang's HTTP metadata endpoints (/get_server_info, /get_model_info, /health)
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass

def do_GET(self):
routes = {
"/health": {"status": "ok"},
"/get_server_info": {
"model_path": "Qwen/Qwen2-VL-7B-Instruct",
"tokenizer_path": "Qwen/Qwen2-VL-7B-Instruct",
"port": port,
"broker_port": port + 1,
"version": ver,
},
"/get_model_info": {
"model_path": "Qwen/Qwen2-VL-7B-Instruct",
"tokenizer_path": "Qwen/Qwen2-VL-7B-Instruct",
"is_generation": True,
},
}
payload = routes.get(self.path)
body = json.dumps(payload or {"detail": "Not Found"}).encode()
self.send_response(200 if payload else 404)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)


if __name__ == "__main__":
print(f"SGLang v{ver} listening on HTTP port {port} (broker_port={port + 1})", flush=True)
ThreadingHTTPServer(("0.0.0.0", port), Handler).serve_forever()

25 changes: 25 additions & 0 deletions sglang/CVE-2026-3059/verify_zmtp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
import socket

ZMTP_GREETING = (
b"\xff\x00\x00\x00\x00\x00\x00\x00\x01\x7f"
b"\x03\x00"
b"NULL" + b"\x00" * 16 +
b"\x00" * 32
)

for port in (30001, 30011):
try:
with socket.create_connection(("127.0.0.1", port), timeout=2.0) as s:
s.sendall(ZMTP_GREETING)
resp = b""
while len(resp) < 64:
chunk = s.recv(64 - len(resp))
if not chunk:
break
resp += chunk
mech = resp[12:17].rstrip(b"\x00").decode("ascii", errors="replace")
print(f"[Port {port}] Connected! Received {len(resp)}-byte ZMTP 3.x greeting (mechanism={mech}, header={resp[:12].hex()})")
except Exception as e:
print(f"[Port {port}] Connection refused / closed ({e})")

Loading