Un enlace compartido de ChatGPT podía ejecutar código en tu sesión y leer conversaciones privadas: la cadena que cruzó el sandbox de OpenAI

Un enlace público de ChatGPT podía ejecutar código en la sesión de quien lo abría y leer conversaciones privadas. La cadena: MathLive, postMessage sin validar el origen y un JSONP de YouTube.

El 26 de octubre OpenAI cerró, sin anunciarlo, una vulnerabilidad que permite convertir un enlace público de ChatGPT en código arbitrario ejecutándose con la identidad de quien lo abría. Parameter, la firma de seguridad que encontró el fallo, lo explica sin rodeos: un atacante podía crear una conversación, publicarla con la función de compartir de ChatGPT y enviar el enlace a otra persona. Cuando la víctima lo abría mientras tenía la sesión iniciada, el código se ejecutaba como chatgpt.com, con acceso a su sesión autenticada.

Lo que demostraron los investigadores no fue un aviso decorativo. El código inyectado enumeró la lista de conversaciones de la víctima, abrió una conversación privada que nunca se había compartido con nadie y mandó una parte de su contenido a un servidor controlado por el atacante. En palabras de Parameter: esencialmente se podía ejecutar cualquier acción en nombre de la cuenta de ChatGPT de la víctima. La víctima no tuvo que instalar nada, ni unirse a un espacio de trabajo, ni iniciar sesión en una página falsa: abrir el enlace bastaba.

Lo más incómodo del caso es dónde estaba el fallo. No estaba en el modelo, ni en el prompt, ni en la función de compartir. Estaba en tres decisiones de diseño, cada una razonable por separado, que juntas formaban un camino de vuelta a la página principal. Este post es el recorrido completo por ese camino, con la cronología, el código relevante y lo que hay que revisar en nuestros propios front-ends de IA.

Dos features que por separado parecían inocuas

La pregunta que abrió la investigación de Parameter fue muy simple: si el código de una vista previa está encerrado en otra web, ¿qué más puede alcanzar?

ChatGPT es capaz de convertir un bloque de código HTML en una mini página web mediante su función de vista previa, el Preview. Como esa página puede contener código arbitrario, ChatGPT la ejecuta dentro de un marco restringido, en una dirección web distinta. La vista previa puede ejecutar JavaScript, pero el navegador la trata como otro sitio: no puede leer directamente la página de chatgpt.com que la rodea.

Todo correcto hasta aquí. El aislamiento funcionaba, y los investigadores fueron explícitos al respecto: no rompieron la vista previa directamente.

El problema llegó por la otra feature de la conversación: un grafo interactivo. Al renderizarlo, ChatGPT cargó en la página principal una biblioteca de matemáticas llamada MathLive. Y cargar esa biblioteca hizo algo más que dibujar el grafo: instaló un oyente de mensajes en la página principal de chatgpt.com, encargado de procesar los mensajes relacionados con el teclado en pantalla de MathLive.

Aquí aparece la pieza clave de toda la historia. Los navegadores ofrecen una API para que dos marcos intercambien información sin poder leerse: postMessage. Es un buzón. El marco restringido no puede entrar en la página principal, pero sí puede dejar un mensaje en el buzón. Y eso es seguro solo si quien recibe el mensaje comprueba quién lo ha enviado.

MathLive no hacía esa comprobación en la ruta que interesaba. Su oyente aceptaba un mensaje que cambiaba la distribución del teclado virtual, y esa distribución podía contener HTML.

La cadena quedaba así: el grafo instaló el receptor y la vista previa aportó el remitente no confiable. Dos features que no tienen relación entre sí crearon sin querer un camino que cruzaba el sandbox.

El buzón: postMessage sin comprobar el remitente

La explotación empezó con dos mensajes bien definidos. El primero sustituía la distribución del teclado virtual por el HTML del atacante. El segundo le pedía a MathLive que mostrara ese teclado, con lo que la biblioteca renderizaba la distribución nueva dentro de la página real de ChatGPT.

El formato del mensaje era público y documentado: type igual a mathlive#virtual-keyboard-message, action igual a update-setting y una lista de distribuciones cuyo campo markup podía contener HTML sin restricciones. El segundo mensaje usaba la misma familia con action igual a show.

Dos fallos de diseño se combinaban aquí. El controlador de MathLive en el nivel superior usaba una política de origen inexistente, es decir, aceptaba mensajes de cualquier dirección web. Y tampoco exigía que el mensaje procediera de una ventana previamente autorizada: bastaba con alcanzar el oyente y conocer el formato.

