The Flag Changed the Envelope, Not the Decoder: SGLang CVE-2026-93034 and an Affected Range That Stops Too Early

CERT/CC published CVE-2026-93034 on 8 October 2026 against SGLang, the high-throughput LLM serving engine. The mechanism, in the CNA's own words, is that the ZMQ message decoder “unconditionally deserializ[es] PickleWrapper payloads via pickle.loads() in _maybe_unwrap_pickle without type allowlisting or authentication” — and that this “persists via the msgpack path even when SGLANG_USE_PICKLE_IPC is disabled.” CISA's ADP container scores it CVSS 3.1 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H), CWE-502, with SSVC filed as exploitation none, automatable yes, technical impact total. NVD carries the record with status Deferred.

The interesting part is not the deserialization bug. It is the affected range: the CVE record lists SGLang 0 through v0.5.20, inclusive. v0.5.21 shipped on 2 October 2026, six days before the CVE published. Any scanner reading that range mechanically will mark a v0.5.21 deployment as not affected. We read the code at the v0.5.21 tag and at main, and the decoder is unchanged.

A typed format that still accepts a pickle

SGLang splits a served model across cooperating processes — tokenizer, scheduler, detokenizer — that exchange request objects over ZeroMQ. Two transports exist. With SGLANG_USE_PICKLE_IPC set, sock_recv() calls PyZMQ's recv_pyobj(), which is pickle by definition. Clear it, and SGLang switches to a typed msgpack decoder built on msgspec. That looks like the safe branch, and it is the branch a hardening guide would tell you to take.

It is not the safe branch, because the decoder's accepted-type union is assembled as the BaseReq and BaseBatchReq subclasses plus a struct called PickleWrapper — a record whose entire purpose is to carry pickle bytes through a transport that cannot represent arbitrary Python objects. The wrapper is accepted as a top-level message. Once msgpack has decoded it, _maybe_unwrap_pickle does exactly one thing with it:

if isinstance(obj, PickleWrapper): obj = pickle.loads(obj.data)

There is a type check. It checks that the sender picked the pickle path, and then takes it. The application-level dispatcher that decides whether the reconstructed value is a message the detokenizer knows how to handle runs after sock_recv() returns — which is to say, after pickle.loads() has already executed whatever reduction the sender encoded. A rejection cannot undo an action performed while decoding. Toggling the flag changed the envelope and left the decoder where it was.

What makes this worth writing up rather than filing as a known hazard is that SGLang has a safe unpickler. SafeUnpickler in utils/common.py carries an exact-name ALLOWED_GLOBALS set with a comment explaining the design: keep the globals exact so a newly added callable is denied by default. safe_pickle_loads() wraps it. The weight cache protocol uses it; the disaggregated encoder's receiver and server use it. The manager IPC decoder — the one the CVE names — calls bare pickle.loads(). The mitigation exists in the same repository and was not wired into this path.

Who can reach it

A deserialization gadget is only as interesting as the set of peers who can send to it, and here the honest answer is narrow. PortArgs.init_new() in server_args.py has three branches. Without data-parallel attention — the default — every inter-process channel is a ipc:// Unix-domain socket backed by a temp file, which never leaves the host. Turn on DP attention and the channels move to TCP, but with nnodes == 1 and no --dist-init-addr they bind 127.0.0.1. Supply a non-loopback --dist-init-addr and the detokenizer's receive socket binds there: detokenizer_ipc_name becomes tcp://<dist_init_host>:<port_base+1>, and detokenizer_manager.py binds it with get_zmq_socket(context, zmq.PULL, port_args.detokenizer_ipc_name, True) before looping on sock_recv(). No peer authentication anywhere in that path.

So the precondition stack is: DP attention enabled, a non-loopback distribution address, and network reachability to a derived port that is not the inference API port. That is a supported multi-node configuration, not the single-node default. It is also worth noting what SGLang already knows about this failure mode: the sibling helper get_zmq_socket_on_host() in utils/network.py carries a docstring defaulting its host to 127.0.0.1 “to avoid exposing unauthenticated sockets to the network” — and cites CVE-2026-3060 by number for the reason. The project patched the binding default on one helper and left the decoder reachable through another.

