Broken Access Control
Theory
Broken Access Control held #1 in the 2025 edition, as it did in 2021 — and in 2025 it is the widest category on the list, with 40 mapped CWEs including SSRF (CWE-918) and CSRF (CWE-352). Access control enforces that users can only act within their intended permissions. When it fails, attackers can read other users' data, call admin-only functions, or escalate their own privileges.
Types of Broken Access Control
- IDOR (Insecure Direct Object Reference) — changing a resource ID in a URL to access another user's record:
GET /orders/1337where 1337 is not your order - Missing Function-Level Access Control — admin panels or API routes are reachable with zero auth checks; the route simply exists and returns data
- Path Traversal — escaping the intended directory:
GET /download?file=../../etc/passwd - Horizontal Privilege Escalation — user A can read/modify user B's data (same role, different account)
- Vertical Privilege Escalation — a regular user reaches admin-only actions (different role)
- CORS Misconfiguration —
Access-Control-Allow-Origin: *on sensitive endpoints lets any origin read the response
Real-World Breaches
- First American Financial (2019) — 885 million mortgage documents leaked; anyone could view any document by incrementing a number in the URL. No authentication required.
- Parler (2021) — All 70 million posts were scraped in bulk before shutdown because post IDs were sequential and no access check existed on the API
- Facebook (2018) — An IDOR in the video upload flow allowed any logged-in user to access any other user's private videos
- Venmo (2019) — The public transaction API required no authentication, exposing 207 million transactions including payees and amounts
Vulnerable Code Pattern
# VULNERABLE — no ownership check; any authenticated user can read any order
@app.get("/orders/{order_id}")
def get_order(order_id: int, current_user = Depends(get_current_user)):
order = db.get(Order, order_id) # fetches ANY row — never checks ownership!
if not order:
raise HTTPException(404)
return order # leaks data belonging to other users
How the Attack Works — Step by Step
# Attacker: alice (user_id=2). Target: admin order (user_id=1)
# Step 1 — observe your own order to learn the ID format
GET /orders/5 → {"id": 5, "user_id": 2, "product": "Widget"}
# Step 2 — enumerate other IDs; the server never checks ownership
GET /orders/1 → {"id": 1, "user_id": 1, "secret_note": "flag{...}"}
GET /orders/2 → {"id": 2, "user_id": 1, "product": "Confidential"}
# Automate with a simple loop:
for i in range(1, 1000):
r = requests.get(f"https://target.com/orders/{i}", headers=auth)
if r.status_code == 200:
print(i, r.json())
Fixed Code
# FIXED — ownership enforced inside the database query
@app.get("/orders/{order_id}")
def get_order(order_id: int, current_user = Depends(get_current_user)):
order = db.exec(
select(Order)
.where(Order.id == order_id)
.where(Order.user_id == current_user.id) # ownership in the WHERE clause
).first()
if not order:
raise HTTPException(404) # same 404 for not-found AND not-owned — no info leak
return order
# For admin routes, enforce via a reusable dependency:
def require_admin(user = Depends(get_current_user)):
if user.role != "admin":
raise HTTPException(403)
return user
@app.get("/admin/users")
def list_all_users(_admin = Depends(require_admin)): # fails fast if not admin
return db.exec(select(User)).all()
New in 2025: SSRF folded into this category
The 2021 edition listed Server-Side Request Forgery as its own category (A10:2021). In 2025 it is one of the 40 CWEs mapped to Broken Access Control — CWE-918 — because an SSRF is the server being tricked into reaching a resource the caller was never authorised to reach.
- Internal-only services —
?url=http://127.0.0.1:8000/adminreaches endpoints that are firewalled from the internet but trusted from localhost - Cloud metadata —
http://169.254.169.254/latest/meta-data/hands out instance credentials on unpatched IMDSv1 hosts - Alternate schemes —
file://,gopher://anddict://turn a URL fetcher into a file reader or a protocol smuggler
# VULNERABLE — fetches whatever the user supplies
@app.get("/fetch")
async def fetch(url: str):
async with httpx.AsyncClient() as client:
return (await client.get(url)).text # no scheme allow-list, no IP filter
# FIXED — allow-list the scheme AND resolve+check the destination IP
import ipaddress, socket
ALLOWED_SCHEMES = {"http", "https"}
async def fetch_safe(url: str):
parts = urlparse(url)
if parts.scheme not in ALLOWED_SCHEMES:
raise ValueError("scheme not allowed")
ip = ipaddress.ip_address(socket.gethostbyname(parts.hostname))
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise ValueError("destination not allowed") # blocks 127/8, 10/8, 169.254/16
async with httpx.AsyncClient() as client:
return (await client.get(url)).text
How to Fix — Checklist
- Deny by default — every route requires authentication unless explicitly marked public
- Enforce ownership in the query — always filter by
user_id = current_user.id; never trust a client-supplied ID alone - Use indirect references — UUIDs instead of sequential integers make enumeration harder (but are not a substitute for proper auth checks)
- Two-account test — during every code review: can user A access user B's resources? Automate this check in CI
- Log & alert on failures — repeated 403/404 on sequential IDs is a sign of IDOR scanning
IDOR — Read another user's private profile
The profile endpoint is publicly accessible — no login required. It returns any user's private data based on the user_id URL parameter with zero authorisation checks. The form defaults to user_id=2 (alice's profile). Change it to 1 (admin) and click Submit to capture the flag.
user_id to 1 in the form below and click Submit.Missing Function-Level Access Control — Unauthenticated Admin Panel
The admin panel has no authentication check. Any user (or no user at all) can access it directly.
/web/a01/admin directly.SSRF — Reach an internal-only endpoint (CWE-918)
The URL fetcher makes a server-side request to any URL you give it. An internal endpoint refuses connections from the internet but trusts localhost — make the server fetch it for you.
http://127.0.0.1:8000/web/internal/ssrf-flagSSRF — Read local files via the file:// scheme
The same fetcher never allow-lists the URL scheme, so it will happily read from the container filesystem instead of the network.
file:///app/ssrf_flag.txt