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.
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.
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.
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.
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
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.
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
}// GOOD: Check role on every admin endpoint
export async function GET(request: Request) {
const user = await getAuthUser(request)
if (!user || user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
// ... admin logic
}-- GOOD: Use Supabase RLS for role-based access
CREATE POLICY "Admins only" ON admin_settings
FOR ALL USING (
auth.uid() IN (SELECT id FROM profiles WHERE role = 'admin')
);Limits and what each probe needs
| Probe | Needs | Success | Severity |
|---|---|---|---|
| 1 Admin table read | Supabase URL, anon key, table list | 200/201, body not [] | High · 0.90 |
| 2 Role self-elevation | Supabase; the user's own id | 200/204 | Critical · 0.95 |
| 3 Admin route | Endpoint inventory | 200/201 | High · 0.90 |
| 4 Protected endpoint | Endpoints flagged as login-protected | 200/201 | Medium · 0.75 |
| 5 Admin vs regular | Two sessions | four gates | High · 0.80 |
| 6 Mutation replay | Writes captured by the authenticated crawl | 200/201/204 | High · 0.85 |
| 7 Object-level write | Supabase; row ids the crawl saw User A own | 200/204 | Critical · 0.90 |
| 8 RPC call | Supabase; function names found in JS | 200/201 | High · 0.80 |
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.