Tanuki: IDOR on User Statistics Endpoint
Overview
- Platform: BugForge
- Vulnerability: Insecure Direct Object Reference (IDOR) on User Statistics Endpoint
- Key Technique: Path parameter manipulation on
/api/stats/:userIdto access other users’ data without ownership validation - Result: Enumerated all seed user stats and captured hidden
achievement_flagfield from user IDs 1-3
Objective
Find and exploit vulnerabilities in a flashcard study application to capture the flag.
Initial Access
# Target Application
URL: https://lab-1774991334271-678obv.labs-app.bugforge.io
# Auth details
Registered user: haxor (ID 4, role: user)
Auth mechanism: JWT HS256 (payload: {id, username, iat})
Key Findings
- Insecure Direct Object Reference on Statistics Endpoint (CWE-639: Authorization Bypass Through User-Controlled Key) — The
GET /api/stats/:userIdendpoint accepts an arbitrary user ID as a path parameter and returns that user’s statistics without verifying the authenticated user owns the requested resource. Any authenticated user can read any user’s stats by iterating IDs, including a hiddenachievement_flagfield only present on seed accounts.
Attack Chain Visualization
┌──────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Register user │ │ Map API surface │ │ Test admin │
│ (haxor, ID 4) │────>│ via traffic capture │────>│ endpoints → 403 │
└──────────────────┘ └─────────────────────┘ └──────────┬───────────┘
│
v
┌───────────────────────┐
│ Identify IDOR on │
│ GET /api/stats/:id │
│ (userId in path) │
└───────────┬───────────┘
│
v
┌───────────────────────┐
│ Request /api/stats/1 │
│ /api/stats/2 │
│ /api/stats/3 │
│ as user ID 4 │
└───────────┬───────────┘
│
v
┌───────────────────────┐
│ 200 OK — full stats │
│ + achievement_flag │
│ on seed users only │
└───────────────────────┘
Application Architecture
| Component | Path | Description |
|---|---|---|
| Frontend | React SPA (MUI components) | Flashcard study interface with deck management |
| Backend | Express/Node.js | REST API with JWT authentication |
| Database | SQLite (assumed, sequential IDs) | Users, decks, cards, study progress |
| Auth | JWT HS256 | Token contains {id, username, iat} — no role in claims, role stored DB-side |
Exploitation Path
Step 1: Reconnaissance — Map the API Surface
Registered an account (haxor, assigned ID 4) and captured traffic to enumerate all API endpoints. The application is a spaced-repetition flashcard study tool with deck management, study sessions, and progress tracking.
Key endpoints discovered:
POST /api/register— User registration (returns JWT, no role in response)POST /api/login— Authentication (returns JWT + user object withrolefield)GET /api/verify-token— Token validationGET /api/decks/GET /api/decks/available— Deck listing (3 available: Planets & Moons, Linux Trivia, Cheese Origins)POST /api/decks/:id/add/DELETE /api/decks/:id/remove— Deck managementGET /api/study/:deckId/cards— Flashcards for studyPOST /api/study/progress— Save card progress (spaced repetition: card_id, quality)POST /api/study/session— Save study session (deck_id, cards_studied, correct_count)GET /api/stats/:userId— User statistics (userId in path parameter)GET/POST/PUT/DELETE /api/admin/*— Admin CRUD for users, decks, cards
Key observations:
- JWT contains
{id, username, iat}with no role — role stored server-side only - Login response includes
role: "user"— role exists in the user model - Sequential user IDs — seed users at IDs 1-3, our registration got ID 4
- Stats endpoint takes userId as a path parameter rather than deriving it from the JWT
- CORS wide open:
Access-Control-Allow-Origin: *
Step 2: Test Admin Access (Dead End)
Attempted to access admin endpoints with our regular user JWT:
GET /api/admin/users HTTP/1.1
Host: lab-1774991334271-678obv.labs-app.bugforge.io
Authorization: Bearer <jwt>
Response: 403 Forbidden — {"error":"Admin access required"}
Admin endpoints have proper server-side role enforcement. This ruled out simple broken access control on the admin panel.
Step 3: IDOR on Statistics Endpoint
The /api/stats/:userId endpoint stood out — it takes the user ID as a path parameter rather than deriving it from the authenticated JWT. This is a classic IDOR pattern.
Tested by requesting stats for user ID 1 while authenticated as user ID 4:
GET /api/stats/1 HTTP/1.1
Host: lab-1774991334271-678obv.labs-app.bugforge.io
Authorization: Bearer <jwt-for-user-4>
Response: 200 OK — returned full statistics for user ID 1, including an achievement_flag field not present on our own account.
Confirmed the same for IDs 2 and 3:
GET /api/stats/2 HTTP/1.1
Authorization: Bearer <jwt-for-user-4>
# 200 OK — stats + achievement_flag
GET /api/stats/3 HTTP/1.1
Authorization: Bearer <jwt-for-user-4>
# 200 OK — stats + achievement_flag
Our own stats (GET /api/stats/4) did not contain the achievement_flag field — it’s only present on seed accounts.
Step 4: Flag Captured
The achievement_flag field across all three seed users contained the same flag:
bug{Amp1w10WfTw2e4twMHj9JqDIIahssqxl}
Flag / Objective Achieved
bug{Amp1w10WfTw2e4twMHj9JqDIIahssqxl}
Obtained from the achievement_flag field in seed user statistics, accessed via IDOR on GET /api/stats/:userId.
Key Learnings
- Path parameters holding user IDs are high-value IDOR targets — When an endpoint accepts a resource identifier in the URL path rather than deriving it from the authenticated session/token, always test whether it enforces ownership. The JWT had the user ID right there, but the endpoint ignored it.
- Sequential IDs make enumeration trivial — With IDs 1-3 as seed users and our registration at ID 4, the entire user space was trivially enumerable. UUIDs wouldn’t fix the authorization bug, but they’d raise the bar for blind enumeration.
- Hidden fields in API responses are not access control — The
achievement_flagfield was absent from the frontend UI but fully present in the API response. Security through omission from the UI is not security at all.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| Broken access control on /api/admin/* | 403 “Admin access required” | Server enforces role check on admin endpoints |
Tools Used
| Tool | Purpose |
|---|---|
| Caido | HTTP interception, request replay, and endpoint enumeration |
| Browser DevTools | Traffic capture and response inspection |
Remediation
1. Insecure Direct Object Reference on Statistics (CVSS: 7.5 - High)
Issue: The /api/stats/:userId endpoint uses the path parameter directly for the database lookup without comparing it to the authenticated user’s ID from the JWT. Any authenticated user can access any other user’s statistics.
CWE Reference: CWE-639 - Authorization Bypass Through User-Controlled Key
// BEFORE (Vulnerable)
app.get('/api/stats/:userId', authenticate, async (req, res) => {
const stats = await db.get(
'SELECT * FROM user_stats WHERE user_id = ?',
[req.params.userId]
);
res.json(stats);
});
// AFTER (Secure) — Option A: Enforce ownership
app.get('/api/stats/:userId', authenticate, async (req, res) => {
if (parseInt(req.params.userId) !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
const stats = await db.get(
'SELECT * FROM user_stats WHERE user_id = ?',
[req.params.userId]
);
res.json(stats);
});
// AFTER (Secure) — Option B: Remove path param entirely
app.get('/api/stats', authenticate, async (req, res) => {
const stats = await db.get(
'SELECT * FROM user_stats WHERE user_id = ?',
[req.user.id] // Always use the authenticated user's ID
);
res.json(stats);
});
Option B is preferred — removing the path parameter eliminates the attack surface entirely. There’s no reason for a user to request stats for anyone other than themselves.
OWASP Top 10 Coverage
- A01:2021 - Broken Access Control — IDOR on the statistics endpoint allows horizontal privilege escalation, accessing other users’ data without authorization checks
References
- OWASP: Insecure Direct Object References
- CWE-639: Authorization Bypass Through User-Controlled Key
- OWASP API Security Top 10 - API1:2023 Broken Object Level Authorization
- PortSwigger: Insecure Direct Object References (IDOR)
Tags: #idor #broken-access-control #api-security #information-disclosure #bugforge
Document Version: 1.0
Last Updated: 2026-03-31