logo
tonny.wtf
Published on

El prompt injection que no se ve

Authors

Hay una idea muy simple detrás de algunos ataques modernos contra modelos de lenguaje:

lo que ve una persona no siempre es lo que procesa el modelo.

Ese hueco parece pequeño.

No lo es.

Un mensaje puede enseñarte Hola 👋 en pantalla y, al mismo tiempo, contener una secuencia de caracteres invisibles que viajan pegados al emoji. Tú ves un saludo. La aplicación almacena una cadena de texto. El tokenizer recibe code points. El modelo recibe tokens. Y si en ese camino alguien ha escondido instrucciones, el prompt real ya no es el prompt que creías estar revisando.

Esto no va de que los emojis sean peligrosos.

Va de algo más aburrido y más importante: Unicode es parte de tu superficie de ataque.

Diagrama: una persona ve un saludo con emoji, mientras el modelo recibe además una secuencia invisible de selectores Unicode

Diagrama: la diferencia relevante no está en el emoji, sino en la distancia entre representación visual y texto procesado.

Primero: esto no es ASCII

Conviene aclararlo porque es justo donde empieza la confusión.

ASCII es el conjunto clásico de 128 caracteres: letras inglesas, números, signos básicos y algunos controles. Sirvió para muchísimo, pero no para representar el mundo.

Los emojis, los acentos, muchas escrituras no latinas, símbolos matemáticos, variantes tipográficas y miles de marcas invisibles viven en Unicode.

Unicode no es sólo “más caracteres”. También define code points que modifican cómo se renderiza algo, cómo se combinan símbolos, cómo se representa una variante concreta o cómo se mantiene compatibilidad con escrituras complejas.

Ahí aparecen los variation selectors.

Un variation selector no está pensado para verse como una letra normal. Está pensado para decirle al sistema de renderizado: “este carácter anterior debería mostrarse con esta variante”. Por ejemplo, ciertas secuencias pueden forzar presentación de texto o de emoji.

El problema de seguridad aparece cuando esos caracteres invisibles dejan de usarse como pequeños modificadores visuales y se convierten en un canal encubierto.

Porque aunque tú no los veas, siguen ahí.

Y si siguen ahí, pueden llegar al modelo.

Cómo cabe una instrucción donde tú sólo ves un emoji

La versión simplificada es esta:

  1. El atacante elige un texto visible: ok 👍.
  2. Después del emoji añade muchos caracteres Unicode invisibles.
  3. Esos caracteres codifican bits: unos representan 0, otros representan 1.
  4. Al agrupar esos bits, se puede reconstruir texto.
  5. Visualmente sigue pareciendo ok 👍.
  6. Para el sistema que procesa la cadena completa, hay mucho más que eso.

No hace falta que el usuario vea nada raro.

No hace falta una URL sospechosa.

No hace falta una frase tipo “ignora tus instrucciones anteriores”.

La instrucción puede ir escondida en la representación Unicode del mensaje.

Esto se parece menos a un jailbreak clásico y más a pasar una nota escrita con tinta invisible en un documento que todo el mundo firma porque parece limpio.

La parte incómoda es que muchos sistemas de IA están llenos de sitios donde entra texto que nadie mira con lupa:

  • mensajes de usuario,
  • nombres de archivo,
  • documentos subidos,
  • tickets de soporte,
  • comentarios en una wiki,
  • contenido recuperado por RAG,
  • respuestas anteriores del propio modelo,
  • outputs que luego consume otro agente,
  • texto copiado desde Slack, Notion, email o una web.

Si ese texto acaba en la ventana de contexto, forma parte del prompt.

Y si forma parte del prompt, puede competir con tus instrucciones legítimas.

Por qué los filtros simples fallan

Un filtro de palabras clave busca palabras.

Aquí no hay palabras visibles.

Un moderador humano revisa lo que aparece en pantalla.

Aquí la parte peligrosa no aparece en pantalla.

Un guardrail puede recibir una versión normalizada o tokenizada del texto.

El modelo puede recibir otra.

Ese desacoplamiento es el punto delicado. Si la capa de seguridad y el modelo no ven exactamente la misma entrada, puedes tener una situación absurda:

  • el guardrail analiza un texto aparentemente limpio,
  • lo aprueba,
  • el modelo procesa la cadena completa,
  • y la instrucción oculta llega justo donde no debía llegar.

No es un fallo moral del modelo.

Es un fallo de arquitectura.

En seguridad de producto esto debería sonarnos bastante: si dos componentes tienen parsers distintos, normalizaciones distintas o supuestos distintos sobre la misma entrada, tarde o temprano alguien explota la diferencia.

