Endurece tu servidor de inferencia

Marina Camacho9 de noviembre de 2026

Qué nos enseñan los últimos avisos de vLLM, llama.cpp y CPython a los equipos que sirven sus propios modelos, y cómo montarlo para que el próximo fallo no sea un incidente.

Cada vez más equipos sirven modelos abiertos en su propia infraestructura: por coste, por latencia o porque los datos no pueden salir de casa. La pieza que casi siempre aparece en esa arquitectura es un servidor de inferencia como vLLM o llama.cpp (llama-server), y casi siempre se despliega igual: un contenedor con GPU, --api-key puesto, y listo.

Las últimas semanas han dejado claro que eso no basta:

  • vLLM publicó el 20 de octubre un lote de avisos de seguridad corregidos en la 0.32.0, otro el 27 de octubre y tres más el 2 de noviembre, corregidos en la 0.33.0 [1][2][11]. El más serio (GHSA-6757-hggm-hp2w) permite a un cliente con acceso normal a la API ejecutar código Python en el worker, incluso con la serialización insegura desactivada [3].
  • llama.cpp recibió el 26 de octubre el CVE-2026-116816: un fichero GGUF manipulado escribe fuera de un buffer del heap con solo cargarse, antes de procesar ningún prompt [4][5].
  • RunSybil demostró el 6 de noviembre la ejecución de código en el proceso de vLLM a partir de un chat_template.jinja modificado dentro de un modelo, escapando del sandbox de Jinja gracias a un fallo de memoria de CPython [6][7].
  • Y esa misma semana, LiteLLM (el gateway del que hablamos en el segundo post de esta serie) publicó el CVE-2026-114612, en el que un usuario interno se convierte en administrador y acaba ejecutando comandos en el host [8].

Ninguno de estos fallos es exótico. Todos siguen el mismo patrón: el servidor de inferencia confía en algo que viene de fuera, ya sea una petición, un fichero de modelo o una plantilla. Este post va de cómo diseñar el despliegue para que esa confianza no sea necesaria.

Este es el cuarto post de nuestra serie sobre buenas prácticas en productos de IA. El anterior cubría el marcado de salidas sintéticas bajo el artículo 50; hoy bajamos a la capa del servidor de inferencia.

1. Tres ideas que conviene tener claras

"Autenticado" no es "de confianza". El fallo de memoria compartida de vLLM no lo explota un atacante anónimo: lo explota cualquiera con una API key válida, usando tres rutas HTTP normales [3]. Si das acceso a tu servidor a varios clientes, equipos o tenants, cada uno de ellos es un posible atacante del resto.

La API key de vLLM no protege todo el servidor. La propia documentación de seguridad de vLLM lo dice sin rodeos: --api-key solo protege los endpoints bajo /v1, /v2, /inference y /cohere. Rutas como /invocations (que sirve la misma inferencia que /v1), /pooling, /classify, /score, /rerank, /pause, /abort_requests, /update_weights o /metrics quedan abiertas aunque hayas configurado la clave. La recomendación oficial es no depender solo de --api-key y poner delante un proxy inverso que permita únicamente los endpoints que necesitas [9].

Un modelo es código, no datos. Un repositorio de modelo trae pesos, pero también config.json, tokenizador, plantilla de chat y, a veces, código Python que se ejecuta con --trust-remote-code. El aviso de vLLM del 2 de noviembre (GHSA-qrj2-j723-m6w8) muestra cómo un config.json manipulado puede redirigir qué pesos y qué tokenizador se cargan hacia otro directorio del host [10]; el de llama.cpp, cómo un GGUF corrompe memoria al cargarse [4]; y el de RunSybil, cómo una plantilla ejecuta código [6]. Descargar un modelo de un registro público equivale a ejecutar el código de un desconocido.

2. Qué ha pasado, con un poco más de detalle

vLLM: la caché multimodal y el `pickle`

Con --mm-processor-cache-type shm, vLLM comparte entre workers el preprocesado de imágenes y audio en memoria compartida. Cada elemento cacheado se identifica con un par (dirección, contador), y el worker trataba ese par como permiso para leer memoria. Una petición pública podía aportar su propio par apuntando a bytes que el atacante había dejado antes en el buffer, y el worker acababa haciendo pickle.loads() sobre ellos. El aviso documenta la cadena con tres rutas: /v1/chat/completions/render, /v1/chat/completions e /inference/v1/generate, y funciona con VLLM_ALLOW_INSECURE_SERIALIZATION=0 [3].

