BugForge — 2026.07.16

Cheesy Does It: Unicode Username Collision to Full Admin

BugForge Unicode Normalization Privilege Escalation medium

Part 1: Pentest Report

Executive Summary

Cheesy Does It is a premium pizza-delivery application built on a React single-page frontend, a Node/Express JSON API, and a SQLite database, with JWT (HS256) session tokens. Testing found that administrative authorization is decided by Unicode-normalizing the username carried in the caller’s JWT and comparing the result to the literal string admin, while account registration enforces username uniqueness on the raw bytes. Registering a username that normalizes to admin but differs at the byte level yields a token that the admin middleware accepts, granting full administrative access without any prior privilege.

Testing confirmed 2 findings:

ID Title Severity CVSS CWE Endpoint
F1 Unicode NFKC username collision grants full admin Critical 9.1 CWE-285, CWE-289 POST /api/register + /api/admin/*
F2 Payment-token race (TOCTOU): one payment, many orders Medium 4.3 CWE-362, CWE-367 POST /api/orders

The flag-bearing finding (F1) is a complete administrative takeover reachable from an unauthenticated starting position. An attacker registers a single account, receives a token, and reads every administrative endpoint, including a flag delivered in the X-Flag response header. The underlying cause is a two-tier defense mismatch: the identity string is normalized in one place (authorization) but not the other (uniqueness).


Objective

Assess the Cheesy Does It web application for vulnerabilities that lead to privilege escalation or unauthorized data access, and recover the engagement flag.


Scope / Initial Access

# Target Application
URL: https://lab-1784153734864-1ei5cy.labs-app.bugforge.io

# Authentication
Registration: POST /api/register (open, no approval step)
Session:      JWT HS256, payload {id, username, iat}, signed with a strong secret
Start state:  none (self-registered account, role "user")

Registration is open and returns a session token immediately. The token payload carries the username exactly as it was submitted, including any non-ASCII characters. A seeded privileged account named admin exists.


Reconnaissance: Reading the Client Bundle and Mapping the Authorization Surface

The frontend bundle was retrieved and parsed to enumerate the API surface and the shape of the session token. The API is a conventional JSON service; the interesting behavior is entirely in how administrative access is decided. The observations below are the ones that shaped the successful test plan.

  1. Sessions are JWT HS256 with the payload {id, username, iat}. The signing secret resisted an alg=none downgrade and a rockyou dictionary crack, so forging or re-signing a token was not viable. Any privilege had to come from a token the server itself issued.
  2. The verify-token endpoint reports a role field read from the caller’s own database row. Every account created during testing reported role: "user". Registration therefore looked role-safe on its surface.
  3. Administrative endpoints under /api/admin/* returned HTTP 403 for ordinary accounts and exposed no flag in any response body. Whatever the middleware gates on, it is not the role field visible to the client, and the win condition was not in the JSON.
  4. Registration accepts an arbitrary username and rejects duplicates by comparing the raw submitted bytes. The privileged account is named admin, a value an attacker can reference directly.
  5. The static frontend serves index.html for any unmatched GET, so route discovery had to key on Content-Type rather than HTTP status.

Observations 2, 3, and 4 together pointed away from role-based tampering and toward the question of what attribute the admin check actually reads. That attribute turned out to be the username string, normalized.


Application Architecture

Component Detail
Backend Node.js / Express JSON API
Frontend React single-page application (MUI)
Auth JWT HS256, payload {id, username, iat}, strong secret
Database SQLite (users table holds a role column of user / admin)

API Surface

Endpoint Method Auth Notes
/api/register POST None Returns {token, user}; uniqueness enforced on raw username bytes
/api/login POST None Password login
/api/verify-token GET Bearer Returns role from the caller’s database row
/api/menu/pizzas GET None Menu catalog
/api/payment/validate POST Bearer Validates card, returns a payment_token
/api/payment/process POST Bearer Marks the payment_token processed
/api/orders POST Bearer Creates an order; requires a processed payment_token
/api/admin/stats GET Bearer (admin) Totals; returns X-Flag header when admin
/api/admin/users GET Bearer (admin) All users and personal information
/api/admin/orders GET Bearer (admin) All orders
/api/admin/coupons GET/POST Bearer (admin) Read and create coupons
/api/admin/tickets GET/POST Bearer (admin) Read and modify support tickets

Known Users

Username ID Role
admin seeded admin
admin (our account, last character U+FF4E) 121 user (per database), admin (per authorization check)

Attack Chain Visualization

┌───────────────────────────┐     ┌──────────────────────────┐     ┌───────────────────────────┐     ┌────────────────────────┐
│ POST /api/register        │     │ Response: JWT issued     │     │ GET /api/admin/stats      │     │ Response header        │
│ username "admin"          │     │ payload carries the raw  │     │ Authorization: Bearer JWT │     │ X-Flag: bug{...}       │
│ NFKC(username) == "admin"  │ ──▶ │ Unicode username string  │ ──▶ │ NFKC(username) -> "admin"  │ ──▶ │ read flag from headers │
│ raw bytes  !=  "admin"     │     │ (id 121, role "user")    │     │ middleware grants admin    │     │                        │
└───────────────────────────┘     └──────────────────────────┘     └───────────────────────────┘     └────────────────────────┘
   uniqueness check passes           token is server-issued           authorization keys on the         win condition is a
                                                                       normalized name, not the role     header, not a body field

Findings

F1: Unicode NFKC username collision grants full admin

Severity: Critical CVSS v3.1: 9.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) CWE: CWE-285 (Improper Authorization), CWE-289 (Authentication Bypass by Alternate Name) Endpoint: POST /api/register and /api/admin/* Authentication required: No

Description

Two independent defects compound into a full privilege escalation:

  1. Registration uniqueness is a raw-byte comparison. A submitted username is rejected only if its bytes exactly match an existing account. A username whose bytes differ from admin is accepted even when it normalizes to admin.
  2. Administrative authorization keys on a normalized username, not the database role. The admin middleware applies Unicode NFKC normalization to the username taken from the JWT and compares the result to admin using a case-sensitive equality. The role column in the users table is never consulted for this decision.

A username such as admin (the final character is a fullwidth , U+FF4E; the leading characters are ASCII a d m i) satisfies both conditions at once. Its raw bytes are not equal to admin, so registration accepts it as unique, but NFKC("admin") is exactly "admin", so the admin middleware grants access. The same holds for a fully fullwidth admin (U+FF41 U+FF44 U+FF4D U+FF49 U+FF4E) and for the modifier-letter form ᵃᵈᵐⁱⁿ (U+1D43 U+1D48 U+1D50 U+2071 U+207F).

The comparison after normalization is case-sensitive: a control account Admin (fullwidth, NFKC yields Admin) is denied with HTTP 403, confirming the middleware performs a normalization step and then an exact, case-sensitive match.

Impact

Privilege escalation from an unauthenticated self-registration to full administrator: read access to every user’s personal information and to all orders, coupons, and tickets, plus the ability to create and modify coupons and tickets.

Reproduction

Step 1: Register a username that normalizes to admin but differs in its raw bytes.

POST /api/register HTTP/1.1
Host: lab-1784153734864-1ei5cy.labs-app.bugforge.io
Content-Type: application/json

{"username":"admin","email":"[email protected]","password":"Passw0rd!23","full_name":"pwn","phone":"5550000000","address":"1 St"}

The username is a d m i followed by U+FF4E (fullwidth ); codepoints 0x61 0x64 0x6d 0x69 0xFF4E.

Response:

HTTP/1.1 200 OK
Content-Type: application/json

{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.<payload>.<signature>","user":{"id":121,"username":"admin","email":"[email protected]","full_name":"pwn","phone":"5550000000","address":"1 St"}}

Registration succeeds and issues a session token (abbreviated above). Its HS256 payload decodes to {"id":121,"username":"admin","iat":1784156563}, carrying the raw Unicode username.

Step 2: Call an administrative endpoint with the issued token.

GET /api/admin/stats HTTP/1.1
Host: lab-1784153734864-1ei5cy.labs-app.bugforge.io
Authorization: Bearer <token from Step 1>

Response:

HTTP/1.1 200 OK
X-Flag: bug{TRuy1wzhiOfp9YZVmTcFVLWAf2pIHquJ}
Content-Type: application/json; charset=utf-8
Access-Control-Allow-Origin: *

{"total_users":121,"total_orders":21,"total_revenue":2163.94,"orders_today":21,"revenue_today":2163.94}

The request is authorized as admin and the flag is returned in the X-Flag response header. The same token authorizes /api/admin/users, /api/admin/orders, /api/admin/coupons, and /api/admin/tickets.

Control: a normal account and a Admin account (normalizes to Admin) both return HTTP 403 with no X-Flag header.

Remediation

Fix 1: Normalize the identity before the uniqueness check, and store and compare a single canonical form.

// BEFORE (vulnerable): uniqueness on raw bytes, authorization on a normalized form
// registration
const existing = db.get('SELECT id FROM users WHERE username = ?', [username]);
if (existing) return res.status(409).json({ error: 'username taken' });
db.run('INSERT INTO users (username, ...) VALUES (?, ...)', [username, ...]);

// admin middleware
if (jwt.username.normalize('NFKC') === 'admin') { /* grant admin */ }