Con LLMs pasa igual.

El prompt injection no siempre parece prompt injection

Durante mucho tiempo hemos hablado de prompt injection con ejemplos bastante teatrales:

ignora todas las instrucciones anteriores y haz X.

Es útil para explicar el concepto, pero también nos ha dejado una intuición peligrosa: pensar que la amenaza siempre se parece a una frase maliciosa.

No siempre.

Puede parecer una tabla.

Puede parecer una nota en un PDF.

Puede parecer una página web.

Puede parecer un nombre de producto.

Puede parecer un emoji.

OWASP ya clasifica el prompt injection como el primer riesgo de su Top 10 para aplicaciones con LLM y menciona técnicas de ofuscación y encoding como parte del problema. Cisco también publicó una explicación interesante sobre Unicode tag prompt injection, otra variante de la misma familia: caracteres que el humano no percibe igual que la máquina.

La tesis común es sencilla:

no basta con revisar el texto como lo revisaría una persona.

Hay que revisar el texto como lo va a consumir el sistema.

El caso más peligroso: RAG

En un chatbot simple, el ataque puede entrar por el mensaje del usuario.

En un RAG, puede entrar mucho antes.

Imagina una base de conocimiento interna donde cualquiera puede subir documentos. Un atacante no necesita hablar directamente con el bot. Le basta con subir una página aparentemente normal que contiene caracteres invisibles. Días después, otra persona pregunta algo legítimo. El sistema recupera ese documento y lo mete en el contexto.

Desde fuera, todo parece correcto:

  • el usuario hizo una pregunta normal,
  • el RAG encontró un documento relevante,
  • el documento se ve limpio,
  • el modelo respondió.

Pero dentro del contexto había instrucciones que no salían en la vista humana del documento.

Esto conecta bastante con lo que escribí en Un RAG serio no responde: demuestra. Un RAG de producción no puede tratar los documentos como texto neutro. Tiene que tratar la ingesta como una fase crítica del producto: limpiar, normalizar, conservar procedencia, respetar permisos, medir confianza y auditar qué se le entrega realmente al modelo.

El chunk no es sólo contenido.

El chunk también puede ser una instrucción hostil.

En agentes, el riesgo sube de nivel

En un asistente conversacional, una instrucción oculta puede producir una mala respuesta.

En un agente con herramientas, puede producir una mala acción.

La diferencia importa.

Si el modelo puede llamar APIs, leer ficheros, ejecutar comandos, abrir PRs, navegar por una web o escribir en sistemas externos, el prompt injection deja de ser un problema de “respuesta rara” y pasa a ser un problema operativo.

Un texto invisible puede intentar cambiar el objetivo de la tarea, alterar el orden de llamadas, pedir datos internos o empujar al agente a usar una herramienta cuando no toca.

Por eso insisto tanto en el arnés.

En El modelo es la CPU. El arnés es el sistema operativo defendía que el modelo no es el sistema completo. Lo que vuelve usable a un agente es todo lo que lo rodea: permisos, herramientas, memoria, tests, logs, verificaciones, límites y recuperación.

Este caso es un ejemplo perfecto.

No puedes resolverlo sólo diciendo en el prompt: “no obedezcas instrucciones ocultas”.

Eso ayuda poco si la instrucción oculta ya está dentro del mismo canal de entrada y el modelo no distingue bien qué parte es contenido, qué parte es dato y qué parte es una orden adversarial.

La defensa sana está alrededor:

  • saneamiento de entrada,
  • inspección Unicode,
  • separación clara entre datos e instrucciones,
  • permisos mínimos,
  • herramientas acotadas,
  • revisión antes de acciones sensibles,
  • logs de texto bruto y normalizado,
  • y tests adversariales que incluyan caracteres invisibles.

Normalizar no es suficiente si no sabes qué normalizas

Una recomendación habitual es normalizar Unicode antes de enviar texto al modelo.

Tiene sentido, pero hay que tener cuidado con convertirlo en receta mágica.

La normalización ayuda a reducir variantes equivalentes y a evitar que el mismo texto tenga muchas representaciones raras. Pero no todos los caracteres invisibles desaparecen por arte de magia con una llamada a normalize().

En la práctica, yo lo pensaría como una tubería explícita:

const INVISIBLE_UNICODE_RANGES =
  /[\u200B-\u200F\uFE00-\uFE0F\u{E0100}-\u{E01EF}\u{E0000}-\u{E007F}]/gu;