El detalle importante para operaciones es este: el ajuste que todas las guías de despliegue recomiendan desactivar no protegía aquí, porque el fallo estaba en un deserializador que siempre está activo [3][11]. Muchas medidas de endurecimiento protegen contra el fallo del año pasado, no contra el próximo.

El resto del lote es menos espectacular pero más frecuente: un fichero EPS etiquetado como imagen que llega a Ghostscript en el host (GHSA-h3rc-j82p-2c3h), identificadores de token fuera de vocabulario que tumban el motor con una sola petición (GHSA-q97c-p368-75c4, y GHSA-q9v9-qpjp-fc24 en el modo --skip-tokenizer-init), o peticiones con muchas imágenes remotas que agotan memoria y conexiones antes de que se compruebe la longitud del prompt (GHSA-5r3c-5x4v-8gjh) [1][12][13]. En un servidor compartido, una denegación de servicio de un cliente es una caída para todos.

llama.cpp: el modelo ataca al cargar

CVE-2026-116816 afecta a las builds de llama.cpp desde la b5899 hasta antes de la b10261. Un token de tipo BYTE con texto como <0x-1> en el tokenizador PLaMo-2 se convierte en un índice negativo y escribe fuera del buffer. VulnCheck lo puntúa con 8,5 en CVSS 4.0. El vector es local y requiere que la víctima cargue el fichero, que es exactamente lo que hace cualquiera que descarga un GGUF de un registro público [4][5].

Un detalle que dice mucho del ecosistema: la corrección se publicó en la b10261 el 4 de agosto, unas doce semanas antes del CVE [5]. Si solo reaccionáis a CVEs, llegáis tarde; y si usáis una app o un runtime que empaqueta su propio llama.cpp, la versión que importa es la que lleva dentro, no la del repositorio.

CPython y Jinja: la plantilla de chat como vector

Las plantillas de chat de Hugging Face se renderizan con el sandbox de Jinja. RunSybil encontró un fallo de memoria en el manejo de tipos genéricos de CPython que permite salir de ese sandbox, y lo demostró con un Qwen2.5-0.5B-Instruct al que le añadió un chat_template.jinja malicioso: al servirlo con vLLM, la plantilla ejecutaba código en el proceso [6][7].

El equipo de seguridad de Python no lo considera una vulnerabilidad de CPython, porque ejecutar código no confiable en un sandbox de Python no entra en su modelo de amenazas. El parche se incorporó a las ramas 3.13, 3.14 y 3.15 como corrección normal, no a la 3.12 [7]. La conclusión de los investigadores es la que nos interesa: tratad los sandboxes de plantillas como una defensa de mejor esfuerzo, no como una frontera de seguridad [7]. Esto afecta sobre todo a quien permite que sus clientes suban sus propios modelos, pero también a cualquiera que sirva un modelo sin revisar su plantilla.

3. Cómo lo montaríamos: defensa por capas

La idea de fondo es asumir que el servidor de inferencia tendrá fallos y diseñar para que un fallo no dé acceso a nada más. En la práctica, cinco capas.

3.1 Red: el servidor de inferencia no habla con el mundo

vLLM documenta que la comunicación entre nodos (PyTorch Distributed, transferencia de KV cache, paralelismo) es insegura por defecto, y que torch.distributed puede abrir un TCPStore que escucha en todas las interfaces incluso en un solo host [9]. Así que:

  • El servidor de inferencia vive en una subred privada. Nadie lo alcanza salvo el proxy o el gateway.
  • Los puertos internos (KV cache, torch.distributed, paralelismo de datos) solo son accesibles entre nodos del clúster.
  • El egress está cerrado. El servidor no necesita salir a internet en producción si los modelos ya están en local. Si sirve modelos multimodales con URLs remotas, limita los dominios con --allowed-media-domains y desactiva las redirecciones con VLLM_MEDIA_URL_ALLOW_REDIRECTS=0, que es la protección contra SSRF que recomienda vLLM [9].

En Kubernetes, algo así:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: vllm-isolation
  namespace: inference
spec:
  podSelector:
    matchLabels:
      app: vllm
  policyTypes: [Ingress, Egress]
  ingress:
    # Solo el proxy/gateway puede llamar a la API
    - from:
        - namespaceSelector:
            matchLabels: { name: llm-gateway }
          podSelector:
            matchLabels: { app: inference-proxy }
      ports:
        - port: 8000
  egress:
    # DNS interno y nada más. Sin salida a internet.
    - to:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: kube-system }
      ports:
        - { port: 53, protocol: UDP }

