DAST scanner · special

Privilege Escalation

A free account should not be able to read admin tables, open admin routes, promote itself, replay an admin's writes or call server functions. The scanner logs in as a regular user and tries each of those eight things in turn. Most probes decide on the status code alone; the admin-versus-regular comparison is the one that reads the bodies.

DASTMediumHighCriticalPrivilege escalation

Eight probes, one identity

Every request carries the regular user's token. With two accounts that is User B; with one account it is User A. The probes run top to bottom and share one time budget: when it runs out the scanner stops and keeps the findings it already has instead of being cancelled and losing them.

All eight use the regular user's token 1 · Admin table read GET /rest/v1/{table}?select=id&limit=1 HIGH · 0.90 table name contains admin, roles, permissions, settings, config, system… success: 200 or 201 and the body is not [] 2 · Role self-elevation PATCH /rest/v1/{table}?user_id=eq.{me} {"role":"admin"} CRITICAL · 0.95 table name contains role or permission success: 200 or 204, one finding per accepted field 3 · Admin route GET {endpoint url} HIGH · 0.90 path contains /admin, /api/admin, /settings, /config or /system success: 200 or 201 4 · Protected endpoint GET {endpoint url} (POST/PUT/PATCH send body {}) MEDIUM · 0.75 needs a login and is not an admin path success: 200 or 201 5 · Admin vs regular GET {endpoint url} as User A, then as User B HIGH · 0.80 login-protected GETs; needs two sessions success: admin body is 1.5x larger with more records 6 · Mutation replay {captured method} {captured url} + captured body HIGH · 0.85 writes User A made that got 200, 201 or 204; first 20 success: 200, 201 or 204 7 · Object-level write PATCH /rest/v1/{table}?id=eq.{A's row id} body {} CRITICAL · 0.90 row ids User A owns; plain tables only; first 15 success: 200 or 204 8 · RPC call POST /rest/v1/rpc/{function} body {} HIGH · 0.80 function names found in the JavaScript bundle success: 200 or 201

1Eight probes, each with its own success rule. The right-hand column is the whole decision for most of them: a status code. Tests 1, 2, 7 and 8 also need a Supabase URL and anon key, because they talk to the REST layer directly.

2Tables are chosen by name. A table that looks like admin data is read with select=id&limit=1. A table with role or permission in its name gets five PATCH bodies against the user's own row: role=admin, is_admin, is_verified, plan=enterprise, permissions=all.

3Endpoints are chosen by path. Anything that looks like an admin route is requested directly. Every other login-protected endpoint is requested too, at lower severity, because a plain 200 for a regular user is only a hint that nothing checks the role.

4The only probe that compares bodies. It fetches the same URL with both tokens and asks whether the admin's response is a bigger list of records. The next scene walks through it.

5Replay. Writes that User A made during the crawl are sent again with the regular user's token. If the server accepts them, the endpoint never checked who was calling. Up to 20 are replayed.

6Row writes and RPC. The row probe patches User A's real rows with an empty body so nothing changes. The RPC probe posts {} to every function name found in the bundle. Both need the Supabase URL and anon key.

Admin versus regular: four gates before a finding

A bigger response is not proof by itself. A page can be longer because of formatting, a timestamp or a verbose error. The scanner only reports when the admin's response is both larger and contains more records than the regular user's.

Scanner GET /api/orders same URL, two tokens User A token (admin side) request in flight… 200 · 4800 B 200 · 900 B 12 records 1 record records = list length, or the list under data / result / items User B token (regular) request in flight… 200 · 1900 B 200 · 500 B 3 records 1 record 1 · both answer 200 or 201 pass A: 200 B: 200 2 · both bodies over 50 bytes pass 4800 B and 1900 B 900 B and 500 B 3 · admin size over 1.5 × regular pass 4800 > 1.5 × 1900 = 2850 900 > 1.5 × 500 = 750 4 · admin records > regular > 0 pass12 > 3, and 3 > 0 fail1 > 1 is false Finding · HIGH · confidence 0.80 Admin (12 records, 4800B) vs Regular (3 records, 1900B) No finding The admin page is only bigger. Both users see one record, so size alone is not treated as extra data.

1One URL, two tokens. For every login-protected GET endpoint the scanner fetches the URL with User A's token, then with User B's. The scanner is handed User A as the admin side and does not verify that A really is an admin.

2Status and size floor. If either response is not 200 or 201 the pair is skipped. If either body is 50 bytes or less it is skipped too: an error page or an empty reply proves nothing.