El siguiente paso era el que convertía HTML en código. Al abrirse el teclado, MathLive concatenaba la distribución con una cadena HTML y la asignaba a innerHTML en el documento de ChatGPT. Por el camino pasaba por un ayudante llamado createHTML que, a primera vista, parecía sanitizar el valor. No lo hacía: createHTML marcaba la cadena como de confianza y la dejaba pasar sin cambios. Es la diferencia entre un wrapper de Trusted Types y un sanitizador de verdad. Trusted Types puede restringir quién asigna a un sumidero HTML, pero una política de identidad no convierte en seguro un texto que controla el atacante.

Con esto ya había HTML arbitrario en la página principal de ChatGPT. Todavía no había ejecución de JavaScript: los scripts insertados a través de innerHTML no se ejecutan por sí solos. Ese detalle es el que obliga a dar el siguiente salto.

De una cadena HTML a un documento con la identidad de chatgpt.com

El HTML inyectado creaba un iframe con srcdoc, la propiedad que permite que un marco embebido lleve su propio documento dentro de un atributo. MathLive insertaba ese marco en el documento principal de ChatGPT sin atributo sandbox, y ahí están las dos propiedades que lo hacen útil para un atacante.

La primera: srcdoc provoca un análisis nuevo del documento, y en ese documento un script externo sí se ejecuta. La segunda, más valiosa: como el marco lo crea la página principal de ChatGPT, el navegador le concede la identidad web de chatgpt.com. El código dentro del nuevo marco podía acceder a la página padre.

Eso es un salto de privilegio en toda regla. El marcado que había nacido aislado dentro de la vista previa terminaba en un marco que se ejecutaba como el sitio real, con su origen, sus cookies y su sesión.

La CSP: la última puerta y la que se dejó entreabierta

La política de seguridad de contenido de ChatGPT (CSP) fue el siguiente muro, y ahí frenó el ataque. La política bloqueaba el JavaScript en línea, así que el nuevo marco no podía llevar el código del atacante dentro de su propio marcado. Sin embargo, la CSP sí permitía cargar scripts de YouTube.

El punto de inflexión fue el endpoint oEmbed de YouTube, que admite un parámetro de callback con estilo JSONP. Las respuestas JSONP son JavaScript, no JSON. El endpoint intentaba rechazar un callback inválido, pero colocaba el callback en la respuesta antes de devolver el error de validación. La respuesta empezaba así:

globalThis.__marker=31337;void({ "error": { "code": 400, "message": "Invalid JSONP callback name: ..." } });

Terminar el callback con ;void convertía en inofensivo el objeto de error que YouTube añadía al final. Como YouTube ya estaba permitido por la CSP, el navegador cargó esa respuesta como script y ejecutó el callback del atacante dentro del nuevo marco. Detalles menores, pero relevantes: la respuesta llegaba con el tipo de contenido text/javascript, con nosniff y sin cabecera Cross-Origin-Resource-Policy observada.

En ese momento ya había JavaScript arbitrario ejecutándose como chatgpt.com. La vista previa original seguía aislada en su dirección web; el cruce se había producido convenciendo a un componente de confianza de la página principal para que creara un documento nuevo sin sandbox.

Parameter fue claro sobre el papel de este paso: el JSONP de YouTube fue el último recurso para superar la CSP, no la causa. Bloquear solo ese endpoint quitaría un camino, pero dejaría que una vista previa no confiable siguiera insertando HTML en ChatGPT. El equipo que responde a un informe de inyección en cliente endureciendo únicamente la CSP ha parcheado solo el último tramo.

De un fallo de laboratorio a un enlace público

La primera versión que funcionaba no era un ataque creíble. Usaba una app de ChatGPT controlada por el investigador como remitente del mensaje, así que la víctima tenía que conectarse a esa app o invocarla antes. Los investigadores probaron entonces con un adjunto HTML nativo: ChatGPT abría el adjunto dentro del mismo Preview, de modo que podía enviar los mensajes maliciosos de MathLive.

Pero al exportar la conversación como enlace público, el adjunto se mantenía y el grafo desaparecía. Y el grafo era imprescindible: era el que cargaba MathLive y el que instalaba el oyente vulnerable en la página principal. Sin grafo, el adjunto podía enviar los mensajes, pero no había nadie escuchando.