// AFTER (secure): one canonical form everywhere
function canonical(name) {
  return name.normalize('NFKC').toLowerCase();
}
// registration
const key = canonical(username);
const existing = db.get('SELECT id FROM users WHERE username_canonical = ?', [key]);
if (existing) return res.status(409).json({ error: 'username taken' });
db.run('INSERT INTO users (username, username_canonical, ...) VALUES (?, ?, ...)',
       [username, key, ...]);

Fix 2: Do not decide authorization from the username string. Resolve the role from the authenticated user id.

// BEFORE (vulnerable)
if (jwt.username.normalize('NFKC') === 'admin') { req.isAdmin = true; }

// AFTER (secure)
const user = db.get('SELECT role FROM users WHERE id = ?', [jwt.id]);
req.isAdmin = user && user.role === 'admin';

Additional recommendations:

  • Apply the same canonicalization to login, password reset, and any other identity lookup, so a normalized form cannot collide with a seeded account through any path.
  • Consider restricting registration to a conservative username character set, rejecting characters that normalize into ASCII letters.

F2: Payment-token race (TOCTOU): one payment, many orders

Severity: Medium CVSS v3.1: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N) CWE: CWE-362 (Concurrent Execution using Shared Resource with Improper Synchronization), CWE-367 (Time-of-check Time-of-use Race Condition) Endpoint: POST /api/orders Authentication required: Yes

