Endureix el teu servidor d'inferència

Marina Camacho9 de novembre de 2026

Què ens ensenyen els últims avisos de vLLM, llama.cpp i CPython als equips que serveixen els seus propis models, i com muntar-ho perquè la propera fallada no sigui un incident.

Cada vegada més equips serveixen models oberts a la seva pròpia infraestructura: per cost, per latència o perquè les dades no poden sortir de casa. La peça que gairebé sempre apareix en aquesta arquitectura és un servidor d'inferència com vLLM o llama.cpp (llama-server), i gairebé sempre es desplega igual: un contenidor amb GPU, --api-key posada, i llest.

Les últimes setmanes han deixat clar que això no basta:

  • vLLM va publicar el 20 d'octubre un lot d'avisos de seguretat corregits en la 0.32.0, un altre el 27 d'octubre i tres més el 2 de novembre, corregits en la 0.33.0 [1][2][11]. El més seriós (GHSA-6757-hggm-hp2w) permet a un client amb accés normal a l'API executar codi Python al worker, fins i tot amb la serialització insegura desactivada [3].
  • llama.cpp va rebre el 26 d'octubre el CVE-2026-116816: un fitxer GGUF manipulat escriu fora d'un buffer del heap només en carregar-se, abans de processar cap prompt [4][5].
  • RunSybil va demostrar el 6 de novembre l'execució de codi al procés de vLLM a partir d'un chat_template.jinja modificat dins d'un model, escapant de l'entorn de proves (sandbox) de Jinja gràcies a una fallada de memòria de CPython [6][7].
  • I aquesta mateixa setmana, LiteLLM (l'intermediari del qual parlem al segon post d'aquesta sèrie) va publicar el CVE-2026-114612, en el qual un usuari intern es converteix en administrador i acaba executant comandes a l'amfitrió [8].

Cap d'aquestes fallades és exòtica. Totes segueixen el mateix patró: el servidor d'inferència confia en alguna cosa que ve de fora, ja sigui una petició, un fitxer de model o una plantilla. Aquest post va de com dissenyar el desplegament perquè aquesta confiança no sigui necessària.

Aquest és el quart post de la nostra sèrie sobre bones pràctiques en productes d'IA. L'anterior cobria el marcatge de sortides sintètiques sota l'article 50; avui baixem a la capa del servidor d'inferència.

1. Tres idees que convé tenir clares

"Autenticat" no és "de confiança". La fallada de memòria compartida de vLLM no l'explota un atacant anònim: l'explota qualsevol amb una clau d'API vàlida, utilitzant tres rutes HTTP normals [3]. Si dones accés al teu servidor a diversos clients, equips o inquidins (tenants), cadascun d'ells és un possible atacant de la resta.

La clau d'API de vLLM no protegeix tot el servidor. La pròpia documentació de seguretat de vLLM ho diu sense embuts: --api-key només protegeix els endpoints sota /v1, /v2, /inference i /cohere. Rutes com /invocations (que serveix la mateixa inferència que /v1), /pooling, /classify, /score, /rerank, /pause, /abort_requests, /update_weights o /metrics queden obertes encara que hagis configurat la clau. La recomanació oficial és no dependre només de --api-key i posar al davant un intermediari invers (reverse proxy) que permeti únicament els endpoints que necessites [9].

Un model és codi, no dades. Un repositori de model porta pesos, però també config.json, tokenitzador, plantilla de xat i, de vegades, codi Python que s'executa amb --trust-remote-code. L'avís de vLLM del 2 de novembre (GHSA-qrj2-j723-m6w8) mostra com un config.json manipulat pot redirigir quins pesos i quin tokenitzador es carreguen cap a un altre directori de l'amfitrió [10]; el de llama.cpp, com un GGUF corromp memòria en carregar-se [4]; i el de RunSybil, com una plantilla executa codi [6]. Descarregar un model d'un registre públic equival a executar el codi d'un desconegut.

2. Què ha passat, amb una mica més de detall

vLLM: la memòria cau multimodal i el `pickle`