El punto de inflexión fue poner las dos piezas en una única conversación pública normal. Un grafo auténtico inicializaba MathLive en la página principal. Más adelante, una vista previa HTML escrita por el asistente enviaba los dos mensajes. El enlace /share/<id> resultante conservaba la cadena completa para cualquier otro usuario.

La entrega era exactamente la de una conversación compartida cualquiera: correo, mensaje, ticket de soporte o publicación en redes. En la reproducción más estable, la víctima abría el enlace y pulsaba una vez el botón normal de vista previa de ChatGPT. En una variante posterior, los investigadores marcaron el bloque de HTML como previsualizable para que ChatGPT lo abriera automáticamente: en esa versión, abrir el enlace era la única interacción.

La apertura automática también se reprodujo tres veces en una cuenta independiente, incluso después de una recarga completa, con el aviso apareciendo unos 4,5 segundos después de cargar. Como el grafo y la vista previa se cargan de forma independiente, el payload podía enviar su mensaje antes de que MathLive estuviera listo; el portador final resolvió esa carrera reintentando los dos mensajes durante un periodo limitado. Un guard en la página principal se aseguraba de que la prueba de conversación privada y la petición saliente solo se ejecutaran una vez.

El impacto real: no era un XSS, era la cuenta

Hasta ahí, el atacante solo podía mostrar un aviso y añadir un banner a la página. Eso demostraba dónde se ejecutaba el código, pero no por qué el fallo importaba.

El salto lo dio el hecho de que el JavaScript corría como chatgpt.com, es decir, con el mismo contexto autenticado que la aplicación web normal. El payload leyó la información de sesión que la página ya tenía disponible y la usó para llamar directamente a las APIs de conversación de ChatGPT. Primero enumeró las conversaciones recientes de la víctima; después pidió los mensajes de una conversación privada concreta.

Las peticiones que el payload hacía eran estas:

GET /backend-api/conversations?exclude_conversation_origin=tpp&expand=false&is_archived=false&is_starred=false&limit=20&order=updated&offset=0

GET /backend-api/conversations/<conversation-id>?num_turns=2&include_has_versions=true

No era scraping de lo que se veía en pantalla: era acceso directo a la API autenticada, el mismo backend que usa el frontend real. La conversación objetivo no tenía ninguna relación con el enlace malicioso y nunca se había compartido con el atacante. Solo un 403, un 404 o un 405 en la petición de detalle hacían que el código recurriera a la ruta antigua, /backend-api/conversation/<id>?include_full_conversation=true.

El marco de mismo origen leía el bloque #client-bootstrap del documento padre, extraía el token de acceso de sesión y el identificador de cuenta, y comprobaba que el identificador del token coincidiera con el de la sesión cuando ambos estaban presentes. Las peticiones salían con dos cabeceras en memoria:

Authorization: Bearer <token de la víctima>

ChatGPT-Account-ID: <cuenta de la víctima>

Una vez disponible la respuesta, exfiltrarla era trivial: el payload creaba una petición de imagen normal a un servidor controlado por el atacante. Los investigadores enviaron a propósito solo un fragmento pequeño de la respuesta, porque el código inyectado ya había recibido la respuesta autenticada completa y podía leer su contenido.

Hay un detalle que suele pasar desapercibido y que merece la pena subrayar: no hubo robo de cookies. Las cookies HttpOnly impiden que JavaScript lea el token de sesión, pero el navegador las adjunta automáticamente a las peticiones que salen del mismo origen. El atacante nunca tenait la credencial: cada petición salía del navegador de la víctima, con su dirección IP y su agente de usuario, de modo que en los registros del servidor la actividad parece la de un usuario normal navegando. Ese es el motivo por el que las herramientas de detección de extremo no marcaron nada.

El radio de impacto, por tanto, no era la conversación compartida: era el historial entero de la cuenta. En un ChatGPT de empresa eso significa código pegado, borradores de contratos, notas de incidentes y datos de clientes que nunca debieron pegarse en un chat. Una sola cadena de esa lista y una pulsación bastaban.

Cronología: trece días entre el primer hallazgo y el cierre

