Broken access control: vertical privilege escalation

Broken access control means the server lets a caller do something its role does not allow. In a small local API, an ordinary user called administrator functions, which is vertical privilege escalation. In vulnerable mode the user listed every account, deleted another account with a DELETE request and read the audit log by sending a forged role header. One server-side check per route and method, with deny by default, stopped all of it while every legitimate request still worked.

This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.

Broken access control: horizontal and vertical privilege escalation

Access control decides what an identity may do. Broken access control means the server fails to enforce that decision. Two directions are common. In horizontal privilege escalation, a user acts as a peer: Bob reads Alice's note, and both hold the same role. The authorization testing post and the IDOR post reproduce that case.

This post covers vertical privilege escalation. A caller with a low role performs an action reserved for a higher role, such as a normal user calling an admin function. OWASP names the API version of this flaw Broken Function Level Authorization, API5:2023. It asks whether a regular user can reach administrative endpoints, and whether changing the HTTP method from GET to DELETE gets past a check.

We cite the OWASP Top 10 2025, A01 Broken Access Control. The OWASP project page lists the 2025 edition as the most current release, and the 2021 edition is the previous one. A01 says access control is only effective in trusted server-side code where the attacker cannot change the check or its metadata, and that everything except public resources should be denied by default.

Three vertical flaws in one fixture

The fixture is a toy API with two sessions held on the server: a user and an admin. Four synthetic accounts exist. Vulnerable mode contains three separate mistakes of a common kind.

  • Admin function hidden only in the UI. GET /api/me returns a menu. The admin gets "Users" and "Audit log"; the user does not. GET /api/admin/users has no server check, so the missing menu entry protects nothing.
  • Role taken from the client. GET /api/admin/audit-log reads the role from an X-Role request header and falls back to the session role. A user who sends X-Role: admin becomes an admin for that route.
  • Method gap. GET /api/admin/accounts/{id} checks the role. DELETE on the same path was added later and has no check.

The same rules as the model post apply. Two short-lived servers bind 127.0.0.1 on free ports. The tokens demo-user-token and demo-admin-token are public test strings. Requests use GET and DELETE on five paths, and the data is synthetic and in memory. For a real system, agree hosts, accounts, records, methods and stopping conditions with the owner first. The Acceptable Use Policy describes authorized use, and this article gives no permission to test another system.

Run the downloadable fixture

Download fixture.py and verify.py into one directory. You need Python 3.9 or later and curl. Read the source, then run:

python3 verify.py --output rerun

The output directory must be new. The runner starts the vulnerable server, sends 23 curl requests, stops it, then repeats the same 23 requests against the fixed server. Each request bypasses proxies, has a one-second connect timeout and a three-second total timeout, and does not follow redirects.

Our recorded run on 10 November 2026 used fixture and runner version 1.0.0, Python 3.15.2 and curl 8.7.1 on macOS ARM64. It exited with {"checks": 46, "passed": true, "boundary_failures_vulnerable": 8, "boundary_failures_fixed": 0, "failures": []}. The runner passes when every response matches the expected result for its mode, including the intentionally vulnerable responses. The two boundary counts record how many requests broke the intended policy. Versions, UTC times, ports and source hashes are in results.json.

Role by route by method results

We sent each request as four callers: anonymous, the user, the user with a forged X-Role: admin header, and the admin. Each cell shows the status code in vulnerable mode, then in fixed mode. Bold marks a vulnerable response that breaks the policy. The fixed column shows the intended policy.

Status codes from 40 matrix requests: vulnerable mode / fixed mode
Route and methodAnonymousUserUser with forged X-Role: adminAdmin
GET /api/me401 / 401200 / 200200 / 200200 / 200
GET /api/admin/users401 / 401200 / 403200 / 403200 / 200
GET /api/admin/audit-log401 / 401403 / 403200 / 403200 / 200
GET /api/admin/accounts/{id}401 / 401403 / 403403 / 403200 / 200
DELETE /api/admin/accounts/{id}401 / 401204 / 403204 / 403204 / 204