The range problem

The researcher's own write-up is unusually disciplined about evidence tiers, and we are repeating its boundaries rather than smoothing them. The executed experiments — a marker file written by the receiving process, with the setting on and off, three fresh starts each — were run on v0.5.18. A commit-pinned source inspection on 1 October found the same receive and unwrap primitives in v0.5.20 and in the main revision of that day. The author explicitly declines to call that a runtime test of those versions or a continuous affected range, and notes that a 7 October release check found v0.5.21 published after the inspection, whose fix status the post does not assess. CERT/CC received the report on 14 July 2026 and confirmed arbitrary code execution under the non-loopback DP-attention condition on 17 September; the case is VU#765030, though no public vulnerability note was resolvable at that ID when we checked.

We closed the gap the write-up left open. Reading python/sglang/srt/managers/io_struct.py at the v0.5.21 tag and at main (commit 86624f14, 9 October 2026): PickleWrapper is still a msgspec.Struct with tag=True; it is still appended to _struct_types and therefore still inside the decoder's accepted Union; _maybe_unwrap_pickle still calls bare pickle.loads(obj.data) with no allowlist; and SGLANG_USE_PICKLE_IPC still defaults to EnvBool(True) in environ.py at both v0.5.20 and v0.5.21. The detokenizer's bind-and-receive loop is identical at v0.5.21. No GitHub Security Advisory has been published in the sgl-project/sglang repository for this issue, and the only non-repository reference on the CVE record is the researcher's post.

That makes ≤ v0.5.20 a statement about where the researcher stopped looking, not about where the code changed — but it is the field every automated consumer will read. It is the inverse of the problem we documented when DB-GPT's CVEs named no fixed version at all: there, the absence of a ceiling made the record useless; here, a precise-looking ceiling makes it actively misleading. A range derived from an inspection date needs to say so.

What to do

  • Do not treat v0.5.21 as remediated. The CVE's upper bound reflects a 1 October source inspection, not a code change. Verify against your own tree: if _maybe_unwrap_pickle in io_struct.py calls pickle.loads without an allowlist, the path is present regardless of version string.
  • Audit the reachability condition, not the flag. The question is whether you run data-parallel attention with a non-loopback --dist-init-addr. If you do, the detokenizer and sibling receivers are bound on that address, on ports derived from it, with no peer authentication. Clearing SGLANG_USE_PICKLE_IPC does not change that; it was never the control.
  • Put network controls on the derived ports. A loopback-bound HTTP API says nothing about separately bound component sockets. Restrict the dist_init_port + 1..6 range to the hosts that are genuinely part of the serving group, and treat broader exposure as the actual finding.
  • If you maintain or fork this code, wire in what already exists. safe_pickle_loads() and its exact-name allowlist ship in the same package and are already used by the weight cache and encoder paths. Removing PickleWrapper from the top-level accepted union is the stronger fix; routing the unwrap through the safe unpickler is the smaller one.

Verification note: the CVE ID, CNA (certcc), reserved date (17 September 2026), publication timestamp (8 October 2026, 14:00:09Z), description text, affected range (0 through v0.5.20), CWE-502 classification, and reference list were read from the CVE Program record via the CVE Services API; the CVSS 3.1 9.8 vector, Deferred status, and SSVC triple were read from the NVD record. The CERT/CC report date, 17 September confirmation, VU#765030 identifier, v0.5.18 test target, 1 October source inspection and 7 October release check are as stated in the researcher's own published write-up, which is the CVE's sole non-repository reference; we did not independently witness those experiments, and kb.cert.org returned no note at VU#765030 when checked. The v0.5.21 and main code claims — PickleWrapper in the accepted union, bare pickle.loads in _maybe_unwrap_pickle, SGLANG_USE_PICKLE_IPC defaulting to True, the detokenizer bind, the PortArgs IPC-versus-TCP branches, and the presence of SafeUnpickler/safe_pickle_loads elsewhere in the package — were read by us directly from the repository at those tags and at commit 86624f14. We ran no SGLang service and sent no crafted message to any host.

Sources: