Parameter

Privilege escalation

Also known as

  • Privesc

Privilege escalation is gaining access or permissions beyond what an account was granted: reaching another user's data at the same level (horizontal) or performing actions reserved for a higher role such as admin (vertical), usually because the server does not check authorization on every request.

OWASP
A01:2021 Broken Access Control
Last reviewed

What is privilege escalation in a web application?

Privilege escalation in a web app means a logged-in user does something their role was never meant to allow. The application authenticated them correctly; it just did not check, on that specific request, whether this user may touch this object or call this function.

There are two directions:

A vertical escalation where the UI hides the admin feature but the API does not enforce it:

POST /api/orgs/org_77/members/user_a/role HTTP/1.1
Host: api.example
Authorization: Bearer <user_a member token>
Content-Type: application/json

{"role": "admin"}
HTTP/1.1 200 OK
Content-Type: application/json

{"user_id": "user_a", "org_id": "org_77", "role": "admin"}

user_a is a plain member. The endpoint checked that the token was valid and that user_a belonged to org_77, then trusted the request. The server should have returned 403.

What does it look like in practice?

Most escalation bugs fall into a small set of patterns:

  • Missing function-level checks. Admin routes protected only by the frontend: hidden buttons, an /admin path nobody links to, a GraphQL mutation left out of the member UI but present in the schema.
  • Client-controlled role fields. A signup or profile update that accepts "role", "is_admin" or "plan" in the body and binds it straight to the model. That is mass assignment, and it is one of the fastest routes to admin.
  • Object IDs without ownership checks. GET /api/projects/project_1043 returns the project to anyone authenticated. Swap the ID, get another user's data.
  • Tenant boundary gaps. Checks that confirm the user is an admin but not an admin of this tenant, so an admin in org_12 can manage org_77.
  • Inconsistent enforcement. The REST endpoint checks the role, but the bulk export, the CSV import, the websocket handler, or the older /v1/ route does not.
  • Trusted headers. Access decided by X-Forwarded-For, X-User-Role, or an internal-only header that an external caller can also set.

How do you test for privilege escalation?

Test every action with every role, and compare against what each role should be allowed to do. This is systematic work that needs authenticated test accounts at every level, ideally two per role in two separate tenants.

  1. Create the accounts. For each role (for example viewer, member, admin, owner), create user_a and user_b in org_12, plus the same set in org_77.
  2. Capture the high-privilege traffic. As the owner, exercise every feature and record each request: method, path, body, and which object IDs it references.
  3. Build the matrix. List each action as a row and each role as a column, and fill in the expected result from the product's permission documentation or the team's answer.
  4. Replay with each lower role. Swap in the lower role's session token and resend every captured request unchanged. Then resend with object IDs belonging to another user in the same tenant, then another tenant.
  5. Tamper with role-bearing input. Add role, is_admin, org_id, and plan fields to create and update requests, and try changing your own membership.
  6. Record the difference. Any response that succeeds where the matrix says it should not is a finding. Watch for 200 responses that return data, and for 403s that still perform the action.

A filled-in matrix for a small project API:

ActionViewerMemberAdminOther-tenant admin
GET projectallowallowallowdeny
PATCH project namedenyallowallowdeny
DELETE projectdenydenyallowdeny
Invite memberdenydenyallowdeny
Change member roledenydenyallowdeny
Export all projectsdenydenyallowdeny

The finding is any cell where the observed result is allow and the expected result is deny. The last column catches the tenant-boundary bugs that single-tenant testing misses. Tools such as Burp's Autorize extension automate the replay step, but building the expected matrix is manual work.

How do you fix it?

Deny by default and enforce authorization on the server, per request, with the object and the action both in the check. Centralize the decision so every route, including the old ones, goes through the same code.

In Django REST Framework, scope the queryset to the caller's tenant and gate each action on role:

from rest_framework import permissions, viewsets

class IsOrgAdminForWrites(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        membership = obj.org.memberships.filter(user=request.user).first()
        if membership is None:
            return False  # other tenant: deny
        if request.method in permissions.SAFE_METHODS:
            return True
        if view.action == "destroy":
            return membership.role == "admin"
        return membership.role in ("member", "admin")

class ProjectViewSet(viewsets.ModelViewSet):
    serializer_class = ProjectSerializer
    permission_classes = [permissions.IsAuthenticated, IsOrgAdminForWrites]

    def get_queryset(self):
        # Horizontal control: only objects in the caller's orgs are reachable
        return Project.objects.filter(org__memberships__user=self.request.user)

And in the serializer, mark role, org and similar fields read-only so they cannot be set from the request body.

What does not work: hiding buttons or routes in the frontend, unguessable IDs such as UUIDs on their own (they leak through URLs, logs, and other responses), and a WAF, which sees a well-formed request from a valid user. Keep the role-by-action matrix as an automated test suite so a new endpoint without a check fails in CI.

What about OS and cloud privilege escalation?

The same idea applies below the application: an attacker with a foothold turns limited access into higher access. MITRE ATT&CK tracks it as its own tactic, TA0004.

  • Operating systems. Misconfigured sudo rules, writable scripts run by root, SUID binaries, services running as SYSTEM or root with weak file permissions, and unpatched kernel flaws.
  • Cloud. Over-broad IAM permissions let an identity grant itself more. AWS documents the classic case with iam:PassRole: a user who can pass a role to a service can make that service act with the role's permissions, even ones the user lacks. From inside a workload, the instance metadata service hands the instance role's credentials to anything that can make a request, which is why an application bug such as SSRF often becomes cloud privilege escalation.

Written by Parameter · Last reviewed