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.jinjamodificado 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-domainsy desactiva las redirecciones conVLLM_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:
/metricses útil, pero que lo lea Prometheus por la red interna, no a través del proxy público.- Las rutas
rendere/inference/v1/generatesolo existen con--enable-scale-outo--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=1en 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-codesalvo 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 elconfig.jsontrae claves que apuntan a rutas (comospeculators_configen 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=1en producción (expone/collective_rpc, que ejecuta RPC arbitrarias en el motor), ni profiler, ni--enable-tokenizer-info-endpointsi 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-lenacorde a vuestro caso,--limit-mm-per-promptpara el número de imágenes o audios por petición, yVLLM_MAX_N_SEQUENCESmucho 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 a0[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 unmin_tokensdesmesurado que cuelga el motor mientras/healthsigue 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:
- 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].
- Suscripción a avisos. Los avisos de seguridad de GitHub de
vllm-project/vllmy deggml-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]. - Triaje por configuración, no solo por versión. Muchos fallos dependen de un flag:
shmen 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. - 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-domainsestá 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_weightsy las rutasrenderno son accesibles desde fuera. - [ ]
VLLM_SERVER_DEV_MODE, profiler y--enable-tokenizer-info-endpointestá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-codesin revisión del código; preferimos safetensors. - [ ] Revisamos
chat_templateyconfig.jsonde 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-promptyVLLM_MAX_N_SEQUENCESestá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.