VLLM · GHSA-qrj2-j723-m6w8 · AI agents & MCP
FIX → 0.33.0
vLLM trusts a path in a model repository's config.json (speculators_config) and loads the directory it names as the served model and tokenizer, with no containment check. An attacker who publishes a crafted model can make an operator's server read a directory of someone else's model and expose its tokenizer vocabulary and weights through the API.
first public Nov 2, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Any team that runs vLLM and serves models it did not fully write itself, such as public Hub checkpoints, vendor checkpoints, git-cloned directories or shared model mounts on multi-tenant hosts. Deployments that only serve models their own team built, from a closed source, are less exposed. The flaw is reached through the normal model loading path with no extra option needed.
Not affected if only-trusted-local-models, vllm-at-or-above-0.33.0
Who cares model-serving, python, self-hosted, vllm
pip show vllm; versions below 0.33.0 are affected.speculators_config key in any model's config.json that you did not write.Upgrade to 0.33.0
maybe_override_with_speculators runs unconditionally for every non-cloud-storage model (vllm/engine/arg_utils.py:2218). If the served repository's config.json carries a speculators_config key, vLLM takes speculators_config["verifier"]["name_or_path"] straight out of that artifact and assigns it to both the model and the tokenizer:
# vllm/transformers_utils/config.py:808-810
verifier_model = speculators_config["verifier"]["name_or_path"]
model = tokenizer = verifier_model
There is no containment check of any kind — no basename, no resolve() + commonpath, no relative_to/parents test, no rejection. An absolute path needs no traversal syntax at all. The only validation anywhere on this path checks that four keys (); nothing inspects what points at.
Check it with your coding agent
It reads this page and tells you if your code is affected.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
vllm (pypi): move to 0.33.0 or later.isabsvllm/transformers_utils/configs/speculators/base.py:77-94name_or_pathServing a crafted repository therefore redirects the whole served model identity — weights, config and tokenizer — to a directory the artifact names. Confirmed end-to-end against a live vllm serve: the unauthenticated POST /detokenize returns the victim directory's tokenizer vocabulary verbatim, and the victim checkpoint is what the engine loads as the primary model.
Line numbers above are at main @ e0317b28ce3ba4c9bd2ff69d885e3f34688a6d71, where no containment fix has landed on vllm/transformers_utils/config.py. No opt-in is required: trust_remote_code stays False throughout (the engine prints it at startup).
The override is not gated on --speculative-config; it runs for every model that is not cloud storage, before ModelConfig is constructed:
# vllm/engine/arg_utils.py:2218-2228
if not is_cloud_storage(self.model):
(self.model, self.tokenizer, self.speculative_config) = (
maybe_override_with_speculators(
model=self.model,
tokenizer=self.tokenizer,
revision=self.revision,
trust_remote_code=self.trust_remote_code,
...
# vllm/transformers_utils/config.py:786-810
config_dict, _ = PreTrainedConfig.get_config_dict(model, ..., **without_trust_remote_code(kwargs))
speculators_config = config_dict.get("speculators_config")
if speculators_config is None:
return model, tokenizer, vllm_speculative_config
...
# Override model and tokenizer with the verifier model from config
verifier_model = speculators_config["verifier"]["name_or_path"]
model = tokenizer = verifier_model # :810 — no containment, no isabs check
No basename, no resolve() + commonpath, no relative_to/parents test, no isabs rejection. An absolute path needs no traversal syntax. Note without_trust_remote_code(kwargs) at :786: this path deliberately reads the config without remote code, which is why the redirect needs no opt-in.
# vllm/transformers_utils/configs/speculators/base.py:77-94
spec_config = config_dict["speculators_config"]
methods = spec_config["proposal_methods"]
first_method = methods[0]
_ = first_method["speculative_tokens"]
_ = spec_config["verifier"]["name_or_path"] # presence only
_ = config_dict["speculators_model_type"]
except (KeyError, IndexError, TypeError) as e:
raise ValueError("Invalid speculators config structure") from e
if "transformer_layer_config" not in config_dict:
raise ValueError("Must provide transformer_layer_config")
Nothing inspects what name_or_path points at. All of these keys are attacker-written; speculators_model_type must be one of the in-tree @register_speculator names (eagle3, peagle, dflash, dflash2, dspark — vllm/transformers_utils/configs/speculators/algos.py), which the attacker simply types.
An operator who serves an untrusted repository — a public Hub model, a vendor checkpoint, or a directory delivered by git clone or a shared mount — hands its publisher two things.
The victim directory's tokenizer vocabulary, verbatim, to an unauthenticated client. /detokenize returns it as text, and sweeping token ids walks the whole vocabulary. No inversion, no numerical attack, no credentials. For a checkpoint whose tokenizer is a public base this is of little value; for a domain-specific or in-house vocabulary it is the model's IP, and it also discloses the domain, the languages, the special tokens and internal naming.
Execution of the victim's checkpoint. The engine loads it as the primary model, so the attacker obtains full black-box query access to a private model through the ordinary completions API — which is the practical substance of model theft, since behavioural cloning needs no weight recovery. The operator, meanwhile, is silently serving someone else's weights under their own service.
Shared and multi-tenant model hosts are the realistic setting, since that is where the reachable directories belong to someone other than the repository's publisher.
A fix for this was introduced in https://github.com/vllm-project/vllm/pull/61587.