BugForge — 2026.05.02

Sokudo: Predictable Bearer Token + Timestamp Leak via Leaderboard

BugForge Predictable Bearer Token + Sensitive Timestamp Disclosure medium

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.

  1. The registration response token equals our own last_login truncated to seconds. Registered haxor at 2026-05-02 00:35:05 and received Bearer 20260502003505. Token format hypothesis confirmed against data we controlled.
  2. GET /api/stats/leaderboard is callable by any authenticated user and returns last_login for every user including admin. The value is ISO-8601 with millisecond precision; the second portion is what the token format consumes.
  3. The bundle contained the UI binding s(e.data.flag||"") adjacent to the /api/admin/users fetch, indicating the flag is delivered as a top level flag key on a successful admin response.
  4. Express X-Powered-By header 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.

  1. Bearer tokens are not random. The token issued at login or registration is the user’s last_login formatted as YYYYMMDDhhmmss (UTC, truncated to seconds). Confirmed by registering haxor at 2026-05-02 00:35:05 and receiving Bearer 20260502003505.
  2. GET /api/stats/leaderboard is callable by any authenticated user and returns last_login for every user including admin. 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/users works 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_login is 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

#bugforge #webapp #broken-authentication #predictable-token #information-disclosure #cwe-330 #cwe-200