Amb --mm-processor-cache-type shm, vLLM comparteix entre workers el preprocessament d'imatges i àudio en memòria compartida. Cada element guardat a la memòria cau s'identifica amb un parell (adreça, comptador), i el worker tractava aquest parell com a permís per llegir memòria. Una petició pública podia aportar el seu propi parell apuntant a bytes que l'atacant havia deixat abans al buffer, i el worker acabava fent pickle.loads() sobre ells. L'avís documenta la cadena amb tres rutes: /v1/chat/completions/render, /v1/chat/completions i /inference/v1/generate, i funciona amb VLLM_ALLOW_INSECURE_SERIALIZATION=0 [3].

El detall important per a operacions és aquest: l'ajust que totes les guies de desplegament recomanen desactivar no protegia aquí, perquè la fallada era en un deserialitzador que sempre està actiu [3][11]. Moltes mesures d'enduriment protegeixen contra la fallada de l'any passat, no contra la propera.

La resta del lot és menys espectacular però més freqüent: un fitxer EPS etiquetat com a imatge que arriba a Ghostscript a l'amfitrió (GHSA-h3rc-j82p-2c3h), identificadors de token fora de vocabulari que tomben el motor amb una sola petició (GHSA-q97c-p368-75c4, i GHSA-q9v9-qpjp-fc24 en el mode --skip-tokenizer-init), o peticions amb moltes imatges remotes que esgoten memòria i connexions abans que es comprovi la longitud del prompt (GHSA-5r3c-5x4v-8gjh) [1][12][13]. En un servidor compartit, una denegació de servei d'un client és una caiguda per a tothom.

llama.cpp: el model ataca en carregar

El CVE-2026-116816 afecta les compilacions (builds) de llama.cpp des de la b5899 fins abans de la b10261. Un token de tipus BYTE amb text com <0x-1> al tokenitzador PLaMo-2 es converteix en un índex negatiu i escriu fora del buffer. VulnCheck el puntua amb un 8,5 en CVSS 4.0. El vector és local i requereix que la víctima carregui el fitxer, que és exactament el que fa qualsevol que descarrega un GGUF d'un registre públic [4][5].

Un detall que diu molt de l'ecosistema: la correcció es va publicar a la b10261 el 4 d'agost, unes dotze setmanes abans del CVE [5]. Si només reaccioneu als CVE, arriveu tard; i si utilitzeu una aplicació o un entorn d'execució (runtime) que empaqueta el seu propi llama.cpp, la versió que importa és la que duu a dins, no la del repositori.

CPython i Jinja: la plantilla de xat com a vector

Les plantilles de xat de Hugging Face es renderitzen amb l'entorn de proves de Jinja. RunSybil va trobar una fallada de memòria en la gestió de tipus genèrics de CPython que permet sortir d'aquest entorn, i ho va demostrar amb un Qwen2.5-0.5B-Instruct al qual va afegir un chat_template.jinja maliciós: en servir-lo amb vLLM, la plantilla executava codi al procés [6][7].

L'equip de seguretat de Python no ho considera una vulnerabilitat de CPython, perquè executar codi no fiable en un entorn d'execució aïllat de Python no entra en el seu model d'amenaces. El pegat es va incorporar a les branques 3.13, 3.14 i 3.15 com a correcció normal, no a la 3.12 [7]. La conclusió dels investigadors és la que ens interessa: tracteu els entorns de proves de plantilles com una defensa de millor esforç, no com una frontera de seguretat [7]. Això afecta sobretot a qui permet que els seus clients pugin els seus propis models, però també a qualsevol que serveixi un model sense revisar la seva plantilla.

3. Com ho muntaríem: defensa per capes

La idea de fons és assumir que el servidor d'inferència tindrà fallades i dissenyar perquè una fallada no doni accés a res més. A la pràctica, cinc capes.

3.1 Xarxa: el servidor d'inferència no parla amb el món

vLLM documenta que la comunicació entre nodes (PyTorch Distributed, transferència de memòria cau KV, paral·lelisme) és insegura per defecte, i que torch.distributed pot obrir un TCPStore que escolta a totes les interfícies fins i tot en un sol amfitrió [9]. Així doncs:

  • El servidor d'inferència viu en una subxarxa privada. Ningú hi arriba tret de l'intermediari o la porta d'enllaç (gateway).
  • Els ports interns (memòria cau KV, torch.distributed, paral·lelisme de dades) només són accessibles entre nodes del clúster.
  • L'eixida (egress) està tancada. El servidor no necessita sortir a internet en producció si els models ja estan en local. Si serveix models multimodals amb URLs remotes, limita els dominis amb --allowed-media-domains i desactiva les redireccions amb VLLM_MEDIA_URL_ALLOW_REDIRECTS=0, que és la protecció contra SSRF que recomana vLLM [9].

