Biterra Labs preview / Intro to Web Exploitation
IDOR stands for insecure direct object reference. The app takes an identifier from the request — a user ID, order ID, filename — and fetches that object without checking whether the current user is allowed to see it. You change the ID and receive someone else's data.
A profile page is https://example.com/profile?id=123. The server does:
It never asks "is the logged-in user allowed to see 123?" Change the URL to ?id=124 and you might see another profile.
The same idea shows up as:
?file=invoice-123.pdf → try another invoice you should not ownGET /api/orders/456 → try another order number<input type="hidden" name="user_id" value="123"> → change the value and submitThe reference comes directly from the request. The app is insecure because it authenticates you, then forgets to authorise the object.
Do not confuse this with path traversal. If ?file=../admin/flag.txt works, that is a file-path bug. IDOR is asking for a real object by its normal identifier.
| Type | Example |
|---|---|
| Horizontal | One customer reads or changes another customer's order |
| Vertical | A normal user accesses an object or action reserved for administrators |
| Context-dependent | A support agent can open tickets assigned to them, but not every ticket |
IDOR most often causes horizontal access-control failures, but the same missing object check can cross roles or depend on the application's rules.
The object reference is not always a number in the URL:
| Reference | Example |
|---|---|
| Number | /orders/457 |
| Filename | /invoices/account-b-july.pdf |
| UUID | /documents/0d50b4d2-70a7-4e2d-9138-f39c4d20c828 |
| JSON field | {"user_id":124} |
Sequential numbers make valid objects easier to guess, but replacing them with UUIDs does not fix missing authorisation. A UUID can still leak through a link, log, email, or API response. Unpredictable identifiers help, but they are not permission checks.
Imagine a shop with two accounts. Each account owns one order:
An ordinary request from account A asks for its own order:
GET /api/orders/456 HTTP/1.1
Cookie: session=account-a
HTTP/1.1 200 OK
Content-Type: application/json
{"id":456,"owner":"account-a","item":"Keyboard"}
Now suppose the same account A session requests account B's known order. Only the object reference is different:
GET /api/orders/457 HTTP/1.1
Cookie: session=account-a
A vulnerable response exposes B's object:
HTTP/1.1 200 OK
Content-Type: application/json
{"id":457,"owner":"account-b","item":"Monitor"}
A secure application refuses the request. It may return 403 Forbidden, or deliberately use 404 Not Found so it does not reveal whether the object exists.
This is why a 404 alone is inconclusive: the object may not exist, or the application may be hiding an object account A cannot access.
The same ownership rule should apply when an object is edited, deleted, downloaded, or exported. A safe read route does not prove the other operations are safe.
The same missing check may let one user change or delete another user's object. Suppose order 457 belongs to account B, but account A sends:
PATCH /api/orders/457 HTTP/1.1
Cookie: session=account-a
Content-Type: application/json
{"delivery_address":"Account A's address"}
If the update succeeds, the server checked that account A was logged in but failed to check whether account A could modify order 457.
| Operation | Possible impact |
|---|---|
GET /orders/457 | Read someone else's order |
PATCH /orders/457 | Change someone else's order |
DELETE /orders/457 | Delete someone else's order |
GET /orders/457/invoice | Download a related protected file |
A reference can also sit inside a nested route such as /teams/8/documents/42. A check for access to team 8 does not automatically prove the user may access every document inside it.
The vulnerable pattern fetches from every order in the database using only the client-supplied ID:
order = Order.query.get_or_404(order_id)
return jsonify(order.to_dict())
const order = await prisma.order.findUnique({
where: { id: Number(request.params.id) },
});
response.json(order);
Instead, scope the lookup to objects the current user may access:
order = Order.query.filter_by(
id=order_id,
user_id=current_user.id,
).first_or_404()
return jsonify(order.to_dict())
const order = await prisma.order.findFirst({
where: {
id: Number(request.params.id),
userId: request.user.id,
},
});
if (!order) return response.sendStatus(404);
response.json(order);
The identifier still chooses an order, but only from the current user's authorised set. More complex applications may use roles, team membership, ownership, or a central policy check instead. Apply the check on the server for every operation; hiding a link or button in the browser is not access control.
Only practise against systems you own or are explicitly authorised to test, such as this lab.
An order API replaces sequential IDs with UUIDs. While signed in as account A, a request using account B's known order UUID still returns B's order. Has the UUID fixed the vulnerability?
No. The UUID made the reference harder to guess, but the server still failed to check whether account A could access that order.
Planet Express Dispatch
Look for object identifiers in the URL, body, or request parameters.
If the app still trusts you, you may be testing access control rather than business logic.