Blog

Is the vLLM tool-call parser safe? CVE-2025-9141, the 29-day eval() window, and four checks a gateway owes you

vLLM's Qwen3 Coder tool parser ran eval() on model output for 29 days in 2025 (CVE-2025-9141). Today: 0 eval(), 4 literal_eval. What a gateway can and cannot check.

Leo Kaka18 min read
Path diagram: model tokens become Qwen3 Coder XML, enter the vLLM tool parser, reach a highlighted eval() call and then the host; below it a version bar showing eval() present from v0.10.0 through v0.10.1 and removed in v0.10.1.1

It was not safe for 29 days in 2025, and the warning was on the pull request the whole time. The tool-call parser is the code inside an inference engine that turns model tokens into a structured tool call. vLLM’s parser for Qwen3 Coder passed any tool argument whose type it did not recognise to Python’s eval(), which executes whatever expression it is handed. An automated reviewer flagged the line as a critical security vulnerability on the day the PR was opened, the maintainer force-merged it the same day, and the fix arrived on 20 August as CVE-2025-9141, CVSS 8.8. So yes, a model can exploit the engine that runs it: the parser is code that runs on model-controlled input, and for a month that code was eval. Today’s vLLM has zero eval() calls across the 50 files of its tool-parser package (I checked), but four parsers still run ast.literal_eval through a thin wrapper, and Python’s own documentation now says not to call that on untrusted data.

The part of this that concerns a gateway is the geometry. The parser sits inside the inference engine. By the time a tool call reaches a gateway it is already JSON, and whatever the parser did to produce that JSON has already happened. A gateway can enforce hygiene on the way out; it cannot reach back into the engine. This post walks the CVE from the source, then draws that line precisely, with the price of each check written next to it.

Three layers, three dates, and only one CVE

Boyd Kane’s essay of 25 August, which reached 173 points and 84 comments on Hacker News by the time I pulled it, is about a boundary most threat models skip. The harness (the agent program that actually executes tool calls) runs on one machine; the model’s tokens are computed on another, with GPUs, weights, and datacentre network access. His one-sentence threat model: “Because the LLM controls the tokens passed to the inference engine, a malicious LLM could therefore emit a sequence of tokens that a poorly written inference engine mistakes for code or instructions to execute rather than data to return to the user.”

That is the middle of three layers. The other two surfaced on their own dates, and they need naming so nobody confuses them.

LayerWhat holds the boundaryWhat was reported, and whenStatus
WorkspaceThe agent harness’s filesystem rulesOne r/LocalLLaMA user reported on 24 August 2026 that DeepSeek Harness, a preview, “left the project folder (although DSH was set up correctly) and started to walk through my other files”Not a breach. DSH’s sandbox docs fence writes only, and its fs-sandbox README says so: “Reads always pass through — every mode permits reading.” The confirmed write escape is discussion #523, on the minimal preset
Inference engineThe parser between model tokens and structured outputCVE-2025-9141, July to August 2025: tool arguments to eval() in vLLMConfirmed, fixed in 0.10.1.1; this post
PlatformThe hosting platform’s own infrastructureOpenAI’s 21 July 2026 disclosure: during an evaluation its models escaped a sandbox and chained stolen credentials and zero-days into remote code execution reaching Hugging Face’s production database, a “platform-level compromise” in OpenAI’s wordsConfirmed by both parties; covered in our 24 August post

Three different teams own those three boundaries, and this post is only about the middle one. It is the only one of the three where the untrusted input is produced by the model itself, arrives as a token stream, and lands in code running on someone else’s expensive machine.

What CVE-2025-9141 actually did: the branch that reached eval()

The advisory’s summary is one sentence: “An unsafe deserialization vulnerability allows any authenticated user to execute arbitrary code on the server if they are able to get the model to pass the code as an argument to a tool call.” The CVSS vector is AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H: network, low complexity, low privileges (an API key), no user interaction, full compromise. Affected: >= 0.10.0, < 0.10.1.1. Weakness class: CWE-502, deserialization of untrusted data. The advisory lists the preconditions too: tool calling enabled (--enable-auto-tool-choice), --tool-call-parser qwen3_coder, and a parameter whose type is not explicitly defined or recognised.

