The Sandbox Was an Empty Dict: SGLang’s Dumper eval() RCE Ships in 0.5.22 After Its Fix Was Closed as Queue Spam
NVD published CVE-2026-111391 at 12:16:33.197Z on 16 October 2026 against SGLang, the inference engine carrying 37,212 stars and 9,615 forks. VulnCheck is the assigning CNA, the record is vulnStatus: Deferred, and the CVSS 4.0 score is 9.2 critical on the vector AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N, classified CWE-95. The affected range is pkg:pypi/sglang from 0.5.10 through 0.5.22.
That upper bound is the story. SGLang 0.5.22 was uploaded to PyPI at 00:25:44Z on 16 October 2026 — eleven hours and fifty-one minutes before the CVE record went public, and it is the current release. There is no fixed version because the newest version is affected. A correct patch existed two days earlier. It was closed without merging.
Three lines, and one of them is the defence
We read python/sglang/srt/debug_utils/dumper.py at the v0.5.22 tag. The vulnerable construct is lines 1309 through 1320, quoted verbatim:
_FILTER_BUILTINS: dict[str, Any] = {"search": re.search, "match": re.match}
def _evaluate_filter(filter_expr: str, tags: dict[str, Any]) -> bool:
"""Evaluate a Python filter expression against the tags dict.
Unknown tag keys resolve to None, so `layer_id is None` works when layer_id is absent.
`re.search` and `re.match` are available as `search()` and `match()`.
"""
namespace = _DefaultNoneDict(tags)
namespace.update(_FILTER_BUILTINS)
return bool(eval(filter_expr, {"__builtins__": {}}, namespace))
The intent is legible and, in isolation, reasonable. This is a debugging facility: an engineer wants to dump tensors only when some condition over the tags holds — layer_id == 3, search("attn", name) — and a Python expression is the most ergonomic way to express that. Passing {"__builtins__": {}} as globals is the gesture that is supposed to make it safe.
It is not a sandbox, and the reason is the line directly above it. re.search is a Python function object, and every Python function object carries __globals__: a live reference to the module namespace it was defined in. The re module’s globals contain the real __builtins__ mapping. So the expression evaluator hands the attacker a pointer back to everything the empty dict was meant to remove. The published proof of concept walks exactly that chain — search.__globals__['__builtins__']['__import__'] — and then imports whatever it wants.
The reporter states the principle in one sentence we will quote because it is the whole lesson: “The empty-builtins argument is not a security sandbox because callable objects retain references to their defining globals.” Removing names from the globals dict does nothing if you then place, into the locals dict, an object whose attributes reconstruct them. This is not an exotic escape; it is the first thing anyone attacking a Python expression sandbox tries, and it has been the documented failure mode of eval-with-restricted-globals for roughly two decades.
Where the expression comes from, and where it runs
The filter is a configuration field. In the DumperConfig dataclass at the same tag:
@dataclass(frozen=True)
class DumperConfig(_BaseConfig):
enable: bool = False
filter: Optional[str] = None
dir: str = "/tmp/dumper"
...
server_port: str = "-1"
Both defaults matter. enable is False and server_port is "-1", so a default SGLang deployment exposes none of this — which is what the AT:P (attack requirements present) term in the CVSS vector is encoding, and it is encoded honestly. The configuration is read from the environment with a prefix that is worth noting for anyone grepping their own manifests, along with the comment explaining it:
@classmethod
def _env_prefix(cls) -> str:
# NOTE: should not be `SGLANG_DUMPER_`, otherwise it is weird when dumping Megatron in Miles
return "DUMPER_"
The environment variable that arms this is therefore DUMPER_SERVER_PORT, and it does not contain the string sglang. An inventory sweep for SGLang-specific environment variables will not find it.
The evaluation itself happens inside the dump hook, at line 498:
if (f := self._config.filter) is not None and not _evaluate_filter(f, tags):
return
This is the detail that makes the bug a deferred-execution primitive rather than a request-scoped one. The attacker writes the expression into configuration over HTTP and gets a 200; the code runs later, in the scheduler process, on the next forward pass that reaches a dumper hook. The HTTP request that plants the payload and the request that detonates it are different requests, and the second one can be an ordinary, legitimate inference call from a real user.
Two doors, and the CVE describes the one the researcher did not use
There are two deployment shapes, and the distinction is material to triage.
A numeric port starts a standalone control server. From the same file:
def _start_http_server(*, prefix: str, target: object, http_port: int):
handler_class = _make_http_handler(prefix=prefix, target=target)
server = HTTPServer(("0.0.0.0", http_port), handler_class)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
A wildcard bind, and the handler it registers performs no authentication of any kind — do_POST checks the path prefix, parses the JSON body, and dispatches. On this path the CVE’s PR:N is literal: reaching the port is the authorisation.
The string reuse mounts the same control surface on the main model API instead. We confirmed the route at v0.5.22 in python/sglang/srt/entrypoints/http_server.py, lines 891 to 895:
if os.environ.get("DUMPER_SERVER_PORT") == "reuse":
@app.api_route("/dumper/{method}", methods=["POST"])
@auth_level(AuthLevel.ADMIN_OPTIONAL)
async def _dumper_control_handler(method: str, request: Request):
This is the shape the reporter actually demonstrated, on a server launched with --api-key user-secret and no admin key. The observed result, from the issue: a wrong bearer token on /dumper/get_state returns 401; the ordinary API key returns 200; the ordinary key on /dumper/configure returns 200; one call to /generate then caused the scheduler process to write the marker file. The authorisation boundary was working exactly as designed — it distinguished a valid key from an invalid one — and the design placed arbitrary code execution on the user side of it.
The project has the right primitive and did not use it. From python/sglang/srt/utils/auth.py at the same tag, the three levels are defined with this docstring:
- NORMAL: legacy behavior (api_key protects all endpoints when configured)
- ADMIN_OPTIONAL: can be accessed without any key (if no keys configured),
or with api_key/admin_api_key depending on server config.
- ADMIN_FORCE: requires admin_api_key; if admin_api_key is NOT configured,
it must be rejected (403) even if api_key is provided.
ADMIN_FORCE is the level a remote code execution primitive belongs at, and it fails closed when no admin key is configured. The dumper route is at ADMIN_OPTIONAL, which fails open in precisely that case. The gap here is not a missing mechanism; it is one decorator argument.
The filter is not the only field you can set
One observation we add that neither the CVE nor the advisory mentions. The control handler dispatches configure to:
def configure(self, **kwargs) -> None:
self._config = replace(self._config, **kwargs)
That is an unfiltered dataclasses.replace over the whole config. The JSON body can set any field, not just filter. Among them is grafter_transform_path, documented in the dataclass as a “Fully-qualified Python path ‘pkg.subpkg.module.fn_name’”, which resolves through:
module = importlib.import_module(module_path)
return getattr(module, attr)
Reaching that call requires the grafter to be enabled, which has its own preconditions, so we are not claiming a second independently exploitable path and did not test one. The structural point stands regardless: the remediation question is not “how do we make the filter expression safe?” It is “why can an unauthenticated or ordinary-user HTTP request rewrite the serving process’s debug configuration at all?” A parser that only fixes filter leaves every other field writable.
Forty-eight minutes to a patch, fifteen hours to a close
The disclosure timeline is short and entirely public, and it is the part of this record defenders should read twice.
- 14 October, 04:32:22Z —
JPengLifiles issue #44188, “[Bug] Dumper filter eval() enables scheduler-process RCE”: the source lines, a working reproduction against 0.5.19 on an A100, HTTP status codes for each step, the marker file’s SHA-256, a candid note that an earlier attempt used a host path that did not exist inside the container, and a remediation proposal (structured parser or restricted evaluator; and if arbitrary expressions are genuinely needed, force admin auth and disable by default). - 14 October, 05:20:47Z — a different account,
Asthenia0412, opens PR #44205, replacingeval()with an AST-based evaluator that whitelists node types and blocks dunder attribute access. Forty-eight minutes after the report. Three files, +310/−94. - 14 October, 11:29:31Z — the PR author fixes a
ruff-formatCI failure and pushes again. - 14 October, 20:40:14Z — maintainer
hnyls2002closes it, unmerged. - 16 October, 00:25:44Z — 0.5.22 ships to PyPI with the
eval()intact. - 16 October, 12:16:33Z — the CVE publishes, naming 0.5.22 as affected.
The closing comment is quoted in full, because paraphrasing it would be unfair to the maintainer:
Closing this along with all other open PRs from this account, and blocking the account from sgl-project.
Over two days it opened 37 PRs, none merged: near-duplicates of the same change (four for
pp_proxy_tensorsalone), resubmissions of PRs closed the day before, test PRs, and runtime changes with no test, repro, or numbers.Every PR here costs maintainer review time and CI. Accounts that flood the queue with unverified PRs will be closed and blocked the same way.
We want to be careful here, because this is easy to report badly. The maintainer is not wrong, and nothing in that comment is unreasonable. Thirty-seven pull requests in two days from one account, four of them near-duplicates of each other, is a denial of service against the scarcest resource an open-source project has. The enforcement action is proportionate and the policy it announces is defensible. We also note what the maintainer did not say: he did not attribute the submissions to a model, and neither do we.
What we can say precisely is what the outcome was. A blanket close applied to an account’s entire queue cannot distinguish the forty-second duplicate from the one PR that patches a 9.2 remote code execution, and in this case it did not. The security fix was not rejected on its merits — it was never evaluated on its merits. At the time of writing we re-read dumper.py on the main branch: _FILTER_BUILTINS is still at line 1309 and eval(filter_expr, {"__builtins__": {}}, namespace) is still at line 1320. Issue #44188 remains open with one comment on it — the one announcing the fix that was closed.
There is a defensive engineering lesson in this that has nothing to do with Python. Volume-based abuse controls are now load-bearing infrastructure for open-source maintainers, and the queue they are filtering is the same queue security fixes arrive in. Any policy that acts on submitter rather than submission needs an exception path for reports that reference an open security issue — not because maintainers owe anyone free review, but because the alternative is what happened here: the project shipped a release two days later containing a bug it had already been handed a patch for. This site has repeatedly documented the gap between “a correct patch exists” and “a version you can install” — in PraisonAI, in vLLM’s unmerged fix PRs, and in SGLang’s own five multimodal records three days ago. This is the first instance we have covered where the patch was not merely waiting but affirmatively discarded, and by a rule that was aimed at something else entirely.
No feed will tell you
As with the previous SGLang batch, the record is effectively invisible to tooling. At the time of writing, api.osv.dev returns {"code":5,"message":"Vulnerability not found"} for CVE-2026-111391, and there is no GitHub Security Advisory — the vulnerability was filed as an ordinary public bug report, not through the advisory workflow. The NVD record is Deferred, so no NVD analysis is scheduled. A scanner reading the CVE’s version range correctly will mark every installation affected and offer nowhere to go, which is at least honest; a scanner keyed on OSV or GHSA will say nothing at all.
The exposure shape is also narrower and sharper than the score suggests, and we would rather be useful than alarming. DUMPER_SERVER_PORT is unset by default, so this is not a mass-exploitation story. It is a story about which deployments set it: the ones doing numerical debugging of a serving stack — comparing tensors across parallelism configurations, bisecting a precision regression, grafting activations between a baseline and a target. Those are long-lived internal clusters with real model weights and real credentials mounted, operated by the teams best equipped to be careful, and the variable is the kind of thing that gets set in a debugging session and left in a Helm values file. The scheduler process it lands in holds the weights, the environment, and the GPUs.
What to do
- Grep for the variable, not the package version. Every version you can install is affected, so a version check yields nothing actionable. Search your manifests, Dockerfiles, Helm values, Kubernetes
env:blocks and launcher scripts forDUMPER_SERVER_PORT— including the literal valuereuse, which is the shape that mounts the route on your main inference API. Remember the prefix isDUMPER_, notSGLANG_. - If it is set, unset it. There is no patch to apply, so removing the trigger is the remediation. The debug dumper is a development tool; nothing in production inference requires the control server.
- If you need it for an active debugging session, block the route at the gateway. On the
reuseshape, denyPOST /dumper/*at your ingress or sidecar — the route is mounted on the same app as/generate, so your existing model-API ingress is already in the path. On the numeric-port shape, nothing in SGLang restricts the bind; it is0.0.0.0unconditionally, so the control must be a NetworkPolicy or firewall rule. - Do not treat an ordinary API key as a trust boundary against this. The route is
ADMIN_OPTIONAL. If you run with--api-keyand no--admin-api-key, every key holder can reach it; configuring an admin key narrows the population but does not remove the primitive. Operators who need the route and can tolerate a local patch should consider raising it toADMIN_FORCE, which the codebase already implements. - Audit what the scheduler can reach. Code execution here inherits the serving process’s identity: mounted model volumes, the environment block, cloud instance credentials, and east-west network access. The same reasoning we applied to xoscar’s unauthenticated actor pool and Archon’s open bind applies: the question is not whether the API gateway is hardened, but who already has a route to the pod.
- Watch #44188, not the release feed. There is no advisory to subscribe to and no OSV entry to match. The open issue is the only authoritative place a fix will be visible before it ships.
Verification note. The CVE identifier, publication timestamp (12:16:33.197Z, 16 October 2026), vulnStatus: Deferred, assigning CNA, CVSS 4.0 score and vector, CWE-95 assignment, affected version range and reference list were read first-hand from the NVD 2.0 API. The severity label, affected range and researcher credits — Jiapeng Li, Jiajia Liu, Qiang Fu and Jiadai Wang — were read first-hand from the VulnCheck advisory. All Python excerpts from debug_utils/dumper.py, entrypoints/http_server.py and utils/auth.py were fetched at the v0.5.22 tag and are quoted verbatim, including comments and docstrings; the line numbers cited are those in that tag. The continued presence of the eval() call on the main branch was checked directly at the time of writing. Issue #44188’s title, author, creation timestamp, open state, reproduction steps, observed HTTP status codes, impact text and environment block, and pull request #44205’s author, creation and close timestamps, unmerged state, diff statistics and both comments, were read first-hand from the GitHub API; the maintainer’s closing comment is quoted in full and verbatim. Release versions and upload timestamps came from the PyPI JSON API, and star, fork and licence data from the GitHub API. The OSV absence was measured directly. The grafter_transform_path observation is our own reading of the source and is presented as a structural argument, not as a demonstrated second exploit; we did not test it. We did not install SGLang, launch a server, set DUMPER_SERVER_PORT, contact any dumper endpoint, author or execute any filter expression, or send any request to any third-party deployment; no proof of concept was executed and the escape chain is described only to the extent already published in the public issue. We make no claim that the closed pull request was machine-generated, and the maintainer makes none. We have no evidence of exploitation and imply none.
Sources:
- NVD — CVE-2026-111391: SGLang 0.5.10 through 0.5.22 code injection in the debug dumper filter (9.2 critical, CWE-95, Deferred)
- VulnCheck advisory — SGLang 0.5.10 through 0.5.22 Code Injection via Debug Dumper Filter (researcher credits)
- sgl-project/sglang issue #44188 — Dumper filter eval() enables scheduler-process RCE (open; reproduction, evidence and remediation proposal)
- sgl-project/sglang pull request #44205 — AST-based evaluator replacing eval() (closed unmerged, 14 October 2026)
- sgl-project/sglang
debug_utils/dumper.pyat v0.5.22 — _FILTER_BUILTINS, _evaluate_filter, DumperConfig and the 0.0.0.0 control server - sgl-project/sglang
entrypoints/http_server.pyat v0.5.22 — the /dumper/{method} route at AuthLevel.ADMIN_OPTIONAL - PyPI — sglang 0.5.22, uploaded 00:25:44Z on 16 October 2026 and named as affected