BugForge - Daily - Cheesy Does It

6 min read

Daily - Cheesy Does It

Vulnerabilities Covered

Unicode Normalization Collision Homoglyph / Confusable Username Broken Access Control Privilege Escalation Improper Input Validation

Summary

The Cheesy Does It admin dashboard is protected by a role check that refuses ordinary accounts with Admin access required. The JWT is a strong HS256 token that cannot be forged or cracked, and the registration form whitelists the role field, so the usual privilege-escalation routes are dead ends. The real flaw is a mismatch in how the username is compared in two different places: registration enforces uniqueness on the raw byte string, while the admin authorization layer NFKC-normalizes the username before deciding whether the caller is the administrator. Registering a Unicode homoglyph of admin, such as the fullwidth form admin (U+FF41 + dmin), sails past the uniqueness check as a brand-new, byte-distinct account, yet normalizes back to admin the moment the app authorizes an admin request. The freshly created account is therefore treated as the real administrator and GET /api/admin/stats returns the flag in the X-Flag response header.

1

Application Analysis

Register an account and explore the Cheesy Does It storefront. The app is a React SPA backed by a JSON API, with the usual customer features (menu, custom pizza builder, checkout, loyalty points, support tickets) plus an Admin area that ordinary users cannot open.

Cheesy Does It storefront

2

Mapping the Admin Gate

