Operator On The Wire
← Back to Knowledge Base
OOTW / Chapter III - Web / 02. IDOR

Example

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:

  • operator can view Alice's document.
  • operator can view the admin document.
  • The response includes internal_note.
  • The only changed value is the id parameter.

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.

Audit


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 403 or 404 responses 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.