A Kubernetes, una cosa així:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: vllm-isolation
  namespace: inference
spec:
  podSelector:
    matchLabels:
      app: vllm
  policyTypes: [Ingress, Egress]
  ingress:
    # Només l'intermediari/gateway pot trucar a l'API
    - from:
        - namespaceSelector:
            matchLabels: { name: llm-gateway }
          podSelector:
            matchLabels: { app: inference-proxy }
      ports:
        - port: 8000
  egress:
    # DNS intern i res més. Sense sortida a internet.
    - to:
        - namespaceSelector:
            matchLabels: { kubernetes.io/metadata.name: kube-system }
      ports:
        - { port: 53, protocol: UDP }

Si el servidor és multinode, afegeix una regla d'entrada (ingress) i sortida (egress) entre els mateixos pods de vLLM per als ports interns, i cap més.

3.2 Intermediari: llista blanca d'endpoints, no llista negra

Aquesta és la mesura amb millor relació esforç/benefici, i és la que recomana la pròpia documentació de vLLM [9]. L'intermediari deixa passar només el que el teu producte utilitza i bloqueja tot el reste, inclosos els endpoints de control i els que vLLM no autentica.

# Només exposem chat completions i embeddings. Tota la resta, 404.
limit_req_zone $http_authorization zone=per_key:10m rate=10r/s;

server {
    listen 443 ssl;
    client_max_body_size 2m;          # sense cossos 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 existeixen cap a fora.
    location / { return 404; }
}

Dos matisos:

  • /metrics és útil, però que ho llegeixi Prometheus per la xarxa interna, no a través de l'intermediari públic.
  • Les rutes render i /inference/v1/generate només existeixen amb --enable-scale-out o --tokens-only [9]. Si no les utilitzeu, no activeu aquests modes; si les utilitzeu, no les exposeu fora del clúster.

Si ja teniu un intermediari de LLMs al davant (LiteLLM, Envoy AI Gateway, un de propi), aquest és el seu lloc natural: autenticació per client, límits per clau i registre. Però recordeu el que vam veure amb LiteLLM aquesta setmana: l'intermediari també és programari amb fallades, així que el servidor d'inferència ha d'estar aïllat fins i tot de l'intermediari més enllà del port de l'API [8].

3.3 Models: tracta'ls com a codi de tercers

El que més ha canviat en aquestes setmanes és això: la cadena de subministrament del model és tan crítica com la dels paquets. El que faríem:

  • Un registre intern de models. Producció només carrega models des del vostre emmagatzematge, mai directament des de Hugging Face. Amb HF_HUB_OFFLINE=1 al contenidor eviteu descarregues sorpresa.
  • Revisió fixada i empremtes (hashes) verificades. Cada model aprovat té una revisió concreta (commit) i l'empremta SHA-256 de cada fitxer.
  • Sense --trust-remote-code tret que el codi del repositori s'hagi revisat i se'n fixi la revisió.
  • Safetensors per defecte. Eviteu formats basats en pickle (.bin, .pt, .pth) per a models de tercers.
  • Revisió de la plantilla de xat i de config.json. Són fitxers petits i llegibles. Si una plantilla conté trucades estranyes o atributs amb doble guió baix, o el config.json porta claus que apunten a rutes (com speculators_config a l'avís de vLLM [10]), no entra.

Un control d'admissió senzill, que s'executa en integració contínua (CI) abans de copiar el model al registre intern:

"""Admissió de models de tercers: s'executa en CI abans del registre intern."""
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"}   # claus que carreguen una altra 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())  # {"fitxer": "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"format basat en pickle: {name}")
        if path.suffix == ".py":
            problems.append(f"codi Python al repositori (requereix trust_remote_code): {name}")
        if name not in expected:
            problems.append(f"fitxer no aprovat: {name}")
        elif sha256(path) != expected[name]:
            problems.append(f"empremta diferent de l'aprovada: {name}")

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

    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 amb construccions sospitoses: {tpl.name}")
    return problems

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

