---
id: "GHSA-qrj2-j723-m6w8"
page: "https://infrastack.ai/live/GHSA-qrj2-j723-m6w8"
kind: "vulnerability"
aliases: ["GHSA-qrj2-j723-m6w8"]
title: "vLLM Speculators Config verifier.name_or_path Element Directory Traversal Information Disclosure Vulnerability"
severity: "medium"
cvss: 5.4
epss: null
exploited: false
status: "open"
category: "ai-tooling"
products: [{"name": "vllm", "fixed": ["0.33.0"], "affected": "< 0.33.0", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["ai-tooling", "cwe-200", "cwe-22", "model-serving", "python", "self-hosted", "vllm"]
not_affected_if: ["only-trusted-local-models", "vllm-at-or-above-0.33.0"]
published_at: "2026-11-02T19:49:32+00:00"
first_seen_at: "2026-11-09T22:53:23+00:00"
updated_at: "2026-11-09T22:56:23+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/vllm-project/vllm/security/advisories/GHSA-qrj2-j723-m6w8", "first_seen_at": "2026-11-09T22:53:23+00:00"}]
exploit_sources: []
---

# vLLM Speculators Config verifier.name_or_path Element Directory Traversal Information Disclosure Vulnerability

Link for people: https://infrastack.ai/live/GHSA-qrj2-j723-m6w8 (this .md is for agents)

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.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:vllm affected < 0.33.0 fixed 0.33.0

## How to check

- Check the installed vLLM version with `pip show vllm`; versions below `0.33.0` are affected.
- Serve only models you trust: look for a `speculators_config` key in any model's `config.json` that you did not write.
- Check whether vLLM is serving models from untrusted Hub repositories, git clones or shared mounts.

## Fix

- `vllm` (pypi): move to `0.33.0` or later.

## Timeline

- 2026-11-09T22:53:23+00:00 ghsa-repo https://github.com/vllm-project/vllm/security/advisories/GHSA-qrj2-j723-m6w8

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/vllm-project/vllm/security/advisories/GHSA-qrj2-j723-m6w8

## From the sources

### Summary

`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:

```python
# 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 `isabs` rejection. An absolute path needs no traversal syntax at all. The only validation anywhere on this path checks that four keys *exist* (`vllm/transformers_utils/configs/speculators/base.py:77-94`); nothing inspects what `name_or_path` points at.

Serving 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).

### Details

### The call site

The override is not gated on `--speculative-config`; it runs for every model that is not cloud storage, before `ModelConfig` is constructed:

```python
# 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,
            ...
```

### The redirect

```python
# 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.

### The validation is key-existence only

```python
# 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.

### Impact

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.

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
