A while back I found a bug that let me pull any team's private member list with a single request, then add myself to that team as an admin with one more. Full account takeover, and it worked against any account on the platform.

At its core this is an IDOR. You take an ID that points at your stuff, swap it for an ID that points at someone else's, and the server hands it over. The twist here is how I got the swap to work. The obvious version was blocked, so I had to lean on the way the API parsed its own URLs.

The setup

The product is built around teams. You sign in with your own account, and your account belongs to one or more teams. Each team has members, roles, settings, and a shared account that the team runs together. Everything team-specific lives under one REST prefix, keyed by an opaque, random-looking team ID:

HTTP
GET /api/teams/team:11111111111111111111111111111111/users HTTP/1.1
Host: app.example.com
Cookie: session=<redacted>

That first path segment, the team ID, is the security boundary. Before any team route runs, a membership filter sits in front of it and asks one question: is the caller actually a member of the team named in that first segment? If yes, you're in. If no, you get a flat refusal:

HTTP
HTTP/1.1 403 Forbidden
Content-Type: application/json

{"error":"NOT_ON_REQUIRED_TEAM"}

I checked the boundary the boring way first. My account was on exactly one team. Asking for my own team's /users gave me my roster. Asking for another team's /users directly gave me back 403 NOT_ON_REQUIRED_TEAM. So the basic access control held up. The thing you ask for is the thing you get checked against. If it had stayed that simple, there would be no post.

Two parsers, one path

A URL path looks like a single string, but it almost never is. It gets normalized, and the different pieces of a request pipeline tend to normalize it at slightly different times and in slightly different ways. The moment a security check and the application logic disagree about what a path actually points at, that disagreement becomes a weapon.

This is the same family as the classic reverse-proxy bug, where the proxy makes its allow-or-deny call on the raw path and the origin then decodes and rewrites that path into something else. It drives a lot of WAF bypasses, SSRF allowlist escapes, and request smuggling. So any time I see a path segment doing security work, I start poking at how that segment gets parsed.

This API had the split baked right in:

  • The membership filter read the raw, still-encoded path. To it, %2e%2e was just an ordinary path segment, a label like any other. It never decoded it.
  • The route dispatcher ran after the filter. It percent-decoded the path, turning %2e%2e into .., then collapsed that .. as a real parent-directory step before it picked a handler.

So the filter authorized one path and the dispatcher served a different one. The decode happened after the authorization check, not before. That ordering is the whole bug.

The pivot

If the filter only looks at the first segment, and the dispatcher will happily eat a .. out of the middle of the path, then I can write one URL that means two different things to the two halves of the system:

Text
Request:  /api/teams/team:111…/%2e%2e/team:222…/users
                     └── mine ──┘        └─ victim ┘

 membership filter (raw path, never decoded)
   first segment = team:111…   ->  I AM a member   ->  PASS

 router (decodes %2e%2e to "..", then collapses it)
   /api/teams/team:111…/../team:222…/users
   collapse "..":
   /api/teams/team:222…/users   ->  serves the VICTIM team

The first segment is my own team, so the membership check is happy. I really am a member of team:111…. Then the router resolves the .., throws my segment away, and lands on the victim's resource. The auth layer thinks I'm asking about my own team. The app serves someone else's.

Reading any team's data

I put my own team ID in the authorized slot, dropped in %2e%2e, then the victim team ID, and asked for the roster:

HTTP
GET /api/teams/team:11111111111111111111111111111111/%2e%2e/team:22222222222222222222222222222222/users HTTP/1.1
Host: app.example.com
Cookie: session=<redacted>
X-Requested-With: XMLHttpRequest
Accept: application/json
HTTP
HTTP/1.1 200 OK
Content-Type: application/json

{
  "members": [
    {
      "firstName": "<redacted>",
      "lastName": "<redacted>",
      "email": "<redacted>@victim-corp.example",
      "roles": ["admin"],
      "permissions": ["manage_users", "edit_content", "view_financials", "approve_financials"]
    }
    // every active and pending member of the victim team
  ]
}

200 OK, and there was the victim team's full member list. First names, last names, personal email addresses, roles, and permission sets for people who had nothing to do with my account.

The clean way to prove it was the encoded segment and not some fluke of my session was to run the request again without the trick:

HTTP
GET /api/teams/team:22222222222222222222222222222222/users HTTP/1.1
Host: app.example.com
Cookie: session=<redacted>

That came back 403 NOT_ON_REQUIRED_TEAM every single time. Same session, same victim, same endpoint. The only thing that flipped the response from 403 to 200 was the %2e%2e sitting in the middle. When a single encoded segment is the difference between forbidden and allowed, you've found the bug.

From reading to takeover

Reading another team's private member list is already bad. But the same trick worked on every team route, including the ones that write. The worst of those was the invite endpoint, which adds a new member to a team with whatever roles you ask for.