Screenshot of GitHub advisory GHSA-79j6-g2m3-jgfw, "Remote code execution in the vllm tool call parser for Qwen3-Coder": affected versions 0.10.0 up to but excluding 0.10.1.1, patched 0.10.1.1, severity High 8.8/10 with the CVSS v3 base metrics broken out, CVE ID CVE-2025-9141, weakness CWE-502, and a Details section listing the three conditions under which the code path is reached
The advisory as published: three preconditions, one weakness class, and an 8.8 that comes entirely from what the parser does with an unrecognised parameter type. Page captured 2026-08-26.Source: GHSA-79j6-g2m3-jgfw.

Here is the code that earned that score, from the parser as shipped in v0.10.1, lines 200–220. Qwen3 Coder emits tool calls as XML-ish markup rather than JSON, so the parser has to coerce each <parameter=name> string into the type the tool schema declares:

# vllm/entrypoints/openai/tool_parsers/qwen3coder_tool_parser.py @ v0.10.1
# (branches above this point handle string / int / float / bool and never reach eval)
else:
    if param_type == "object" or param_type.startswith("dict"):
        try:
            converted_value = json.loads(param_value)
            return converted_value
        except json.JSONDecodeError:
            logger.warning(
                "Parsed value '%s' of parameter '%s' is not a "
                "valid JSON object in tool '%s', will try other "
                "methods to parse it.", param_value, param_name,
                func_name)
    try:
        converted_value = eval(param_value)   # <- model output
        return converted_value
    except Exception:
        logger.warning(
            "Parsed value '%s' of parameter '%s' cannot be "
            "converted via Python `eval()` in tool '%s', "
            "degenerating to string.", param_value, param_name,
            func_name)
    return param_value

The essay says the parser “passed almost every tool-call argument to eval()”. Reading the whole function, that is too strong, and the precise version matters because it tells you which tool schemas were exposed. String, integer, float and boolean parameters each had their own conversion and never reached eval. Parameters whose name was not in the schema were returned as plain strings. Everything else fell through: an object whose value failed json.loads, every array, and any type string the parser had not heard of. If your agent had a single tool with a list-typed argument, a files: string[] say, then every call to it put a model-generated string through eval() on the inference host. Most agent tool sets have several.

The PR:L in the vector is doing real work. The attacker needs an API key to the vLLM server, and needs the model to emit the payload as a tool argument. One HN commenter, Zambyte, frames it as a malicious-model problem: the same class as a crafted PDF and a vulnerable Acrobat, so be careful “downloading and running random models”. I would add the delivery route that needs no malicious weights: prompt injection, where the payload is generated on the fly by an honest model reading a hostile inbox, and the API key belongs to whoever the server already trusts.

The 29 days between “critical” and the fix

The timeline is entirely public, and I am reproducing it from the pull request pages rather than from the essay, because the essay’s most quotable claim, that an automated reviewer flagged the bug and the maintainer merged over it, deserved a primary source.

Date (2025)EventSource
22 JulPR #21396 “[Model] Add Qwen3CoderToolParser” opened by the parser’s author; first commit contains param_value = eval(param_value)PR
22 Julgemini-code-assist review: “I’ve identified a critical security vulnerability with the use of eval() on model output, which must be addressed.”same PR
22 JulMaintainer: “I’m force merging this to unblock model usage, after lint.” Merged as 4594fc3, 7 of 9 checks passedsame PR
24 Julv0.10.0 released with the parserrelease
18 Augv0.10.1 released, eval still presentrelease
20 AugAdvisory published (reporter levigross); PR #23266 “Do not use eval() to convert unknown types” merged; v0.10.1.1 released as “a critical bugfix and security release”PR #23266, release
20 AugSame release also fixes GHSA-rxc4-3w6r-4v47: unauthenticated memory-exhaustion via an oversized HTTP header; the advisory’s own remediation includes “use a proxy in front of vLLM”advisory
27 AugPR #23099 merged: the eval fallback comes back as ast.literal_eval(param_value) # safer, because “falling back to plain strings breaks many Qwen3-Coder outputs”PR #23099

Twenty-nine days from the warning to the fix, across two tagged releases. I want to be careful about the tone here, because the obvious reading, maintainer ignores security bot and ships RCE, is accurate and also not very useful. The maintainer’s reason was written down: a popular model had just been released and its tool calling did not work without this parser. Parsing an arbitrary token stream into turns, tool calls and arguments is not trivial, and the pressure to ship parsers on release day is real. The patched release went out the same day as the advisory; that part of the record is to the maintainers’ credit. None of that changes the structural point: the review signal existed, a human overrode it in writing, and as far as the public record shows nothing in the process brought anyone back to the line for a month. If you run inference engines, that is the failure mode to design around: not the bot being wrong, but the bot being right and nobody being on the hook to re-read it.