Each caller deleted a different account (acct-7 for anonymous through acct-10 for the admin), so one delete could not hide the next. Three more requests give controls and impact. After the user's DELETE, an admin read of acct-8 returned 404 in vulnerable mode, so the account was gone, and 200 in fixed mode. The route /api/admin/debug exists in the code, but its policy entry names no role. Vulnerable mode returned 200 to both the user and the admin. Fixed mode returned 403 to both. With deny by default, a route stays closed until someone assigns it a role.

The positive controls show that the fix did not break the product. The admin received 200 or 204 on every matrix route in both modes, and the user kept access to /api/me. A server that refuses everyone would also pass the denial checks. Anonymous callers got 401 in both modes, so authentication worked. The fault was in the role decision after it.

Verify a vertical escalation before you report it

A 200 status alone proves little, because some APIs answer 200 with an error body or a login page. Confirm the response holds data that only the higher role should see. The forged-header request against port 53441 in the vulnerable run was:

curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request GET \
  --header 'Authorization: Bearer demo-user-token' \
  --header 'X-Role: admin' \
  http://127.0.0.1:53441/api/admin/audit-log

The recorded response shows the status and body below, with headers omitted. The same request without the header returned 403 in the run, which isolates the header as the cause.

HTTP/1.0 200 OK

{"events": [{"at": "2026-11-10T09:00:00Z", "event": "synthetic login", "who": "admin"}]}

A usable finding records the caller's real role, the route and method, the expected denial, the observed status, the data or state change, and a baseline request with the legitimate role. For a destructive method, test on a disposable record and get written permission first. In this run the state check after the user's DELETE is the proof of impact: acct-8 returned 404 afterwards. The user never held a role that allows deletion.

Fix: one deny-by-default role check per route and method

The vulnerable mode checks roles inside each route, so every new route or method is a chance to forget one. The fixed mode replaces those checks with a single table and a single gate. The gate reads the role only from the server-side session, ignores X-Role, and denies any route and method pair that has no entry:

POLICY = {
    ("GET", "/api/me"): "user",
    ("GET", "/api/admin/users"): "admin",
    ("GET", "/api/admin/audit-log"): "admin",
    ("GET", "/api/admin/accounts/{id}"): "admin",
    ("DELETE", "/api/admin/accounts/{id}"): "admin",
    ("GET", "/api/admin/debug"): None,  # route exists, nobody wrote a policy yet
}

def policy_gate(method, key, user):
    required = POLICY.get((method, key))
    if required is None or RANK[user["role"]] < RANK[required]:
        return 403
    return None

The handler picks the check by mode before any route code runs. The second excerpt is the header-trusting branch of legacy_checks, the vulnerable route-by-route function:

if self.server.mode == "fixed":
    denied = policy_gate(method, key, user)
else:
    denied = legacy_checks(self.headers, method, key, user)
if key == "/api/admin/audit-log":
    role = headers.get("X-Role") or user["role"]  # role read from a client header
    if role != "admin":
        return 403

In the fixture, the fix is a mode switch, so treat it as a pattern, not a production patch. It removes the cause of each case. The hidden admin route needs an admin session, the header is never read, and DELETE has its own table entry. The retest repeated the same 23 requests on port 53466. Every cell in the matrix matched the policy: 0 boundary failures, with the admin and /api/me calls still succeeding.

Retest with the full matrix, since a patch that blocks X-Role on one route can leave the DELETE gap open. When you add a route, add a table row and a matrix row in the same change.

Evidence bundle

What this experiment does not cover

This is one toy process with two static sessions and a flat two-role model. The flaws are ones we wrote, so the run shows that the checks detect them and the fix closes them. It does not show how common they are. We did not test POST, PUT or PATCH, method override headers such as X-HTTP-Method-Override, path tricks like case changes or double slashes, JWT claims, cookies, groups, tenant scoping or the web UI. Real policies also depend on the object being touched, which a two-role table does not model. Related reading: mass assignment and the OWASP API Top 10.

Excession Labs checks role boundaries like these in its security assessments, using one account per role and a full matrix of routes and methods. See how a finding like this looks in a sample report.