3Size gate. The admin body must be more than 1.5 times the regular body. The 1.5 factor is the constant DIFFERENTIAL_SIZE_RATIO.

4Record gate. Records are counted as the length of a top-level list, or of the list under data, result, results or items; any other JSON object counts as 1. The admin count must be greater than the regular count, and the regular count must be above zero.

5All four pass. The finding records both sizes and counts as evidence, so the reader can see exactly what the admin got that the regular user did not.

6The false-positive guard. Here the admin page is 900 bytes against 500, so the size gate passes, but both users get one record. Gate 4 fails and nothing is reported.

The three probes that write

Tests 2, 6 and 7 change state or try to. They differ in how much they risk: the role probe really sends a role value to the user's own row, the replay sends what was captured, and the row probe sends an empty patch.

2 · Role self-elevation table name has role or permission PATCH /rest/v1/{table} ?user_id=eq.{my id} five bodies, one request each: {"role": "admin"} {"is_admin": "true"} {"is_verified": "true"} {"plan": "enterprise"} {"permissions": "all"} 200 or 204 means success CRITICAL · 0.95 6 · Mutation replay writes User A made while crawling {method} {captured url} body: the captured body kept if captured with 200, 201, 204 first 20 are replayed as User B for example DELETE /api/projects/9 Authorization: User B 200, 201 or 204 means success HIGH · 0.85 7 · Object-level write rows User A owns, plain tables PATCH /rest/v1/{table} ?id=eq.{A's row id} the body is empty, so nothing changes: {} Prefer: return=minimal first 15 rows across all tables 200 or 204 means success CRITICAL · 0.90 All three decide on the status code The scanner does not read the row back to confirm the value changed, and it does not inspect the response body.

1Role self-elevation writes a real value. For each of the five fields it PATCHes the user's own row. The PATCH asks for the updated row back; only a returned row is a Critical finding for that field (a bare 204 or an empty list means RLS filtered the row out). This is the one probe that can actually promote the test account, so run it against accounts you can reset.

2Replay uses what the crawl captured. Only POST, PUT, PATCH and DELETE requests that succeeded for User A are kept. They go out again with the regular token and the captured body. A success means the endpoint never checked the caller's role.

3The row probe is designed to be harmless. It targets rows User A owns, sends {} with Prefer: return=minimal, and stops after 15 rows. If User B's token is accepted for A's row, row-level security is not protecting it.

4Status only. None of the three confirms the change by reading the row afterwards, so each finding says the server accepted the request, not that data changed.

Why it matters

Why it matters

Privilege escalation turns a $0 account into full admin access. Admin endpoints typically return every record, so one missing role check exposes all user data. Beyond reading, an attacker can change app settings, disable security features, delete anything and create new admin accounts to keep access.

Real-world breach

Microsoft Exchange, ProxyLogon (2021). CVE-2021-26855 allowed attackers to authenticate as the Exchange server, escalate to admin and install web shells. Tens of thousands of organisations worldwide were affected.

Vulnerable vs fixed code

The vulnerable handler authenticates the caller but never asks what role they hold. The fix checks the role on every admin endpoint, and a database policy makes the same rule hold even if a route is missed.

// BAD: any logged-in user reaches the admin logic
export async function GET(request: Request) {
  const user = await getAuthUser(request)
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
  // ... admin logic runs for every authenticated user
}

Limits and what each probe needs

ProbeNeedsSuccessSeverity
1 Admin table readSupabase URL, anon key, table list200/201, body not []High · 0.90
2 Role self-elevationSupabase; the user's own id200/204Critical · 0.95
3 Admin routeEndpoint inventory200/201High · 0.90
4 Protected endpointEndpoints flagged as login-protected200/201Medium · 0.75
5 Admin vs regularTwo sessionsfour gatesHigh · 0.80
6 Mutation replayWrites captured by the authenticated crawl200/201/204High · 0.85
7 Object-level writeSupabase; row ids the crawl saw User A own200/204Critical · 0.90
8 RPC callSupabase; function names found in JS200/201High · 0.80
What the status code cannot tell

Probes 3 and 4 skip an HTML 200 unless an anonymous request to the same URL is refused, and the Supabase writes (2 and 7) need a returned row, but probes 6 and 8 still accept a 200 without reading the body (probe 1 only checks it is not an empty list). Mutation replay only runs with a distinct admin session, never replays DELETE, and is Medium unless the path looks administrative. The scanner still trusts the session roles it is given: it assumes User A is the admin side, so with two ordinary accounts the differential compares two ordinary users.