Parameter publicó el 28 de octubre una línea de tiempo minuciosa, y es la parte que mejor explica cómo funciona la divulgación coordinada:

  • 13 de octubre: se confirma la ruta de mensaje a HTML de MathLive contra el código de producción de ChatGPT.
  • 14 de octubre: la cadena se reproduce a través de la interfaz normal y el aviso fijo se convierte en JavaScript arbitrario.
  • 16 de octubre: se confirma la entrega mediante enlace público entre cuentas y espacios de trabajo distintos, con una pulsación en la vista previa.
  • 17 de octubre: se confirma la apertura automática de la vista previa al navegar al enlace, reproducida tras una recarga completa.
  • 18 de octubre: el informe se envía al programa de recompensas de errores de OpenAI.
  • 19 de octubre: se añade la prueba controlada de divulgación de una conversación privada.
  • 21 de octubre: OpenAI reproduce el problema y la severidad sube de P2 a P1, su categoría más grave.
  • 22 de octubre: OpenAI comunica que una versión de su frontend web ya está corregida y la segunda sigue en actualización.
  • 23 de octubre: los cambios de CSP bloquean parte de la cadena mientras queda un frontend por actualizar.
  • 26 de octubre: OpenAI confirma que ambas versiones rechazan los mensajes cross-frame del informe, da el asunto por resuelto y concede una recompensa total de 5.000 dólares.

Los números que quedan fuera del titular: no hay CVE, no hay puntuación CVSS y P1 y P2 son prioridades internas del programa de recompensas de OpenAI, no una escala pública. No se ha publicado ningún indicador de compromiso, así que la ausencia de explotación conocida no es una prueba de que no la hubiera. Parameter afirma que no detectaron uso real, y nadie ha dicho lo contrario.

Sobre el dinero: 5.000 dólares para una cadena que une dos funciones del producto, un oyente de una biblioteca de terceros, la herencia de origen de un iframe y un salto de la lista de CSP parece un precio bajo para una toma de control de sesión. Es una lectura de Parameter y de otros analistas, no un dato: el tamaño de la recompensa no es una medida de severidad y OpenAI fija sus propias tablas. Pero sí explica algo: si una cadena de este tipo se paga como un fallo de componente, es más probable que se declare y se investigue. Es un incentivo con consecuencias medibles.

Por qué cada pieza parecía razonable

Parameter cerró su informe con una frase que resume el caso entero: el fallo solo apareció cuando todas esas features se combinaron en una misma página. Repasemos qué se rompió en concreto, porque de aquí sale la lección reutilizable.

  • MathLive aceptaba un mensaje que cambiaba el estado sin probar de qué ventana venía. La práctica correcta con postMessage es verificar en el receptor tanto event.origin como event.source antes de actuar. En el lado del emisor, restringir el origen de destino protege al emisor, no al receptor. Aquí el atacante controlaba al emisor, así que solo una comprobación en el receptor podía cortar la cadena, y no había ninguna.
  • El formato del mensaje permitía HTML crudo como dato de configuración. Una distribución de teclado es un dato estructurado, no un documento. Si el HTML es inevitable, tiene que pasar por una lista blanca que excluya marcos, srcdoc, objetos y scripts.
  • Ese HTML podía crear un documento sin sandbox que heredaba la identidad de chatgpt.com. Los marcos creados dinámicamente por la página principal necesitan atributo sandbox, no solo los que estaban en el diseño original.
  • La CSP era una barrera de apoyo, no la única. Una lista blanca de script-src responde a una pregunta, si un script de ese origen es aceptable. Una vez que un script permitido se ejecuta, lo hace con el origen y los privilegios de la página que lo incrusta. YouTube no hizo nada malo: era un origen permitido que se podía doblegar para entregar código. Preguntar por cada host permitido si un atacante puede conseguir que sirva código que él influye es un ejercicio de revisión que casi nadie hace.

Y hay un quinto punto, el más importante desde el punto de vista de la arquitectura de un producto de IA: las superficies públicas o compartibles de cualquier producto hay que tratarlas como entrada controlada por el atacante, no como contenido fiable. El modelo no se manipuló en ningún momento y no se smuggleó ninguna instrucción en su contexto. Un red team centrado en el modelo no habría encontrado este fallo jamás; un XSS DOM sobre el frontend, sí.

Qué revisar en nuestros propios front-ends de IA

