BugForge — 2026.05.28

Sokudo: JWT Signature Verification Bypass on a Legacy Route Mount

BugForge JWT Signature Verification Bypass easy

Part 1: Pentest Report

Executive Summary

Sokudo (速度) is a Japanese cyber-themed speed-typing practice application: a React single-page app backed by an Express API. The API is mounted at two version prefixes, /v1 and /v2, that share one router. The /v2 mount verifies JWT signatures correctly and rejects every forged token. The /v1 mount does not; it decodes the token and trusts it without checking the signature. An unsigned (alg=none) token claiming the seeded admin account’s id is accepted on /v1, granting full access to the admin route group and the flag.

Testing confirmed 1 finding:

ID Title Severity CVSS CWE Endpoint
F1 JWT signature not verified on legacy /v1 route mount High 7.5 CWE-347, CWE-287 GET /v1/admin/flag

The headline finding is a complete authentication bypass scoped to the /v1/admin/* route group. Because the bypass relies on a forged unsigned token rather than any valid credential, no account or password is required to reach it. The same route group exposes the full user table (PII) and all session records in addition to the flag.


Objective

Capture the flag on the Sokudo lab. The flag is served from an admin-only API endpoint; the engagement goal was to reach it from an unprivileged starting position.


Scope / Initial Access

# Target Application
URL: https://lab-1779412508958-g0b8xm.labs-app.bugforge.io

# Auth details
POST /v2/register -> {token, user}    # self-service registration
POST /v2/login    -> {token, user}
GET  /v2/verify-token -> {user}        # FE bootstraps user object + role from here
Authorization: Bearer <jwt>            # HS256, payload {id, username, role, iat}
# A freshly registered account is issued role="user".

Registration is open. A new account receives a signed HS256 JWT with role:"user". The frontend reads the user object and role from GET /v2/verify-token and renders the admin panel only when role === "admin".


Reconnaissance: Reading the JavaScript Bundle

The application surface was mapped from the production React bundle (/static/js/main.99173f1b.js), which references the API routes directly. The bundle revealed the admin panel’s data flow and the exact endpoint serving the flag, which shaped the entire test plan.

  1. The flag is fetched by the admin panel component from GET /v2/admin/flag and rendered from the flag field of the response. This is the objective.
  2. The admin panel parallel-fetches /v2/admin/users, /v2/admin/sessions, and /v2/admin/flag, a single admin route group, gated client-side by role === "admin".
  3. Authentication is JWT, HS256, with a payload of {id, username, role, iat}. The role claim is present in the token, which raised the question of whether the server trusts it.
  4. The API is reachable under a version prefix (/v2/*). Version prefixes imply the possibility of other mounts (/v1), which became relevant once direct attacks on /v2 were exhausted.

Application Architecture

Component Detail
Backend Express (x-powered-by: Express), API under /v1/* and /v2/*
Frontend React SPA (CRA build), main.99173f1b.js
Auth JWT, HS256, payload {id, username, role, iat}
CORS access-control-allow-origin: * on API responses

API Surface

Endpoint Method Auth Notes
/v2/register POST None Returns {token, user}; role fixed to user
/v2/login POST None Returns {token, user}
/v2/verify-token GET Bearer Returns {user}; FE bootstrap
/v2/session/start POST Bearer Returns {text, duration}
/v2/session/submit POST Bearer Server computes WPM/accuracy from client-supplied fields
/v2/stats GET Bearer Per-user stats
/v2/stats/leaderboard GET Bearer Public-ish leaderboard
/v2/admin/users GET Admin Full user table (PII)
/v2/admin/sessions GET Admin All session records
/v2/admin/flag GET Admin Flag in .flag field
/v1/admin/* GET Admin (broken) Same handlers, signature not verified

Known Users

Username ID Role
admin 1 admin (seeded)
(our account) 4 user (registered)

Attack Chain Visualization

┌────────────────────┐   ┌────────────────────┐   ┌────────────────────┐   ┌────────────────────┐
│  Bundle recon       │   │  Forge alg=none    │   │  GET /v2/admin/flag│   │  GET /v1/admin/flag│
│  flag at            │ ▶ │  token  id:1       │ ▶ │  with forged token │ ▶ │  with SAME token   │
│  /admin/flag        │   │  (seeded admin)    │   │  -> 403            │   │  -> 200, FLAG      │
│  mounts: /v1 + /v2  │   │                    │   │  (v2 verifies sig) │   │  (v1 does not)     │
└────────────────────┘   └────────────────────┘   └────────────────────┘   └────────────────────┘

Findings

F1: JWT signature not verified on legacy /v1 route mount

Severity: High CVSS v3.1: 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) CWE: CWE-347 (Improper Verification of Cryptographic Signature), CWE-287 (Improper Authentication) Endpoint: GET /v1/admin/flag Authentication required: No (a forged unsigned token is sufficient)

Description

The API router is mounted at two version prefixes, /v1 and /v2. The two mounts behave identically at the handler level (same data, same error strings) but apply different authentication middleware:

  1. The /v2 mount verifies the JWT signature. Tokens signed with alg=none, and tokens carrying a deliberately wrong HS256 signature, are both rejected with 403 “Invalid token”.
  2. The /v1 mount does not verify the signature. It decodes the token and trusts its claims. An alg=none token, or a token with an invalid signature, is accepted.

The admin middleware does not trust the JWT role claim. It reads the id claim and looks the role up in the database. Account id:1 is the seeded admin (database role admin). A forged token claiming id:1 therefore resolves to admin on the unverified /v1 mount.

Impact

Complete authentication bypass of the /v1/admin/* route group, exposing the flag, the full user table, and all session records to an unauthenticated requester.

Reproduction

Step 1: Establish the baseline (/v2 rejects forged tokens)

A token forged with alg=none claiming the admin id is rejected on the verified mount.

GET /v2/admin/flag HTTP/1.1
Host: lab-1779412508958-g0b8xm.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc3OTQxMjUyOH0.

Response: 403 Forbidden, body {"error":"Invalid token"}. The /v2 mount checks the signature and rejects the unsigned token.

Step 2: Replay the same token against /v1

The identical forged token is accepted on the legacy mount.

GET /v1/admin/flag HTTP/1.1
Host: lab-1779412508958-g0b8xm.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc3OTQxMjUyOH0.

Response: 200 OK, body {"flag":"bug{x3LyWBYuYnf3ONtQvRxNiammOIC5C03C}"}. The /v1 mount does not verify the signature; the unsigned token is trusted and resolves to admin.

The forged token decodes to:

{ "alg": "none", "typ": "JWT" }
{ "id": 1, "username": "admin", "role": "admin", "iat": 1779412528 }

The signature segment is empty.

Isolation (proving which claim is load-bearing): a forged alg=none token with id:4 (our own account) and role:admin returns 403 on /v1, while id:1 with the same role:admin returns 200. Only the id claim differs between the two requests. This confirms the server ignores the role claim, trusts the id claim, and does not verify the signature on /v1.

Remediation

Fix 1: Apply identical, signature-verifying authentication middleware to every mount

// BEFORE (vulnerable): /v1 mount decodes without verifying
// router mounted at both prefixes, but only /v2 wraps it in verifying middleware
app.use('/v1', adminRouter);                       // no signature verification
app.use('/v2', verifyJwt, adminRouter);            // verifies

// AFTER (secure): one verifying middleware, applied uniformly
function verifyJwt(req, res, next) {
  const token = (req.headers.authorization || '').replace(/^Bearer /, '');
  try {
    // algorithms whitelist rejects alg=none and algorithm confusion
    req.user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
    next();
  } catch (e) {
    return res.status(403).json({ error: 'Invalid token' });
  }
}

app.use('/v1', verifyJwt, adminRouter);
app.use('/v2', verifyJwt, adminRouter);

Additional recommendations:

  • Retire the /v1 alias entirely if it exists only for back-compat. An unused legacy mount is attack surface with no upside.
  • Reject alg=none explicitly via an algorithms whitelist on every jwt.verify call, not just the current version.
  • Add a test that fires alg=none and wrong-signature tokens at every version prefix and asserts a 403, so a future mount cannot regress silently.

OWASP Top 10 Coverage

  • A07:2021 Identification and Authentication Failures: the /v1 mount accepts unsigned and wrongly-signed JWTs, defeating authentication entirely for that route group.
  • A01:2021 Broken Access Control: the authentication bypass yields admin-level access to the user table, session records, and flag from an unprivileged (in fact, credential-less) starting position.
  • A05:2021 Security Misconfiguration: two mounts of the same router with divergent security middleware; the legacy mount left with verification disabled.

Tools Used

Tool Purpose
Browser DevTools / source map Reading the React bundle to map the API and locate the flag endpoint
jwtforge Forging alg=none and wrong-signature tokens
curl / Burp Replaying forged tokens against /v1 and /v2
hashcat + wordlists HS256 secret cracking attempt against /v2 (unsuccessful)

References


Part 2: Notes / Knowledge

Key Learnings

  • Re-fire every failed authentication probe against every version-prefix alias of the route. When an API exposes /v1 and /v2 of the same endpoints, the mounts can share one router but apply different authentication middleware. A legacy /v1 left mounted for back-compat is a common place for signature verification to be missing or broken even when the current /v2 enforces it. The probe is cheap: take the alg=none and wrong-signature JWT variants that got rejected on the live version and replay them verbatim against each alias. Identical response bodies and error strings across versions mean a shared handler, but not shared middleware; the authentication layer has to be tested per-mount, not inferred from handler behavior.

  • When forging a token, the id claim can matter more than the role claim. The admin check here ignored the token’s role and looked the role up in the database by id, so flipping role:admin did nothing on its own; setting id:1 (the seeded admin) is what resolved to admin. When a forged role:admin is accepted but yields no privilege, swap the subject identifier to a low integer for the seeded admin and re-fire before concluding the token is not trusted.


Failed Approaches

Approach Result Why It Failed
alg=none forged token against /v2/admin/flag 403 “Invalid token” The /v2 mount verifies the signature
HS256 secret crack on /v2 (wallarm jwt-secrets 104k + rockyou 14.3M + 27 themed guesses) No hit Secret not in any wordlist; /v2 signature stays unforgeable
role mass-assignment on POST /register (both /v1 and /v2) Account still role:user role field whitelisted out of the registration handler
/v3 namespace probe SPA catch-all (HTML) No such mount exists
/v2/session/submit WPM forgery Not exploited Server computes WPM from client-supplied textGenerated + timeElapsed, so WPM/leaderboard are forgeable, a latent logic bug, but not the flag and not pursued after capture

Tags: #jwt #alg-none #authentication-bypass #version-prefix #broken-access-control #bugforge Document Version: 1.0 Last Updated: 2026-05-28

#jwt #alg-none #authentication-bypass #version-prefix #broken-access-control #bugforge