Privilege escalation

A low-privilege account quietly gains powers it was never granted — and starts acting as admin.

The idea

Privilege escalation is when an account ends up with more access than it was meant to have. Vertical escalation moves up a level — a normal user becomes an admin. Horizontal escalation stays at the same level but reaches sideways — user A reads user B’s data. Both come from the same root mistake: the authorization decision is made in the wrong place, or the server trusts something the client controls.

See it work

Press play, or step through it.

How it works

The whole incident lives in one design choice: does the server let the client decide an authorization-relevant field? The bad handler copies the request body straight onto the user, so role rides in like any other field. The fixed handler binds only an allowlist of safe fields, and gates any role change behind a server-side require_role('admin') check that the client cannot influence.

# BAD — mass assignment: client controls every field, including role
def update_profile(request, user):
    user.update(request.body)        # {name, bio, role:'admin'} all written
    return user                      # mallory is now admin

# FIXED — allowlist fields, decide authz on the server
EDITABLE = {'name', 'bio', 'avatar_url'}   # role is NOT here

def update_profile(request, user):
    fields = {k: v for k, v in request.body.items() if k in EDITABLE}
    user.update(fields)              # role can never be set this way
    return user

# Role changes live behind a separate, audited, admin-only path
def change_role(request, target_user):
    require_role(request.actor, 'admin')        # deny by default
    new_role = validate_role(request.body['role'])
    audit_log(actor=request.actor, target=target_user,
              change=new_role, approver=request.approver)
    target_user.set_role(new_role)
    return target_user

Trade-offs

ChoiceBenefitCost
Deny-by-default authzA missed check fails closed, not openEvery new privileged path needs an explicit grant
Field allowlistingClient can never bind role or is_adminYou maintain the editable-field set per model
Central policy engineOne audited place to reason about accessIndirection; a policy outage blocks everything
Scattered inline checksSimple to write the first oneEasy to forget one; no single source of truth
Audited approval for role changesEvery elevation has an owner and a recordSlower path; needs an approver workflow

Watch out for

Worked example

Mallory signs up as a normal user. The profile-edit endpoint reflects the whole request body into the model, so she sends {"name":"M","role":"admin"}. The server writes it blindly and her role flips to admin. She now reaches /admin/users and starts managing accounts. Triage catches it: a days-old, low-trust account is hitting admin-only endpoints, and the audit log shows a role change with no approver. Containment forces her role back to user, invalidates her sessions, and blocks the endpoint. The fix is structural — allowlist the editable fields and require a server-side require_role('admin') before any role change can happen.

Check yourself

Hiding the admin button in the UI is enough to prevent escalation.