So instead of reading the victim's members, I added one. Me:

HTTP
POST /api/teams/team:11111111111111111111111111111111/%2e%2e/team:22222222222222222222222222222222/invites HTTP/1.1
Host: app.example.com
Cookie: session=<redacted>
Content-Type: application/json

{
  "teamId": "team:22222222222222222222222222222222",
  "email": "attacker@example.com",
  "roles": ["admin", "analytics", "editor"]
}

Same move. The filter saw my team in the first segment and waved it through. The router collapsed the .. and created the invite inside the victim's team, with the exact roles I asked for.

HTTP
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "<invite-uuid>",
  "status": "pending",
  "teamId": "team:22222222222222222222222222222222",
  "roles": ["admin", "analytics", "editor"]
}

Then the platform finished the job for me. It sent its own invitation email, from its own no-reply address, straight to my inbox. A real, legitimate-looking "you've been invited" message with a one-click accept button. I clicked it, and my account dropped right into the victim team's admin console, sitting next to the actual administrators with the admin role and everything that came with it:

  • manage_users, which lets you add and remove members, including kicking out the real admins
  • edit_content, which lets you rewrite the team's shared profile and published content
  • view_financials and approve_financials, which let you read the account's revenue numbers and approve financial actions

One free account, one POST, and I was an admin on an account I had no business touching.

Why it hit everyone

Three things turned "I can do this to one victim" into "I can do this to anybody":

  1. The victim ID was the only thing steering the response. Swap that second team ID for any other team and the whole thing repeats. There was nothing victim-specific to get past.
  2. Those opaque team IDs were not actually secret. A separate, unauthenticated endpoint leaked a team's ID inside a public profile field, no membership needed, so the full set of victim IDs was just sitting there for the taking. An unguessable ID is not an access control, and here it wasn't even unguessable.
  3. There was no rate limiting on the write path. The invite endpoint took automated requests without complaint, so the attack scaled cleanly.

Because the parser mismatch lived in the shared pipeline instead of one handler, the %2e%2e trick worked against the whole team surface. Read members, read settings, change settings, create invites, delete invites, remove members. One gap, and the entire feature set behind it was exposed.

I confirmed the read and the write, captured my evidence, then deleted the invite, removed myself from the team, and checked that the roster was back to where it started. I did not keep any of the data I could see.

The root cause

Take away the placeholders and the bug fits in one line. The security check and the application logic normalized the same input differently, and I lived in the gap between them.

The membership filter authorized a path before it had been decoded. The dispatcher decoded and rewrote that path afterward. Authorize-then-decode means the bytes the auth layer signs off on are never the bytes the app actually runs. The %2e%2e, the team prefix, the self-invite: all of it is just machinery for prying open that one gap.

It's the same shape as proxy and WAF path-normalization bypasses, double-decode bugs, SSRF allowlist escapes, and Unicode case-folding auth bypasses. And the fix is always the same shape too. Normalize once, up front, then make every security decision on that one canonical version.

How to fix it

  • Canonicalize the path once, early, before authorization. Fully resolve percent-encoding and every . and .. segment, and only then run the membership filter, so the auth layer sees the exact path the dispatcher will use. The two halves must never look at different strings.
  • Reject instead of quietly rewriting. If a decoded path still has .. or other traversal in it, return a 400 rather than collapsing it. Silently "fixing" attacker input is how the two interpretations drift apart in the first place.
  • Don't let the first path segment be the whole security story. Authorize against the resource the handler is actually going to touch after routing, not against whatever label happens to come first in the raw URL.
  • Re-check authorization at the data layer. Inside the handler, confirm the caller is a member of the resolved team before you read or write anything. The edge filter is a convenience, not your last line of defense.
  • Stop handing out internal IDs from unauthenticated endpoints. It shouldn't be load-bearing, but not giving attackers the whole victim list for free meaningfully shrinks the blast radius.

Takeaways

If you build things: any time a path segment, a header, or a parameter is doing security work, write down every component that touches and normalizes it, and in what order. If your authorization runs on a less-normalized version of the input than your business logic does, this bug is probably already sitting in your codebase. Go find it before someone else does.

If you hunt things: when an ID lives in the path and direct cross-tenant access gives you a 403, don't walk away. Put an ID you're allowed to use in the authorized slot and try to make the router resolve to one you're not. %2e%2e, double encoding, ; path params, trailing dots, mixed slashes. A clean 403 versus 200 diff that hangs on nothing but an encoding trick is about as convincing as a proof of concept gets.

Timeline

When What happened
Day 0 Found the bug and reported it privately
Day 0 Acknowledged
Day 1 Fixed (path canonicalized before the membership filter) and verified on retest
Later $50,000 awarded