La lección no es solo para OpenAI. Cualquier asistente con función de compartir, previsualizar o exportar tiene el mismo patrón. La lista que se desprende del caso es concreta:

  • Las funciones de compartir y exportar como vías de entrega. Un enlace compartido llega a una sesión iniciada y aparenta ser colaboración legítima. Hay que probar qué renderiza, qué se renderiza automáticamente y qué dispara una sola pulsación.
  • Todos los oyentes de mensajes del origen principal, incluidos los que instalan bibliotecas de terceros para matemáticas, gráficos, diagramas o editores de código. Cada oyente es una frontera de autorización y necesita una lista de orígenes exacta más una comprobación de la ventana esperada antes de interpretar nada.
  • Los mensajes de configuración que transportan HTML. Deben ser valores estructurados, y si hay markup, con lista blanca.
  • Los marcos que crea la página principal. El atributo sandbox se revisa en todo lo construido dinámicamente, no solo en el diseño original.
  • La lista script-src de la CSP como camino de explotación. Para cada host permitido, la pregunta es si un atacante puede influenciarlo.
  • La prueba de impacto contra la API autenticada. Cuando el código aterriza, hay que demostrar una acción de API: enumerar el historial, leer un documento, invocar una herramienta. Un popup con un aviso infravalora el hallazgo, y fue exactamente lo que llevó a la primera clasificación como P2.

Para los asistentes internos el riesgo se multiplica por dos. Si el asistente renderiza contenido recuperado como salida enriquiada (HTML, Markdown con marcado incrustado, gráficos, vistas previas), cada documento del índice es un enlace compartido potencial, y una wiki envenenada puede hacer el trabajo de la conversación maliciosa de Parameter. Además, la sesión suele llevar el SSO de los sistemas que hay detrás del índice.

Qué hacer si usabas ChatGPT

OpenAI ya había desplegado el arreglo el 26 de octubre en ambos front-ends web y todo fue del lado del servidor. No hay nada que instalar, nada que resetear y ninguna sesión que debas revocar por este motivo en concreto. Los usuarios de planes de empresa, móvil y escritorio no están cubiertos por la investigación, que se centraba en el frontend web.

Lo que sí tiene sentido hacer, si has estado pegando material sensible en los chats:

  • Tratar los enlaces de conversación compartida como cualquier enlace de un remitente desconocido. Si el enlace llega por correo, ticket o canal interno, la pregunta no es si el dominio es de fiar, sino si el contenido lo es. La diferencia entre un enlace de phishing y este es que este funcionaba.
  • Revisar qué has pegado en los chats. El radio de impacto del fallo era el historial completo de la cuenta, no el contenido compartido. Si alguien de confianza podía leer tus conversaciones de trabajo, conviene rotar lo que se haya pegado en ellas: credenciales, claves, notas de clientes.
  • Comprobar si tienes conversaciones públicas abiertas que ya no necesitas. La función de compartir es el vector de entrega; cerrar o dejar de compartir las conversaciones que sigan accesibles reduce la superficie.
  • En entornos corporativo, activar el bloqueo de compartir público y vigilar el acceso saliente. Las peticiones del atacante salían del navegador de la víctima, así que se ven como tráfico normal, pero un patrón anómalo de peticiones a /backend-api/conversations justo después de abrir un enlace compartido es un sitio razonable donde mirar.
  • Si construyes asistentes internos, revisa los oyentes de postMessage de tu front-end. Es una búsqueda concreta en el código: listeners de eventos message, y de ahí a los sumideros innerHTML, insertAdjacentHTML, srcdoc y creación dinámica de iframes.

Lo que deja el caso

La primera conclusión es que el aislamiento de un marco es tan fuerte como el puente más débil que lo cruce. La vista previa estaba bien aislada y alguien abrió una puerta lateral desde la página principal que el diseño del sandbox nunca contempló.

La segunda es que el valor de un XSS ya no lo marca la aplicación donde ocurre. Un XSS en un CRM lee los registros del CRM; un XSS en un asistente de IA lee todo lo que cada empleado le ha preguntado, más los datos y las herramientas a las que ese asistente está conectado. Cuando el código se ejecuta como chatgpt.com, su alcance no es la página: es la cuenta.

La tercera, y la más útil para un lector técnico, es que en un producto de IA hay dos superficies de ataque que se testigan con herramientas distintas. El modelo y su contexto se prueban con red teaming, jailbreaks y evaluación de prompt. La capa de renderizado que lo rodea se prueba con XSS DOM, CSP, análisis de postMessage y revisión de sandboxes. Este fallo estaba enteramente en la segunda, y ninguna prueba de modelo lo habría encontrado.

Y una cuarta, de la que la historia de la divulgación da ejemplo: Parameter no publicó hasta que el arreglo estuvo desplegado, y OpenAI cerró en ocho días. Cuando el canal de divulgación funciona, el resultado es este: una cadena de tres decisiones de diseño documentada, con un informe que explica por qué cada una parecía razonable, y un arreglo que llega antes de que el atacante tenga tiempo de usarla.

Fuentes