This example uses the shared Flask lab.
We plug IDOR in as the second web module.
Complete the Chapter III Introduction setup first. The base app.py, schema.sql, seed.sql, and database.db must already exist.
The flow is:
create modules/idor.py
register the blueprint in app.py
restart Flask
visit /idor/basic
The IDOR module provides four pages:
/idor/basic
/idor/blacklist
/idor/better
/idor/fixed
Plug In The Module
From the lab directory (disregard if you have already done this):
cd ~/ootw-web-lab
source .venv/bin/activate
mkdir -p modules
touch modules/__init__.py
Create modules/idor.py:
cat > modules/idor.py <<'EOF'
from flask import Blueprint, abort, redirect, render_template_string, request
from app import get_db
bp = Blueprint("idor", __name__, url_prefix="/idor")
CURRENT_USER_ID = 2
CURRENT_USERNAME = "operator"
BLOCKED_DOCUMENT_IDS = {"1"}
PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; line-height: 1.5; }
nav a { margin-right: 12px; }
input { padding: 6px; }
button { margin-left: 6px; padding: 6px 10px; }
article { border: 1px solid #ccc; padding: 12px; margin: 12px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
code { background: #eee; padding: 2px 4px; }
</style>
</head>
<body>
<h1>{{ title }}</h1>
<nav>
<a href="/idor/basic?id=2">basic</a>
<a href="/idor/blacklist?id=2">blacklist</a>
<a href="/idor/better?id=2">better</a>
<a href="/idor/fixed?id=2">fixed</a>
<a href="/idor/audit">audit</a>
</nav>
<p>{{ description }}</p>
<p>
Current user:
<strong>{{ current_user["username"] }}</strong>
with role
<code>{{ current_user["role"] }}</code>
</p>
<h2>Visible Documents</h2>
<table>
<tr>
<th>ID</th>
<th>Owner</th>
<th>Title</th>
</tr>
{% for item in own_documents %}
<tr>
<td>{{ item["id"] }}</td>
<td>{{ item["owner_username"] }}</td>
<td>{{ item["title"] }}</td>
</tr>
{% endfor %}
</table>
<form method="get" action="/idor/{{ stage }}">
<label for="id">Document ID</label>
<input id="id" name="id" value="{{ requested_id }}">
<button type="submit">Load Document</button>
</form>
{% if document %}
<article>
<h2>{{ document["title"] }}</h2>
<p><strong>Document ID:</strong> {{ document["id"] }}</p>
<p><strong>Owner:</strong> {{ document["owner_username"] }}</p>
<p><strong>Path:</strong> {{ document["path"] }}</p>
<p><strong>Internal Note:</strong> {{ document["internal_note"] }}</p>
</article>
{% endif %}
</body>
</html>
"""
AUDIT_PAGE = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>IDOR Audit</title>
</head>
<body>
<h1>IDOR Audit</h1>
<p>This page shows recent IDOR access decisions from the local lab.</p>
<p><a href="/idor/basic?id=2">back to IDOR lab</a></p>
{% for event in events %}
<article>
<p><strong>{{ event["event_type"] }}</strong> - {{ event["username"] }}</p>
<pre>{{ event["detail"] }}</pre>
<small>{{ event["created_at"] }}</small>
</article>
{% else %}
<p>No IDOR events yet.</p>
{% endfor %}
</body>
</html>
"""
def current_user():
db = get_db()
return db.execute(
"SELECT id, username, role FROM users WHERE id = ?",
(CURRENT_USER_ID,),
).fetchone()
def own_documents():
db = get_db()
return db.execute(
"""
SELECT d.id, d.title, u.username AS owner_username
FROM documents d
JOIN users u ON u.id = d.owner_id
WHERE d.owner_id = ?
ORDER BY d.id
""",
(CURRENT_USER_ID,),
).fetchall()
def load_document(document_id):
db = get_db()
return db.execute(
"""
SELECT d.id, d.owner_id, d.title, d.path, d.internal_note,
u.username AS owner_username
FROM documents d
JOIN users u ON u.id = d.owner_id
WHERE d.id = ?
""",
(document_id,),
).fetchone()
def load_authorized_document(document_id):
db = get_db()
return db.execute(
"""
SELECT d.id, d.owner_id, d.title, d.path, d.internal_note,
u.username AS owner_username
FROM documents d
JOIN users u ON u.id = d.owner_id
WHERE d.id = ?
AND d.owner_id = ?
""",
(document_id, CURRENT_USER_ID),
).fetchone()
def log_access(stage, requested_id, document, decision):
owner = "none"
if document is not None:
owner = str(document["owner_id"])
detail = (
f"stage={stage} "
f"requested_id={requested_id} "
f"current_user={CURRENT_USERNAME} "
f"object_owner={owner} "
f"decision={decision}"
)
db = get_db()
db.execute(
"""
INSERT INTO audit_log (event_type, username, detail)
VALUES (?, ?, ?)
""",
(f"idor-{stage}", CURRENT_USERNAME, detail),
)
db.commit()
def render_lab(stage, title, description, requested_id, document):
return render_template_string(
PAGE,
stage=stage,
title=title,
description=description,
requested_id=requested_id,
document=document,
current_user=current_user(),
own_documents=own_documents(),
)
@bp.get("/")
def index():
return redirect("/idor/basic?id=2")
@bp.get("/basic")
def basic():
requested_id = request.args.get("id", "2")
document = load_document(requested_id)
if document is None:
log_access("basic", requested_id, None, "missing")
abort(404)
log_access("basic", requested_id, document, "allowed-without-owner-check")
return render_lab(
"basic",
"IDOR Basic",
"The route loads any document ID supplied by the request.",
requested_id,
document,
)
@bp.get("/blacklist")
def blacklist():
requested_id = request.args.get("id", "2")
if requested_id in BLOCKED_DOCUMENT_IDS:
log_access("blacklist", requested_id, None, "blocked-by-blacklist")
abort(403)
document = load_document(requested_id)
if document is None:
log_access("blacklist", requested_id, None, "missing")
abort(404)
log_access("blacklist", requested_id, document, "allowed-by-blacklist")
return render_lab(
"blacklist",
"IDOR Weak Blacklist",
"The route blocks one known document ID, then still trusts the requested object reference.",
requested_id,
document,
)
@bp.get("/better")
def better():
requested_id = request.args.get("id", "2")
document = load_document(requested_id)
if document is None:
log_access("better", requested_id, None, "missing")
abort(404)
if document["owner_id"] != CURRENT_USER_ID:
log_access("better", requested_id, document, "forbidden-owner-mismatch")
abort(403)
log_access("better", requested_id, document, "allowed-owner-match")
return render_lab(
"better",
"IDOR Better Owner Check",
"The route checks ownership after loading the document, but the response still reveals whether other documents exist.",
requested_id,
document,
)
@bp.get("/fixed")
def fixed():
requested_id = request.args.get("id", "2")
document = load_authorized_document(requested_id)
if document is None:
log_access("fixed", requested_id, None, "not-found-or-forbidden")
abort(404)
log_access("fixed", requested_id, document, "allowed-owner-match")
return render_lab(
"fixed",
"IDOR Fixed",
"The route only loads the document when the object belongs to the current user.",
requested_id,
document,
)
@bp.get("/audit")
def audit():
db = get_db()
events = db.execute(
"""
SELECT event_type, username, detail, created_at
FROM audit_log
WHERE event_type LIKE 'idor-%'
ORDER BY id DESC
LIMIT 30
"""
).fetchall()
return render_template_string(AUDIT_PAGE, events=events)
EOF
Now register the module in app.py.
Add this near the bottom of app.py, after the route definitions and before the if __name__ == "__main__": block:
from modules.idor import bp as idor_bp
app.register_blueprint(idor_bp)
If XSS is already plugged in, keep the XSS lines and add IDOR below them:
from modules.xss import bp as xss_bp
app.register_blueprint(xss_bp)
from modules.idor import bp as idor_bp
app.register_blueprint(idor_bp)
Run the app:
flask --app app run --host 127.0.0.1 --port 5000
Confirm the module is live:
curl -i "http://127.0.0.1:5000/idor/basic?id=2"
Basic Vulnerability
Open:
http://127.0.0.1:5000/idor/basic?id=2
The lab simulates an authenticated user by hardcoding the current user as operator.
The page lists the operator-owned document. The vulnerable route then loads whichever document ID the request supplies:
document = load_document(requested_id)
The query only asks:
WHERE d.id = ?
It does not ask:
AND d.owner_id = ?
Normal request:
curl -i "http://127.0.0.1:5000/idor/basic?id=2"
Exploit Alice's document:
curl -i "http://127.0.0.1:5000/idor/basic?id=3"
Exploit the admin document:
curl -i "http://127.0.0.1:5000/idor/basic?id=1"
The user is valid, but the object authorization is missing.
Interesting findings include:
operatorcan view Alice's document.operatorcan view the admin document.- The response includes
internal_note. - The only changed value is the
idparameter.
This is horizontal IDOR when operator reads Alice's document.
This is vertical IDOR when operator reads the admin document.
![[Chapter III - Web/2. IDOR/BasicExploit.png]]
Basic Protection - Weak Blacklist
Open:
http://127.0.0.1:5000/idor/blacklist?id=2
This page blocks one known sensitive object:
BLOCKED_DOCUMENT_IDS = {"1"}
if requested_id in BLOCKED_DOCUMENT_IDS:
abort(403)
That blocks the obvious admin document request:
curl -i "http://127.0.0.1:5000/idor/blacklist?id=1"
It does not make the route safe.
Exploit Alice's document:
curl -i "http://127.0.0.1:5000/idor/blacklist?id=3"
Exploit Bob's document:
curl -i "http://127.0.0.1:5000/idor/blacklist?id=4"
The blacklist protects one value. It does not verify the relationship between the current user and the requested object.
![[Chapter III - Web/2. IDOR/MidExploit.png]]
Better Protection - Application Owner Check
Open:
http://127.0.0.1:5000/idor/better?id=2
This page loads the document, then checks the owner:
document = load_document(requested_id)
if document["owner_id"] != CURRENT_USER_ID:
abort(403)
The route no longer returns other users' documents.
Test Alice's document:
curl -i "http://127.0.0.1:5000/idor/better?id=3"
Expected:
403 Forbidden
Test a document that does not exist:
curl -i "http://127.0.0.1:5000/idor/better?id=999"
Expected:
404 Not Found
This is better, but the response still leaks object existence. A forbidden object returns 403. A missing object returns 404.
That difference helps an attacker map valid document IDs.
Fixed Version
Open:
http://127.0.0.1:5000/idor/fixed?id=2
The fixed page enforces object authorization in the database query:
WHERE d.id = ?
AND d.owner_id = ?
The object is only returned when it belongs to the current user.
Test the operator document:
curl -i "http://127.0.0.1:5000/idor/fixed?id=2"
Expected:
200 OK
Test Alice's document:
curl -i "http://127.0.0.1:5000/idor/fixed?id=3"
Expected:
404 Not Found
Test a document that does not exist:
curl -i "http://127.0.0.1:5000/idor/fixed?id=999"
Expected:
404 Not Found
The same response is used for missing objects and forbidden objects.
Local Impact Proof
Use the vulnerable route to generate access events:
curl -i "http://127.0.0.1:5000/idor/basic?id=1"
curl -i "http://127.0.0.1:5000/idor/basic?id=3"
curl -i "http://127.0.0.1:5000/idor/basic?id=4"
Then view:
http://127.0.0.1:5000/idor/audit
The audit page shows the requester, the requested object ID, the object owner, and the access decision.
This proves the useful impact:
current_user=operator
requested_id=1
object_owner=1
decision=allowed-without-owner-check
The application did not need SQL injection, XSS, or admin credentials. It trusted a user-controlled object reference.

Remediation
Patch IDOR by enforcing object authorization server-side:
- Authenticate the user.
- Load objects through a query scoped to the current user.
- Deny by default when ownership cannot be proven.
- Apply the same rule to read, update, delete, download, export, and admin actions.
- Return the same generic response for missing and forbidden objects when object existence is sensitive.
- Log denied object access with requester, object ID, owner, route, and decision.
Safe lookup:
SELECT id, owner_id, title, path, internal_note
FROM documents
WHERE id = ?
AND owner_id = ?;
UUIDs, hashes, and encoded references reduce easy guessing. They do not replace authorization.
Detection
Useful signals include:
- One user requesting many object IDs in sequence.
- A normal user requesting admin-owned objects.
- A user requesting objects owned by many different users.
- Repeated
403or404responses across adjacent IDs. - Downloads, exports, updates, or deletes across many owners from one session.
- Hidden object references discovered through API responses.
Useful log fields:
username
role
requested_object_id
object_owner_id
decision
route
source_ip
user_agent
Detection is strongest when the application logs both the requester and the object owner.