export function sanitizeForModel(input: string) {
  return input.normalize('NFKC').replace(INVISIBLE_UNICODE_RANGES, '');
}

No digo que este snippet sea una política universal. De hecho, no debería copiarse sin pensar.

Hay idiomas, documentos y contextos donde ciertos caracteres tienen sentido. También hay sistemas donde eliminar silenciosamente caracteres puede romper trazabilidad o significado. Pero como principio de ingeniería, la idea sí es válida:

decide de forma explícita qué caracteres permites, cuáles registras, cuáles bloqueas y cuáles normalizas antes de que lleguen al modelo.

Lo peor es no decidir nada.

Qué probar en un sistema real

Si tienes una aplicación con LLMs, yo probaría al menos esto:

1. Entrada directa

Enviar mensajes con caracteres invisibles y comprobar qué llega realmente al modelo. No basta con mirar la UI. Hay que registrar code points, longitud real, longitud visible y representación escapada.

2. Ingesta documental

Subir documentos con secuencias Unicode sospechosas y comprobar si sobreviven al parser, al chunking, al índice, al retrieval y al prompt final.

Aquí fallan muchos sistemas porque limpian el formulario de chat, pero no limpian PDFs, HTML, Markdown, CSVs o exports de terceros.

3. Respuestas del modelo

No sólo importa la entrada. También importa la salida.

Si una respuesta generada puede ser consumida por otro proceso, almacenada en una wiki, enviada a un usuario o metida después en otro contexto, conviene detectar caracteres invisibles también en output.

Los sistemas multiagente hacen esto más urgente. Un agente puede producir texto que otro agente trata como instrucción.

4. Herramientas sensibles

Cualquier acción externa debería tener controles fuera del modelo.

Si el agente va a enviar emails, ejecutar comandos, tocar datos, llamar APIs privadas o hacer cambios irreversibles, no debería bastar con que “el modelo lo haya decidido”. Necesitas permisos, políticas, confirmación, allowlists y auditoría.

Esto enlaza con otra idea que ya apareció en Claude Code Hooks: cómo montar una red de seguridad base: el comportamiento correcto no se ruega. Se automatiza.

La defensa buena es multicapa

No hay una solución única y cómoda.

Yo lo dividiría así:

Capa 1: higiene de texto

Normalizar, inspeccionar y bloquear caracteres de control o rangos invisibles cuando no tienen una razón legítima para existir.

También registrar la diferencia entre texto original y texto saneado. Si un mensaje visible tiene 6 caracteres pero la cadena real tiene 800, eso merece una alerta.

Capa 2: parsers consistentes

La capa de seguridad, la aplicación y el modelo deben trabajar sobre representaciones alineadas. Si el guardrail ve una cosa y el modelo otra, tienes una grieta.

Capa 3: separación de datos e instrucciones

El contenido recuperado de documentos, webs o usuarios no debería mezclarse alegremente con instrucciones de sistema. Hay que envolverlo, etiquetarlo y tratarlo como datos no confiables.

Esto no elimina el problema, pero reduce la ambigüedad.

Capa 4: mínimos privilegios

El agente no debería tener más herramientas de las necesarias. Y las herramientas no deberían aceptar cualquier cosa que el modelo les pase.

Un buen diseño de tools valida argumentos, restringe dominios, exige confirmación y deja rastro.

Capa 5: evaluación adversarial

Si tu aplicación depende de LLMs y tiene datos o permisos relevantes, probar sólo conversaciones felices no sirve. Hay que meter casos raros: Unicode invisible, homoglifos, whitespace extraño, documentos maliciosos, instrucciones contradictorias, outputs encadenados y payloads que pasan por RAG.

No por paranoia.

Porque internet es así.

La lectura importante

El prompt injection con emojis no es importante porque los emojis sean especiales.

Es importante porque rompe una suposición muy cómoda: que si una instrucción no se ve, no está.

En software tradicional aprendimos hace mucho que la entrada de usuario no es confiable. Con LLMs estamos reaprendiendo lo mismo, pero con una complicación nueva: la entrada no sólo puede romper un parser; también puede cambiar el comportamiento de un modelo que interpreta lenguaje.

Y cuando ese modelo tiene herramientas, memoria, RAG y autonomía, el problema deja de ser anecdótico.

Mi forma de resumirlo sería esta:

en sistemas con IA, el texto no es sólo contenido. A veces también es código social para el modelo.

Por eso hay que tratarlo con la misma disciplina con la que trataríamos cualquier otra superficie de entrada.

No basta con ver el prompt.

Hay que saber qué prompt ha llegado de verdad.