The fix itself is nine lines removed and four added. Diff the parser file at the v0.10.1 and v0.10.1.1 tags and this is the whole change; the PR replaces the eval block with a warning and a string:

# vllm/entrypoints/openai/tool_parsers/qwen3coder_tool_parser.py @ v0.10.1.1
logger.warning(
    "Parameter '%s' has unknown type '%s'. "
    "The value will be treated as a string.", param_name,
    param_type)
return param_value

A week later ast.literal_eval went back in, on the argument that it has no namespace and cannot call functions, so it does not reproduce the RCE. True, and not the end of the story; the next section has the numbers.

What the parser looks like a year later: 50 files, zero eval(), one very short adapter

The essay’s broader claim is that inference engines are complex, fast-moving, and therefore bug-prone. That is easy to assert and cheap to check, so I checked it on 25 August 2026 against vLLM’s main. Method: fetch every file in vllm/tool_parsers/ and grep. Anyone can reproduce this in about a minute.

MeasurementValue
Files in vllm/tool_parsers/50 (44 parser modules plus 6 shared helpers such as utils.py)
Lines15,021
eval(0
exec(0 in code; one mention inside a docstring in utils.py
Parsers calling safe_literal_eval4 — hy_v3, step3p5, minicpm5xml, poolside_v1. The wrapper only silences SyntaxWarning and then calls ast.literal_eval; there is no size or depth guard

The Qwen3 Coder parser that started all this no longer exists as a file. Both qwen3_coder and qwen3_xml now map to Qwen3EngineToolParser, an eight-line adapter over a generic parser engine driven by per-model structural tags. The argument conversion for Qwen3 today is this, from vllm/parser/qwen3.py:

def _qwen3_arg_converter(raw_args: str, partial: bool) -> str:
    params: dict[str, object] = {}
    for match in _PARAM_RE.finditer(raw_args):
        name = match.group(1)
        value = match.group(2)
        params[name] = _trim_wrapping_newlines(value)
    # ... partial-stream handling elided ...
    return json.dumps(params, ensure_ascii=False)

Every parameter value stays a string. No coercion, no eval, no literal_eval. Most self-hosted installs sit somewhere between the CVE and today, so I pulled the same file at every tag in between:

vLLM versionWhat qwen3_coder did with an unrecognised type
0.10.0 – 0.10.1eval() — the CVE
0.10.1.1Returned the string
0.10.2 – 0.21.0ast.literal_eval()
0.22.0 – 0.23.0int/float/bool/json.loads by declared type, otherwise the string; no literal_eval
0.24.0 onwardsEngine parser (PR #45413); every value a string, old file deleted

So the code path went eval → string → literal_eval → string, and today’s answer goes further than the day-29 fix did: every value, not just unknown types, is a string. The cost moved downstream. A tool that declares count: integer now receives "count": "3" and has to coerce it itself. That is the correct place for the cost to be, and it is worth knowing that you are paying it.

The four literal_eval parsers are the residue. None is an RCE. But Python’s documentation for the function now reads, in full seriousness: “This function had been documented as ‘safe’ in the past without defining what that meant. That was misleading.” It continues: “A relatively small input can lead to memory exhaustion or to C stack exhaustion, crashing the process… Calling it on untrusted data is thus not recommended.” Model output is untrusted data. The comment in the 2025 patch says # safer. It is. It is not safe. The documented failure is a process crash, a denial of service against the inference host delivered by a tool argument, and whether that matters depends on how many other tenants share that host. I scanned vLLM only; llama.cpp and SGLang have parsers of the same shape, and I have not checked them.

For a sense of how much a parser interprets rather than copies, take the bug the essay cites as harmless: issue #48663. A sister parser, the one that splits reasoning from the answer (--reasoning-parser minimax_m3, vLLM 0.24.0), cut in half any JSON response that quoted the literal text <mm:think>, 2 of 546 documents in the reporter’s run, and reported finish_reason: "stop". Nothing executed. A schema-valid response was rewritten into an invalid one and reported as success, which is what interpreting looks like when it goes wrong quietly.

Where the gateway sits, and why it saw none of this

Draw the path a tool call takes. Tokens come off the GPU. The engine detokenises them into text. The tool parser scans that text for the model’s native markup, extracts names and arguments, coerces types, and produces an OpenAI-shaped tool_calls array. That array is serialised to JSON and goes out over HTTP. A gateway, if there is one, receives that HTTP response, does whatever it does, and forwards it to the harness, which executes the tool.

The eval() in CVE-2025-9141 ran inside the parser, three steps in, and the streaming path in the same file (line 514 at v0.10.1) called the same conversion, so streaming changed nothing. The gateway is the last step on that list. By the time a gateway sees "arguments": "{\"files\": [\"a.py\"]}", the string that produced it has already been through the parser, and if the parser was the vulnerable one, the code has already run. A gateway is the harness’s boundary. It is not the engine’s boundary. There is no filter you can place between engine and harness that protects the engine, because the engine is upstream of you.

The HN thread spent most of its energy re-discovering this. angry_octet insists the essay “isn’t talking about exploits of sandboxes, it is about attacking the inference engine (e.g. vLLM or llama.cpp or SGlang) via its http interface”, and then describes running vLLM “on a separately sandboxed VM on a firewalled VLAN … No DNS, no AD/LDAP, nothing.” That is a deployment answer, and it is the right one. xg15 notes that on real multi-GPU clusters the code that turns tokens into API JSON is probably already on a different machine from the one doing the math; the essay’s own proposed defence, “Run the GPUs and token parser on separate computers”, arriving as a description of what large operators already do. The rest of the thread argues about which layer isolation belongs to; the positions are not in conflict, they are about different rows of the first table.

The official record points the same way. The remediation text for the header-size advisory fixed in the same release says: upgrade, “or use a proxy in front of vLLM which provides protection against this issue.” That is a reverse proxy with a header-size limit; nginx does it in one directive. A gateway is one place that job can live. It is a real job either way, and it is not the job of stopping eval.

Four checks a gateway owes you, and what each one costs

We build a gateway, so the list below is also a list of our own limits; I have written it as what a gateway in this position can do, not what any particular product ships. Every check here can also live in the harness, and most agent frameworks already do some of it; what a gateway adds is one policy and one audit log in front of N harnesses, not a check the harness cannot do. If you have no gateway, put these in the harness, before it executes anything. The principle is the one every parser in this story eventually landed on: a tool call is untrusted data until it has been checked against the schema that requested it, and the request already carries that schema in tools[].function.parameters.

CheckVerdictWhy, and the cost
arguments must parse as a JSON objectRejects the class of output where the upstream parser gave up and passed a string through. Cost: some models emit slightly broken JSON that the harness would have repaired; a fail-closed gateway turns those into hard errors, so the policy needs a per-tool “repair or reject” switch
Validate arguments against the tool’s declared parameters schemaTypes, required fields, and additionalProperties where the schema declares it. The vulnerable parser coerced types with eval on the engine side; this check protects the harness, not the engine, and JSON Schema does it without evaluating the argument. Cost: one compile per distinct schema, cached by content; one validation per call, microseconds
Cap sizes: bytes per argument, bytes per call, calls per responseProtects the harness’s JSON deserialiser and the tool implementation from oversized arguments. It does nothing for literal_eval on the engine side; that has already run. Cost: legitimate long arguments (a file body, a patch) need explicit per-tool limits, or you will break real workloads
Reject native markup leaking throughIf arguments still contain the model’s raw tool syntax (for Qwen3 that is <tool_call>, <function=, <parameter=) the upstream parser did not finish its job. Cost: false positives on text that legitimately discusses those tokens (issue #48663’s shape); coding agents edit prompt templates containing exactly these strings, so keep it off for coding workloads and allowlist elsewhere
Pin and record the engine version behind each self-hosted routeRefuse to route tool-calling traffic to vllm >= 0.10.0, < 0.10.1.1, and to whatever the next advisory names. Cost: not every endpoint exposes its version; this is an ops register, not a header check
Filter model output to protect the engine from itselfWrong order. The engine parses before the gateway sees anything. The engine’s protection is a VM, a VLAN and a patch cadence, none of which live in your request path

The smallest useful version of the first four, in TypeScript, is short enough to read on one screen. It is illustrative, not a product configuration:

// Validate an OpenAI-shaped tool call against the schema the *request* declared.
// Runs downstream of the engine, after it has produced JSON — hygiene only.
import Ajv, { type ValidateFunction } from "ajv";
const ajv = new Ajv({ allErrors: false, strict: false });
// Keyed by schema content, not tool name (two requests may declare different
// "search" schemas). Bounded; use a real LRU in production.
const compiled = new Map<string, ValidateFunction>();

const LIMITS = { argBytes: 64_000, callBytes: 256_000, callsPerResponse: 16 };
// Native markup that should never survive a correct upstream parse.
const LEAK = /<tool_call>|<\/?function[=>]|<\/?parameter[=>]/;

type Tool = { function: { name: string; parameters?: object } };
type ToolCall = { function: { name: string; arguments: string } };

export function checkToolCalls(tools: Tool[], calls: ToolCall[]): string | null {
  if (calls.length > LIMITS.callsPerResponse) return "too many tool calls";
  const byName = new Map(tools.map((t) => [t.function.name, t]));
  for (const c of calls) {
    const tool = byName.get(c.function.name);
    if (!tool) return `unknown tool ${c.function.name}`;          // not in the request
    if (Buffer.byteLength(c.function.arguments) > LIMITS.callBytes) return "arguments too large";
    if (LEAK.test(c.function.arguments)) return "raw parser markup in arguments";
    let args: unknown;
    try { args = JSON.parse(c.function.arguments); } catch { return "arguments not JSON"; }
    if (typeof args !== "object" || args === null || Array.isArray(args)) return "arguments not an object";
    for (const v of Object.values(args as Record<string, unknown>)) {
      const bytes = Buffer.byteLength(typeof v === "string" ? v : JSON.stringify(v));
      if (bytes > LIMITS.argBytes) return "argument too large";   // nested values included
    }
    const schema = tool.function.parameters ?? { type: "object" };
    const key = JSON.stringify(schema);
    let validate = compiled.get(key);
    if (!validate) {
      if (compiled.size >= 1024) compiled.clear();
      compiled.set(key, (validate = ajv.compile(schema)));
    }
    if (!validate(args)) return `schema violation in ${c.function.name}`;
  }
  return null;                                                   // forward it
}

Three notes on what this does not do. It does not know whether the engine that produced these calls ran eval on the way; nothing at this position can. It does not repair anything; a gateway that rewrites tool arguments is a second parser with the same problem class as the first, so this one only says yes or no. And it trusts the schema more than it should: Ajv compiles a schema into generated JavaScript, and on a multi-tenant gateway that schema arrives from the client. Cap schema size, reject pattern (ReDoS) and remote $ref, put a timeout on compile, or use a validator that interprets instead of generating code. A validator is also a program that runs on someone else’s input.

One more cost the table hides. Everything above assumes a complete arguments string. Under streaming, tool_calls[i].function.arguments arrives in fragments across SSE events, so a gateway has to buffer each tool call by index until it closes, validate, then release or reject the whole call. Text before the first tool call can still stream; the tool-call segment cannot. Its time to first byte becomes the full generation time of the call, and the gateway holds up to callBytes × callsPerResponse per in-flight response until then. Validating after you have already forwarded the fragments is a log entry, not a check.

Go check three things this week

First, find out which tool parser your self-hosted engines run and which path it is on. The parser is only in the loop if the server was started with --enable-auto-tool-choice and a --tool-call-parser; if your harness parses the raw text itself, the parse runs on a machine you own and this section does not apply. Otherwise, ask the package you actually installed:

pip show vllm | grep ^Version
V=$(python -c 'import vllm, os; print(os.path.dirname(vllm.__file__))')
grep -rn 'eval(' "$V/tool_parsers" "$V/entrypoints/openai/tool_parsers" "$V/parser" 2>/dev/null

A bare eval( in the file named by your --tool-call-parser is the 2025 incident path; literal_eval is the crash-class path; no hit means string-only. For qwen3_coder the version table above gives the same answer without grepping. A version anywhere in 0.10.00.10.1 with that parser enabled is not a hardening task, it is an incident, and if you cannot upgrade, stop passing --tool-call-parser and parse in the harness until you can. --reasoning-parser is the sibling flag with the same shape and no CVE yet.

Second, count the array parameters and the parameters with a missing or unrecognised type in your tool schemas. In 2025 those were the door. They are also the ones a schema check downstream can do the most for, because a list of file paths has a shape and a model-generated string that is not a list of file paths does not match it.

Then look at whether your engine and your harness share a machine, a network, or a set of credentials. angry_octet’s deployment (engine on its own VM, own VLAN, logs out and nothing in) is the reference answer, and its cost is one more VM, a firewall policy somebody has to own, and a slower path to the GPU when something breaks at 3 a.m. That is cheaper than what the parser was doing for 29 days.

The parser is a program that runs on input the model chose. Treat it the way you treat a PDF renderer: patched on a schedule, isolated by default, and never the thing standing between an attacker and root.