Si el servidor es multinodo, añade una regla de ingress y egress entre los propios pods de vLLM para los puertos internos, y ninguna más.

3.2 Proxy: lista blanca de endpoints, no lista negra

Esta es la medida con mejor relación esfuerzo/beneficio, y es la que recomienda la propia documentación de vLLM [9]. El proxy deja pasar solo lo que tu producto usa y bloquea todo lo demás, incluidos los endpoints de control y los que vLLM no autentica.

# Solo exponemos chat completions y embeddings. Todo lo demás, 404.
limit_req_zone $http_authorization zone=per_key:10m rate=10r/s;

server {
    listen 443 ssl;
    client_max_body_size 2m;          # sin cuerpos de 100 MB

    location = /v1/chat/completions {
        limit_req zone=per_key burst=20 nodelay;
        proxy_pass http://vllm.inference.svc:8000;
        proxy_read_timeout 120s;
    }

    location = /v1/embeddings {
        limit_req zone=per_key burst=20 nodelay;
        proxy_pass http://vllm.inference.svc:8000;
    }

    # /invocations, /pooling, /score, /pause, /update_weights,
    # /metrics, /render, /inference/v1/generate... no existen hacia fuera.
    location / { return 404; }
}

Dos matices:

  • /metrics es útil, pero que lo lea Prometheus por la red interna, no a través del proxy público.
  • Las rutas render e /inference/v1/generate solo existen con --enable-scale-out o --tokens-only [9]. Si no las usáis, no activéis esos modos; si las usáis, no las expongáis fuera del clúster.

Si ya tenéis un gateway de LLM delante (LiteLLM, Envoy AI Gateway, uno propio), este es su sitio natural: autenticación por cliente, límites por clave y registro. Pero recordad lo que vimos con LiteLLM esta semana: el gateway también es software con fallos, así que el servidor de inferencia tiene que estar aislado incluso del gateway más allá del puerto de la API [8].

3.3 Modelos: trátalos como código de terceros

Lo que más ha cambiado en estas semanas es esto: la cadena de suministro del modelo es tan crítica como la de los paquetes. Lo que haríamos:

  • Un registro interno de modelos. Producción solo carga modelos desde vuestro almacenamiento, nunca directamente desde Hugging Face. Con HF_HUB_OFFLINE=1 en el contenedor evitáis descargas sorpresa.
  • Revisión fijada y hashes verificados. Cada modelo aprobado tiene una revisión concreta (commit) y el hash SHA-256 de cada fichero.
  • Sin --trust-remote-code salvo que el código del repositorio se haya revisado y se fije su revisión.
  • Safetensors por defecto. Evitad formatos basados en pickle (.bin, .pt, .pth) para modelos de terceros.
  • Revisión de la plantilla de chat y de config.json. Son ficheros pequeños y legibles. Si una plantilla contiene llamadas raras o atributos con doble guion bajo, o el config.json trae claves que apuntan a rutas (como speculators_config en el aviso de vLLM [10]), no entra.

Un control de admisión sencillo, que se ejecuta en CI antes de copiar el modelo al registro interno:

"""Admisión de modelos de terceros: se ejecuta en CI antes del registro interno."""
import hashlib, json, pathlib, re, sys

PICKLE_EXT = {".bin", ".pt", ".pth", ".pkl", ".ckpt"}
SUSPICIOUS_TEMPLATE = re.compile(r"__\w+__|\bimport\b|\bos\.|\bsubprocess\b|\beval\b")
PATH_KEYS = {"speculators_config", "auto_map"}   # claves que cargan otra cosa