No és un antivirus de models, i una expressió regular no detecta una plantilla ben ofuscada. El que fa és obligar que cada model que entra a producció tingui una revisió explícita, un fitxer de bloqueig (lockfile) i una persona que l'hagi aprovat. És el mateix principi que apliquem a les dependències amb versions fixades i empremtes al post sobre intermediaris.

3.4 Procés: menys privilegis i menys funcions

L'objectiu és que, si algú aconsegueix executar codi al worker, es trobi amb poca cosa:

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ó-amb-pegats> \
  --model /model \
  --host 0.0.0.0 --port 8000 \
  --max-model-len 16384

Ajusteu l'exemple a la vostra imatge i versió (algunes imatges necessiten escriure en una memòria cau, que podeu muntar com a tmpfs), però mantingueu les idees:

  • Sistema de fitxers de només lectura i model muntat en ro. Res a escriure, res a persistir.
  • Sense capacitats, sense superusuari (root), sense escalada de privilegis.
  • Sense credencials al contenidor més enllà de les que necessita. Res de claus de núvol, tokens de Hugging Face amb escriptura ni secrets de la resta de la plataforma. En el cas de vLLM, el procés afectat per la fallada de memòria compartida pot llegir els pesos, el tokenitzador, les credencials muntades i el trànsit d'inferència [11].
  • Desactiva el que no facis servir. Mai VLLM_SERVER_DEV_MODE=1 en producció (exposa /collective_rpc, que executa RPC arbitràries al motor), ni analitzador de rendiment (profiler), ni --enable-tokenizer-info-endpoint si no fa falta [9]. Si no necessites la memòria cau multimodal en memòria compartida, no activis --mm-processor-cache-type shm; i si la necessites, tracta l'actualització a la 0.32.0 o posterior com un pegat de seguretat [3][11]. El mateix val per a la càrrega de LoRA en calent (VLLM_ALLOW_RUNTIME_LORA_UPDATING).

En entorns multi-tenant, el pas següent és separar per instància: un servidor (o almenys un procés) per client o per nivell de confiança, amb un entorn d'execució de contenidors més aïllat (gVisor, Kata) per a models que pugen els mateixos clients. Compartir un motor entre inquidins significa compartir la seva memòria, la seva memòria cau i la seva disponibilitat.

3.5 Límits: una petició no pot tombar tothom

Mitja dotzena dels avisos recents de vLLM són de denegació de servei amb una sola petició [1][12][13]. Part es arregla actualitzant; la resta, amb límits:

  • A l'intermediari: mida màxima del cos, límit de peticions per clau, temps d'espera (timeouts).
  • A vLLM: --max-model-len d'acord amb el vostre cas, --limit-mm-per-prompt per al nombre d'imatges o àudios per petició, i VLLM_MAX_N_SEQUENCES molt més baix que el valor per defecte de 16384 (la documentació suggereix 64 o 128 per a desplegaments públics). Reviseu també els límits de descàrrega i descodificació de mitjans (VLLM_MAX_MEDIA_DOWNLOAD_SIZE_MB, VLLM_MAX_MEDIA_REQUEST_DECODE_BYTES) i no els poseu a 0 [9].
  • A la plataforma: proves de salut (health checks) que mirin la generació real i no només /health. Un dels avisos del 6 d'octubre descriu un min_tokens desmesurat que penja el motor mentre /health continua en verd [12].

4. Pegats: el problema no és el pegat, sinó saber què executeu