Description

Order creation requires a payment_token that has been validated and processed. The order handler checks that the token is unconsumed and then consumes it in separate, non-atomic steps. Submitting many order-creation requests concurrently with the same processed token creates several orders before the token is marked consumed. Sequential reuse of an already-consumed token is correctly rejected; only concurrency defeats the single-use check. The window is reliably winnable rather than a one-off (the reproduction below shows two separate bursts of six orders each), so Attack Complexity is assessed as Low.

Impact

Allows a paying customer to receive several orders from a single payment, with a direct financial cost to the business.

Reproduction

Step 1: Obtain a processed payment token.

POST /api/payment/validate  ->  {"valid":true,"message":"Card validated successfully","payment_token":"8c663307-9572-42f3-ac4c-162df1079b34"}
POST /api/payment/process   ->  {"success":true,"transaction_id":"TXN-1784156224331","message":"Payment processed successfully","payment_token":"8c663307-9572-42f3-ac4c-162df1079b34"}

Step 2: Submit many identical order requests concurrently, reusing the single processed token.

POST /api/orders HTTP/1.1        (x30 concurrent, identical body)
Host: lab-1784153734864-1ei5cy.labs-app.bugforge.io
Authorization: Bearer <token>
Content-Type: application/json

{"items":[{"pizza_name":"Classic Margherita","base_name":"Thin Crust","sauce_name":"Classic Tomato","size":"Medium","toppings":["Tomatoes","Extra Mozzarella"],"quantity":1,"unit_price":10.99,"total_price":10.99}],"delivery_address":"x","phone":"0123456789","payment_method":"card","notes":"","payment_token":"8c663307-9572-42f3-ac4c-162df1079b34","coupon_code":null,"points_to_use":0}

Evidence. The raw per-order responses proving the shared token were not captured before the lab instance was torn down, so this finding is re-derived from the administrative order dump obtained via F1 rather than from first-party per-request captures. That dump shows account user_id=6 producing two bursts of six orders each, ids 4-9 at 2026-07-15 22:49:09 and ids 16-21 at 22:57:04, with orders inside a burst separated by under 30 milliseconds (orders 4 and 5 share the same millisecond). Sequential creation cannot produce a single-second, same-millisecond cluster; the pattern is the signature of a race against the token-consume check, and it matches the reproduction above.

Remediation

Fix: Consume the token atomically and require that exactly one row was updated.

// BEFORE (vulnerable): check then consume, in separate steps
const t = db.get('SELECT consumed FROM payment_sessions WHERE token = ?', [token]);
if (!t || t.consumed) return res.status(400).json({ error: 'invalid payment token' });
db.run('UPDATE payment_sessions SET consumed = 1 WHERE token = ?', [token]);
createOrder(...);

// AFTER (secure): single atomic conditional update; proceed only if it claimed the token
const result = db.run(
  'UPDATE payment_sessions SET consumed = 1 WHERE token = ? AND consumed = 0', [token]);
if (result.changes !== 1) return res.status(400).json({ error: 'invalid payment token' });
createOrder(...);

Additional recommendations:

  • Cap loyalty points earned per order and reconcile point accrual against completed, paid orders.
  • Enforce the single-use guarantee inside a transaction so the token claim and the order insert commit together.