def sha256(p: pathlib.Path) -> str:
    h = hashlib.sha256()
    with p.open("rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()

def admit(model_dir: str, lockfile: str) -> list[str]:
    root = pathlib.Path(model_dir)
    expected = json.loads(pathlib.Path(lockfile).read_text())  # {"fichero": "sha256"}
    problems = []

    files = {str(p.relative_to(root)): p for p in root.rglob("*") if p.is_file()}
    for name, path in files.items():
        if path.suffix in PICKLE_EXT:
            problems.append(f"formato basado en pickle: {name}")
        if path.suffix == ".py":
            problems.append(f"código Python en el repo (requiere trust_remote_code): {name}")
        if name not in expected:
            problems.append(f"fichero no aprobado: {name}")
        elif sha256(path) != expected[name]:
            problems.append(f"hash distinto al aprobado: {name}")

    cfg = json.loads((root / "config.json").read_text())
    for key in PATH_KEYS & cfg.keys():
        problems.append(f"config.json contiene '{key}': revisar a mano")

    for tpl in list(root.glob("chat_template*.jinja")) + [root / "tokenizer_config.json"]:
        if tpl.exists() and SUSPICIOUS_TEMPLATE.search(tpl.read_text()):
            problems.append(f"plantilla con construcciones sospechosas: {tpl.name}")
    return problems

if __name__ == "__main__":
    issues = admit(sys.argv[1], sys.argv[2])
    for i in issues:
        print("BLOQUEADO:", i)
    sys.exit(1 if issues else 0)

No es un antivirus de modelos, y una expresión regular no detecta una plantilla bien ofuscada. Lo que hace es obligar a que cada modelo que entra en producción tenga una revisión explícita, un lockfile y una persona que lo ha aprobado. Es el mismo principio que aplicamos a las dependencias con versiones fijadas y hashes en el post sobre gateways.

3.4 Proceso: menos privilegios y menos funciones

El objetivo es que, si alguien consigue ejecutar código en el worker, se encuentre con poco:

docker run --rm \
  --gpus all \
  --read-only --tmpfs /tmp \
  --cap-drop ALL --security-opt no-new-privileges \
  --user 10001:10001 \
  --network inference-internal \
  -e HF_HUB_OFFLINE=1 \
  -e VLLM_API_KEY \
  -v /models/approved/qwen2.5-7b@<revision>:/model:ro \
  vllm/vllm-openai:<versión-con-parches> \
  --model /model \
  --host 0.0.0.0 --port 8000 \
  --max-model-len 16384

Ajustad el ejemplo a vuestra imagen y versión (algunas imágenes necesitan escribir en una caché, que podéis montar como tmpfs), pero mantened las ideas:

  • Sistema de ficheros de solo lectura y modelo montado en ro. Nada que escribir, nada que persistir.
  • Sin capacidades, sin root, sin escalada de privilegios.
  • Sin credenciales en el contenedor más allá de las que necesita. Nada de claves de nube, tokens de Hugging Face con escritura ni secretos del resto de la plataforma. En el caso de vLLM, el proceso afectado por el fallo de memoria compartida puede leer los pesos, el tokenizador, las credenciales montadas y el tráfico de inferencia [11].
  • Desactiva lo que no uses. Nunca VLLM_SERVER_DEV_MODE=1 en producción (expone /collective_rpc, que ejecuta RPC arbitrarias en el motor), ni profiler, ni --enable-tokenizer-info-endpoint si no hace falta [9]. Si no necesitas la caché multimodal en memoria compartida, no actives --mm-processor-cache-type shm; y si la necesitas, trata la actualización a 0.32.0 o posterior como un parche de seguridad [3][11]. Lo mismo vale para la carga de LoRA en caliente (VLLM_ALLOW_RUNTIME_LORA_UPDATING).

En entornos multi-tenant, el paso siguiente es separar por instancia: un servidor (o al menos un proceso) por cliente o por nivel de confianza, con un runtime de contenedores más aislado (gVisor, Kata) para modelos que suben los propios clientes. Compartir un motor entre tenants significa compartir su memoria, su caché y su disponibilidad.

3.5 Límites: una petición no puede tumbar a todos

Media docena de los avisos recientes de vLLM son de denegación de servicio con una sola petición [1][12][13]. Parte se arregla actualizando; el resto, con límites:

  • En el proxy: tamaño máximo del cuerpo, límite de peticiones por clave, timeouts.
  • En vLLM: --max-model-len acorde a vuestro caso, --limit-mm-per-prompt para el número de imágenes o audios por petición, y VLLM_MAX_N_SEQUENCES mucho más bajo que el valor por defecto de 16384 (la documentación sugiere 64 o 128 para despliegues públicos). Revisad también los límites de descarga y decodificación de medios (VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB, VLLM_MAX_MEDIA_REQUEST_DECODE_BYTES) y no los pongáis a 0 [9].
  • En la plataforma: health checks que miren la generación real y no solo /health. Uno de los avisos del 6 de octubre describe un min_tokens desmesurado que cuelga el motor mientras /health sigue en verde [12].

4. Parches: el problema no es el parche, es saber qué ejecutáis

El ritmo de avisos de seguridad en los servidores de inferencia ya es el de cualquier software expuesto: vLLM ha publicado al menos una veintena de avisos solo desde septiembre [12]. Sin un proceso, cada lote es una sorpresa. Lo que nos funciona:

  1. Inventario real. Qué versión de vLLM, llama.cpp, Python y CUDA corre en cada servicio, sacado de la imagen desplegada y no del manifiesto. Si usáis una plataforma o distribución que empaqueta vLLM, la versión que ves es la suya, no la de vLLM: pedid el commit de upstream [11].
  2. Suscripción a avisos. Los avisos de seguridad de GitHub de vllm-project/vllm y de ggml-org/llama.cpp, más vuestro escáner de dependencias sobre las imágenes (Trivy, Grype o el que uséis). Con el ejemplo de llama.cpp, la corrección estaba disponible doce semanas antes del CVE [5].
  3. Triaje por configuración, no solo por versión. Muchos fallos dependen de un flag: shm en la caché multimodal, --skip-tokenizer-init, --enable-scale-out. Guardad los flags de cada despliegue en el mismo inventario para saber en minutos si estáis expuestos.
  4. Un camino rápido a producción. Imagen reconstruida, tests de humo (incluida una batería mínima de evals para detectar cambios de comportamiento), despliegue gradual y vuelta atrás. Las versiones de vLLM traen a menudo cambios incompatibles en las peticiones, así que probad el cliente antes de desplegar [11].

Checklist: servidor de inferencia en producción

Red

  • [ ] El servidor de inferencia no es accesible desde internet; solo el proxy o gateway llega a su puerto de API.
  • [ ] Los puertos internos (KV cache, torch.distributed, paralelismo) solo son accesibles entre nodos del clúster.
  • [ ] El egress está cerrado; si hay medios remotos, --allowed-media-domains está configurado y las redirecciones desactivadas.

Exposición

  • [ ] El proxy aplica una lista blanca de endpoints y devuelve 404 para el resto.
  • [ ] /invocations, /pooling, /score, /rerank, /pause, /update_weights y las rutas render no son accesibles desde fuera.
  • [ ] VLLM_SERVER_DEV_MODE, profiler y --enable-tokenizer-info-endpoint están desactivados en producción.
  • [ ] No dependemos solo de --api-key: la autenticación y los límites por cliente están en el proxy o gateway.

Modelos

  • [ ] Producción solo carga modelos de un registro interno, con HF_HUB_OFFLINE=1.
  • [ ] Cada modelo tiene revisión fijada, lockfile de hashes y una aprobación registrada.
  • [ ] No usamos --trust-remote-code sin revisión del código; preferimos safetensors.
  • [ ] Revisamos chat_template y config.json de cada modelo antes de admitirlo.

Proceso y aislamiento

  • [ ] El contenedor corre sin root, sin capacidades, con sistema de ficheros de solo lectura y el modelo montado en ro.
  • [ ] El contenedor no tiene credenciales que no necesite.
  • [ ] Los tenants o niveles de confianza distintos no comparten motor; los modelos subidos por clientes corren en un runtime aislado.

Límites

  • [ ] Hay límites de tamaño de cuerpo, peticiones por clave y timeouts en el proxy.
  • [ ] --max-model-len, --limit-mm-per-prompt y VLLM_MAX_N_SEQUENCES están ajustados al caso de uso.
  • [ ] Los health checks comprueban generación real.

Parches

  • [ ] Sabemos qué versión de vLLM, llama.cpp y Python corre en cada servicio, sacada de la imagen desplegada.
  • [ ] Estamos suscritos a los avisos de seguridad de los proyectos que usamos y escaneamos las imágenes.
  • [ ] Ninguna build de vLLM es anterior a la 0.33.0, ni de llama.cpp anterior a la b10261.
  • [ ] Hay un camino documentado para llevar un parche a producción en días, no en semanas.

Conclusión

Servir modelos propios ya no es una curiosidad de laboratorio: es infraestructura de producción, y recibe avisos de seguridad al ritmo de cualquier servidor web. La diferencia es que muchos equipos aún lo despliegan como si fuera un experimento, con una API key y poco más.

Ninguna de las medidas de este post es nueva. Red aislada, lista blanca de endpoints, mínimo privilegio, dependencias fijadas y un proceso de parches son lo que haríamos con cualquier servicio expuesto. Lo nuevo es aceptar que el modelo también es una dependencia, y que la petición de un cliente autenticado también es entrada no confiable.

Si estáis sirviendo modelos propios y queréis revisar el despliegue con otra mirada (qué está expuesto, qué pasaría si el worker cae en malas manos y cuánto tardaríais en parchear), estaremos encantados de tener esa conversación. Sin pitch: una llamada de 30 minutos con uno de nuestros ingenieros.