El ritme d'avisos de seguretat als servidors d'inferència ja és el de qualsevol programari exposat: vLLM ha publicat almenys una vintena d'avisos només des de setembre [12]. Sense un procés, cada lot és una sorpresa. El que ens funciona:

  1. Inventari real. Quina versió de vLLM, llama.cpp, Python i CUDA corre a cada servei, extreta de la imatge desplegada i no del manifest. Si utilitzeu una plataforma o distribució que empaqueta vLLM, la versió que veieu és la seva, no la de vLLM: demaneu el commit del projecte principal (upstream) [11].
  2. Subscripció a avisos. Els avisos de seguretat de GitHub de vllm-project/vllm i de ggml-org/llama.cpp, més el vostre escàner de dependències sobre les imatges (Trivy, Grype o el que utilitzeu). Amb l'exemple de llama.cpp, la correcció estava disponible dotze setmanes abans del CVE [5].
  3. Triatge per configuració, no només per versió. Moltes fallades depenen d'un senyalitzador (flag): shm a la memòria cau multimodal, --skip-tokenizer-init, --enable-scale-out. Guardeu els senyalitzadors de cada desplegament al mateix inventari per saber en minuts si esteu exposats.
  4. Un camí ràpid a producció. Imatge reconstruïda, proves de fum (inclosa una bateria mínima d'avaluacions per detectar canvis de comportament), desplegament gradual i reversió. Les versions de vLLM solen portar canvis incompatibles a les peticions, així que proveu el client abans de desplegar [11].

Llista de comprovació (Checklist): servidor d'inferència en producció

Xarxa

  • [ ] El servidor d'inferència no és accessible des d'internet; només l'intermediari o gateway arriba al seu port d'API.
  • [ ] Els ports interns (memòria cau KV, torch.distributed, paral·lelisme) només són accessibles entre nodes del clúster.
  • [ ] L'eixida està tancada; si hi ha mitjans remots, --allowed-media-domains està configurat i les redireccions desactivades.

Exposició

  • [ ] L'intermediari aplica una llista blanca d'endpoints i retorna 404 per a la resta.
  • [ ] /invocations, /pooling, /score, /rerank, /pause, /update_weights i les rutes render no són accessibles des de fora.
  • [ ] VLLM_SERVER_DEV_MODE, analitzador de rendiment i --enable-tokenizer-info-endpoint estan desactivats en producció.
  • [ ] No depenem només de --api-key: l'autenticació i els límits per client estan a l'intermediari o gateway.

Models

  • [ ] Producció només carrega models d'un registre intern, amb HF_HUB_OFFLINE=1.
  • [ ] Cada model té revisió fixada, fitxer de bloqueig d'empremtes i una aprovació registrada.
  • [ ] No utilitzem --trust-remote-code sense revisió del codi; preferim safetensors.
  • [ ] Reviseu chat_template i config.json de cada model abans d'admetre'l.

Procés i aïllament

  • [ ] El contenidor corre sense superusuari, sense capacitats, amb sistema de fitxers de només lectura i el model muntat en ro.
  • [ ] El contenidor no té credencials que no necessiti.
  • [ ] Els inquidins o nivells de confiança diferents no comparteixen motor; els models pujats per clients corren en un entorn d'execució aïllat.

Límits

  • [ ] Hi ha límits de mida de cos, peticions per clau i temps d'espera a l'intermediari.
  • [ ] --max-model-len, --limit-mm-per-prompt i VLLM_MAX_N_SEQUENCES estan ajustats al cas d'ús.
  • [ ] Les proves de salut comproven la generació real.

Pegats

  • [ ] Sabem quina versió de vLLM, llama.cpp i Python corre a cada servei, extreta de la imatge desplegada.
  • [ ] Estem subscrits als avisos de seguretat dels projectes que fem servir i escanegem les imatges.
  • [ ] Cap compilació de vLLM és anterior a la 0.33.0, ni de llama.cpp anterior a la b10261.
  • [ ] Hi ha un camí documentat per portar un pegat a producció en dies, no en setmanes.

Conclusió

Servir models propis ja no és una curiositat de laboratori: és infraestructura de producció, i rep avisos de seguretat al ritme de qualsevol servidor web. La diferència és que molts equips encara ho despleguen com si fos un experiment, amb una clau d'API i poca cosa més.

Cap de les mesures d'aquest post és nova. Xarxa aïllada, llista blanca d'endpoints, mínim privilegi, dependències fixades i un procés de pegats són el que faríem amb qualsevol servei exposat. El nou és acceptar que el model també és una dependència, i que la petició d'un client autenticat també és entrada no fiable.

Si esteu servint models propis i voleu revisar el desplegament amb una altra mirada (què està exposat, què passaria si el worker cau en males mans i quant afaríeu a pedanejar), estarem encantats de tenir aquesta conversa. Sense arguments de venda (pitch): una trucada de 30 minuts amb un dels nostres enginyers.