OWASP Top 10 Coverage

  • A01:2021 Broken Access Control: F1 grants administrative access because authorization is decided from a normalized username an attacker controls rather than from the server-side role. F2 lets a single-use payment token be spent more than once under concurrency.
  • A04:2021 Insecure Design: both findings are design-level. F1 normalizes identity for authorization but not for uniqueness, a two-tier mismatch. F2 checks and consumes the payment token in separate steps rather than atomically.
  • A07:2021 Identification and Authentication Failures: F1 is an identity collision, where two usernames with different raw bytes resolve to the same privileged identity after Unicode normalization.

Tools Used

Tool Purpose
curl Direct HTTP requests against the API (no intercepting proxy used this engagement)
Python 3 (unicodedata) Verify NFKC normalization of candidate usernames against admin
Python 3 (scripted requests) Concurrent order submissions for the payment-token race
JWT decoder Inspect the issued token payload

References

  • CWE-285: Improper Authorization: https://cwe.mitre.org/data/definitions/285.html
  • CWE-289: Authentication Bypass by Alternate Name: https://cwe.mitre.org/data/definitions/289.html
  • CWE-362: Race Condition: https://cwe.mitre.org/data/definitions/362.html
  • CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition: https://cwe.mitre.org/data/definitions/367.html
  • Unicode Standard Annex #15, Normalization Forms: https://unicode.org/reports/tr15/
  • OWASP Top 10 (2021): https://owasp.org/Top10/

Part 2: Notes / Knowledge

Key Learnings

  • Include Unicode-normalization collisions in the privilege-escalation checklist for any username or identity registration. When an application lets you choose a username and there is a privileged account whose name you know, and the obvious escalation paths (role mass-assignment, token forging, injection) are exhausted, register an identity that NFKC-normalizes or case-folds to the privileged name but is not byte-equal. Fullwidth forms (admin, U+FF41 onward) and modifier letters (ᵃᵈᵐⁱⁿ, U+1D43 / U+2071 / U+207F) fold under NFKC, and the compatibility ligature folds to fi under NFKC. A few families need case folding rather than NFKC: ß folds to ss and the Kelvin sign (U+212A) folds to lowercase k only under case folding, since NFKC alone maps U+212A to capital K. Then hit the privileged endpoint with that identity’s token, and confirm the mechanism with a control that normalizes to a different case (here Admin yields Admin and was denied), which tells you the comparison applies a normalization step and then an exact, case-sensitive match. The root cause generalizes to any system that normalizes identity for one decision but not another: single sign-on, email-based login, password reset, deduplication, and multi-tenant keys.

  • A self-reported role or privilege field is not proof the authorization layer is role-based. Verify at the privileged endpoint, and consider that authorization may key on a different attribute. When verify-token or a /me response shows role: "user", treat that as display data, not a source of truth for authorization. Test the privileged endpoint directly with the candidate token; if it grants access despite the reported role, the middleware keys on something else, whether a normalized username, a token id, or a header, and the next job is to enumerate what. Here every malicious account reported role: "user" for roughly ten tests, which made registration look secure, while the admin middleware was actually comparing the normalized username to admin. The field an application shows you and the field it enforces on can be different, and equality of the first tells you nothing about the second.

  • When you hold privileged access but find no flag or secret in response bodies, dump the response headers. After reaching an administrative state, if body and JSON searches (including a broad bug{} / flag{} regex) return nothing, re-request the privileged endpoints capturing full headers and grep the headers before assuming the win condition is some other action or endpoint. Secret delivery is a header write as often as a body field. Here the flag lived only in X-Flag: bug{...} on every /api/admin/* response and was absent from all bodies, and a body-only search structurally misses it.


Failed Approaches

Approach Result Why It Failed
JWT alg=none downgrade Rejected Algorithm pinned to HS256 server-side
Weak-secret crack (rockyou exhausted) No hit Strong signing secret
Mass-assignment of role / id on register and profile Account stayed role: "user" Server ignores client-supplied privileged fields
Direct admin access and all HTTP verbs on /api/admin/* 403 Middleware enforces on every verb
IDOR on orders and tickets 403 / scoped Ownership-scoped access
Single-order price tampering and order-body override Rejected Server recomputes; charged equals calculated
Coupon enumeration and stacking No stack Single coupon enforced
SQL injection (login, pizza_name, coupon, register INSERT, second-order via username) No injection Parameterized queries
File upload abuse Blocked Type and extension validated, stored under a hashed name
Password spray No hit No weak seeded credentials
Hidden-route fuzzing Ambiguous SPA catchall serves index.html, masking status; fuzz by Content-Type
Control: Admin (normalizes to Admin) 403 Comparison after normalization is case-sensitive, confirming the F1 mechanism

Tags: #webapp #privilege-escalation #unicode-normalization #broken-access-control #race-condition #bugforge Document Version: 1.0 Last Updated: 2026-07-16

#bugforge #webapp #privilege-escalation #unicode-normalization #broken-access-control #race-condition #cwe-285 #cwe-289 #cwe-362