Send a request to any admin endpoint with a normal account’s token. Every route under /api/admin/* is refused server-side with a 403 and Admin access required, so the gate is enforced on the API, not just hidden in the UI.

GET /api/admin/stats HTTP/1.1
Host: lab-xxxx.labs-app.bugforge.io
Authorization: Bearer <normal-user-jwt>
{ "error": "Admin access required" }

Admin endpoint refused for a normal user

The token itself is a clean HS256 JWT whose payload is only { "id", "username", "iat" }; the role is resolved server-side from the database. alg:none is rejected, the signing secret survives wordlist and mask cracking, and PUT /api/profile / POST /api/register both whitelist the role field. The business-logic bugs elsewhere in the app (coupon/points/refund abuse) are deliberate rabbit holes and never disclose the flag. Admin access is the only way in, so the question becomes: how does the server decide an account is the admin?

3

The Key Observation: Two Different Username Comparisons

Try to register the username admin directly. It is rejected because the account already exists:

POST /api/register HTTP/1.1
Content-Type: application/json

{"username":"admin","email":"admin@example.com","password":"Admin"}
{ "error": "Username or email already exists" }

Registering literal admin is rejected

So an admin account exists, and uniqueness is enforced on the exact string we send. The insight is that string equality at registration and string equality during authorization do not have to use the same rules. If the authorization layer canonicalizes the username first, we can supply a value that is a different string to the uniqueness check but the same string after canonicalization. Unicode gives us exactly that: many characters collapse to plain ASCII under NFKC normalization.

The character is U+FF41 (FULLWIDTH LATIN SMALL LETTER A), a completely different code point from ASCII a (U+0061), but:

>>> unicodedata.normalize("NFKC", "admin") == "admin"
True
4

Registering the Homoglyph Account

Register admin (fullwidth a + dmin). To the uniqueness check this is a brand-new byte string, so registration succeeds and hands back a valid JWT:

POST /api/register HTTP/1.1
Content-Type: application/json

{"username":"admin","email":"admin@example.com","password":"Admin","full_name":"Admin","phone":"","address":""}
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": { "id": 5, "username": "admin", "email": "admin@example.com" }
}

Registering the fullwidth homoglyph succeeds

Confirm what the app thinks this account is with GET /api/verify-token using the new token, it is still just a normal user, id 7, role: user:

{ "user": { "id": 5, "username": "admin", "role": "user", "loyalty_points": 0 } }

verify-token still reports role user

This is the tell that the escalation is not about our stored role, it is about how the username string is interpreted when an admin action is authorized.

5

Retrieving the Flag

Call the admin-only endpoint with the homoglyph account’s token. The authorization layer NFKC-normalizes the JWT’s username claim (adminadmin), matches it to the privileged administrator, and serves the dashboard. The flag is returned in the X-Flag response header:

GET /api/admin/stats HTTP/1.1
Host: lab-xxxx.labs-app.bugforge.io
Authorization: Bearer <admin-jwt>
HTTP/1.1 200 OK
X-Flag: bug{j6iIR8Hxss4xUN61Cc6bwa1cmJSIREWS}

{"total_users":5,"total_orders":0,"total_revenue":0,"orders_today":0,"revenue_today":0}

Admin stats returns 200 and the X-Flag header

A 403 for a normal account becomes a 200 for a byte-distinct account whose name merely normalizes to admin, no password, no token forgery, no role tampering.

Why NFKC Normalization Matters

Unicode defines several normalization forms. NFKC (Normalization Form KC, Compatibility Composition) folds “compatibility” characters, visually or semantically equivalent variants, down to a canonical base form. Fullwidth Latin letters, ligatures, superscripts, circled letters, and many look-alikes all collapse to plain ASCII:

  • admin (fullwidth) → admin
  • ⓐdmin (circled a) → depends on form; several compatibility variants of a map to a
  • (ff ligature) → ff

Frameworks and databases apply normalization inconsistently. A username can be stored and uniqueness-checked as raw UTF-8 bytes in one place, then normalized in another (an ORM helper, a .normalize("NFKC") call, a case/accent-insensitive collation, or a comparison against a hardcoded "admin"). When the write path and the authorization path disagree on the canonical form, an attacker can register a name that is unique on write but collides with a privileged name on read. This is an old, well-documented class of bug, GitHub, Django, and Spotify have all shipped variants of it, yet it is easy to miss because both comparisons look correct in isolation.

Impact
  • Full administrative access from an unauthenticated starting point, a single registration request is enough
  • Disclosure of the admin dashboard (/api/admin/stats, users, orders, coupons, tickets) and the flag
  • No password, JWT secret, or role modification required, the attacker never needs the real admin’s credentials
  • The colliding account is trivially reproducible, any homoglyph of admin (fullwidth, mixed, or other NFKC-equivalent code points) works, so blocklisting a single character does not fix it
  • The technique generalizes to any privileged name the application trusts by string (root, superadmin, service accounts)
Vulnerability Classification
  • OWASP Top 10: A01:2021 - Broken Access Control; A07:2021 - Identification and Authentication Failures
  • OWASP API Security Top 10: API5:2023 - Broken Function Level Authorization
  • Vulnerability Type: Privilege escalation via Unicode normalization / homoglyph username collision, uniqueness enforced on the raw string while authorization compares the normalized string
  • Attack Surface: POST /api/register (write path) + GET /api/admin/* (authorization path)
  • CWE: CWE-289 - Authentication Bypass by Alternate Name; CWE-178 - Improper Handling of Case Sensitivity (analogous canonicalization failure); CWE-289/CWE-1007 - Insufficient Visual Distinction of Homoglyphs; CWE-284 - Improper Access Control
Root Cause

The application canonicalizes usernames inconsistently across trust boundaries. At registration, uniqueness is checked against the raw, un-normalized input, so admin and admin are considered different accounts. During admin authorization, the username derived from the JWT is NFKC-normalized (or compared under a normalizing collation / against a literal "admin") before the privilege decision, so admin and admin are considered the same account. Because the write path treats the strings as distinct and the authorization path treats them as equal, an attacker can register a byte-distinct name that the authorization path resolves to the administrator. The role stored for the attacker’s row is irrelevant, the privilege check keys off the normalized username string rather than the authenticated account’s stored identity/role.

Remediation
  • Normalize usernames to a single canonical form (e.g. NFKC) once, at registration, and store the canonical value; enforce uniqueness on that canonical form so a homoglyph of an existing name is rejected on sign-up
  • Make every comparison, uniqueness, login, and authorization, operate on the identical canonical representation; never normalize on one path but not another
  • Authorize on a stable, immutable identifier (user id and a server-side role) rather than on a mutable/attacker-influenced username string
  • Restrict usernames to a conservative allowlist of characters (e.g. [a-zA-Z0-9_.-]) and reject mixed-script or confusable inputs (Unicode UTS-39 confusable/skeleton checks)
  • Reserve privileged names (admin, root, superadmin) and block registration of any name whose canonical/skeleton form collides with them
  • Add tests that assert register("admin") is rejected and that a homoglyph account cannot reach any /api/admin/* route

More Cheesy Does It dailies from this series:

Zw4rts

© 2026 Zw4rts. All rights reserved.