SmallMart: Unicode Case Mapping Bypass
Executive Summary
Overall Risk Rating: π΄ Critical
Key Findings:
- 1 Critical Unicode case mapping inconsistency enabling admin access bypass (CWE-178)
- 1 High identity-based access control using username instead of role (CWE-863)
- 1 High plaintext password storage (CWE-256)
- 1 Medium default Flask secret key in source (CWE-798)
Business Impact: Exploiting the difference between Pythonβs str.lower() and re.IGNORECASE Unicode handling allows an attacker to register a username that bypasses admin restrictions and gain full admin panel access.
Objective
Identify and exploit a vulnerability in the SmallMart application by reviewing the provided source code. Gain access to the admin panel and retrieve the flag.
Initial Access
# Target Application
URL: https://smallmart.hackinghub.io
# Auth: Flask session cookie
Cookie: session=<flask-signed-cookie>
Key Findings
Critical & High-Risk Vulnerabilities
- Unicode Case Mapping Inconsistency -
str.lower()andre.IGNORECASEuse different Unicode folding rules (CWE-178) - Identity-Based Access Control - Admin check relies on username string matching rather than role-based authorization (CWE-863)
- Plaintext Password Storage - Passwords stored and compared without hashing (CWE-256)
- Default Secret Key - Flask secret key falls back to hardcoded value in source (CWE-798)
CVSS v3.1 Score for Unicode Bypass: 8.6 (High)
| Metric | Value |
|---|---|
| Attack Vector | Network (AV:N) |
| Attack Complexity | Low (AC:L) |
| Privileges Required | None (PR:N) |
| User Interaction | None (UI:N) |
| Scope | Changed (S:C) |
| Confidentiality | High (C:H) |
| Integrity | None (I:N) |
| Availability | None (A:N) |
Enumeration Summary
Application Analysis
Target Endpoints Discovered:
| Component | Path | Description |
|---|---|---|
| Store | / |
Product listing (15 items) |
| Register | /register |
User registration with admin username block |
| Login | /login |
Authentication with admin account lockout |
| Admin Panel | /admin |
Flag display, requires is_admin_username() check |
Summary:
- Framework: Flask with Jinja2 templating
- Database: SQLite
- Authentication: Flask session cookies (server-signed)
- Authorization: Username-based (not role-based)
Attack Chain Visualization
βββββββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββββββ
β Source Code Review ββββββΆβ Identify Check ββββββΆβ Find Unicode Char β
β Spot 3 different β β Inconsistency β β Δ° (U+0130) where β
β admin checks β β lower() vs re β β lower()β re.IGNORE β
βββββββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββββββ
β Access /admin βββββββ Login as admΔ°n βββββββ Register admΔ°n β
β Flag Retrieved β β Bypasses lock β β Bypasses lower() β
β β β row != "admin" β β check β
βββββββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββββββ
Attack Path Summary:
- Source Code Review: Identify three inconsistent admin username checks
- Unicode Analysis: Find Δ° (U+0130) which maps differently under
lower()vsre.IGNORECASE - Register: Create account with username
admΔ°n- bypasseslower()check - Login: Authenticate as
admΔ°n- bypasses exact match lockout - Admin Access: Visit
/admin-re.IGNORECASEmatches Δ° to i via simple tolower - Flag: Retrieved from admin panel
Exploitation Path
Step 1: Source Code Analysis - Three Inconsistent Checks
The application uses three different methods to handle the admin username:
| Location | Check | Method | Purpose |
|---|---|---|---|
| Registration | username.lower() == "admin" |
Full Unicode lowercase | Block admin registration |
| Login | row["username"] == ADMIN_USERNAME |
Exact string match | Lock admin account |
| Admin panel | re.match(r"^admin$", name, re.IGNORECASE) |
Simple Unicode tolower | Grant admin access |
The critical insight: Pythonβs str.lower() uses full Unicode case mapping (can produce multiple characters), while re.IGNORECASE uses simple Unicode tolower (always single character output).
Step 2: Find the Unicode Gap - Δ° (U+0130)
The character Δ° (Latin Capital Letter I With Dot Above, U+0130) behaves differently under each mapping:
| Operation | Input | Output | Explanation |
|---|---|---|---|
str.lower() |
"admΔ°n" |
"admiΜn" (6 chars) |
Δ° becomes i + combining dot above (U+0307) |
Py_UNICODE_TOLOWER (re engine) |
Δ° |
i |
Simple mapping: Δ° maps to i (single char) |
This means:
"admΔ°n".lower() == "admin"β False (6 chars vs 5) β passes registration"admΔ°n" == "admin"β False β passes login lockre.match(r"^admin$", "admΔ°n", re.IGNORECASE)β True β grants admin
Step 3: Register with Δ°
Registered a new account with username admΔ°n (Δ° = U+0130).
The registration check username.lower() == "admin" evaluates "admiΜn" == "admin" which is False due to the extra combining dot above character making it 6 characters. Registration succeeds.
Step 4: Login
Logged in with username admΔ°n. The login handler:
- Finds the row in SQLite (case-sensitive
WHERE username = ?matches exactlyadmΔ°n) - Checks
row["username"] == "admin"β"admΔ°n" == "admin"β False β passes the lock - Sets
session["user"] = "admΔ°n"
Step 5: Access Admin Panel β Flag
Navigated to /admin. The admin check:
def is_admin_username(name: str) -> bool:
return bool(re.match(r"^admin$", name or "", flags=re.IGNORECASE))
The re engine compares character-by-character using Py_UNICODE_TOLOWER:
aβaβdβdβmβmβΔ°βi(simple tolower) matches patterniβnβnβ
Match! Admin access granted. Flag retrieved.
Flag / Objective Achieved
β Objective: Bypassed admin restrictions via Unicode case mapping inconsistency
β Flag: Retrieved from admin panel
Key Learnings
Unicode Case Mapping: lower() vs re.IGNORECASE
| Method | Type | Δ° (U+0130) Result | Used By |
|---|---|---|---|
str.lower() |
Full Unicode lowercase | i + \u0307 (2 chars) |
Python string operations |
str.casefold() |
Aggressive case folding | i + \u0307 (2 chars) |
Caseless string comparison |
Py_UNICODE_TOLOWER |
Simple lowercase mapping | i (1 char) |
Python re with IGNORECASE |
The Python docs even note this: βwhen Unicode patterns [a-z] or [A-Z] are used with IGNORECASE, they will match 4 additional non-ASCII letters: Δ° (U+0130), Δ± (U+0131), ΕΏ (U+017F), and K (U+212A)β
Identity vs Role-Based Access Control
- The session stored the username (
session["user"] = row["username"]), not a role - Admin check matched the username against a regex pattern
- If a role-based system was used (
session["role"] = "admin"), the Unicode trick wouldnβt matter because the role would only be set via a DB lookup against the real"admin"account
Defense-in-Depth Failure
- Three separate checks all used different comparison methods
- Each check individually seemed reasonable
- The inconsistency between them created the exploitable gap
Failed Approaches
Approach 1: Flask Session Forgery (Default Key)
flask-unsign --sign --cookie '{"user":"admin"}' --secret 'fake_key_for_testing'
Result: β Failed - Deployed app uses a different SECRET_KEY set via environment variable
Approach 2: Brute Force Secret Key (rockyou.txt)
flask-unsign --unsign --cookie '<token>' --wordlist /usr/share/wordlists/rockyou.txt
Result: β Failed - Secret key not found after 14M+ attempts
Approach 3: Direct Admin Login
Result: β Failed - row["username"] == "admin" exact match blocks login with βAccount lockedβ
Approach 4: Register as βAdminβ (ASCII Case Variant)
Result: β Failed - username.lower() == "admin" blocks all ASCII case combinations
Tools Used
| Tool | Purpose | Usage |
|---|---|---|
| flask-unsign | Session cookie decode/brute force | Decoded session structure, attempted secret key brute force |
| Browser | Registration and login | Registered with Δ° character, accessed /admin |
Remediation
1. Unicode Case Mapping Bypass (CVSS: 8.6 - High)
Issue: str.lower() and re.IGNORECASE use different Unicode folding, allowing registration bypass.
CWE Reference: CWE-178 - Improper Handling of Case Sensitivity
Fix:
# BEFORE (Vulnerable) - Three inconsistent checks
# Registration: username.lower() == "admin"
# Login: row["username"] == ADMIN_USERNAME
# Admin: re.match(r"^admin$", name, re.IGNORECASE)
# AFTER (Secure) - Normalize Unicode + use casefold() consistently
import unicodedata
def normalize_username(username):
# NFKC normalization + casefold for consistent comparison
return unicodedata.normalize('NFKC', username).casefold()
# Registration check
if normalize_username(username) == "admin":
flash("This username is reserved.", "error")
# Store normalized username
db.execute("INSERT INTO users (username, password) VALUES (?, ?);",
(normalize_username(username), hashed_password))
2. Identity-Based Access Control (CVSS: 7.5 - High)
Issue: Admin access determined by matching username string, not a dedicated role field.
CWE Reference: CWE-863 - Incorrect Authorization
Fix:
# Use role-based access control
session["user_id"] = row["id"]
session["role"] = row["role"] # 'admin' or 'user'
# Admin check
if session.get("role") != "admin":
flash("Access denied.", "error")
3. Plaintext Password Storage (CVSS: 7.5 - High)
Issue: Passwords stored and compared as plaintext.
CWE Reference: CWE-256 - Plaintext Storage of a Password
Fix:
from werkzeug.security import generate_password_hash, check_password_hash
# Registration
hashed = generate_password_hash(password)
# Login
if not check_password_hash(row["password"], password):
flash("Invalid credentials.", "error")
OWASP Top 10 Coverage
- A01:2021 - Broken Access Control (admin check bypass via Unicode inconsistency)
- A02:2021 - Cryptographic Failures (plaintext password storage)
- A04:2021 - Insecure Design (username-based identity instead of role-based authorization)
- A07:2021 - Identification and Authentication Failures (inconsistent input validation)
References
Unicode & Case Mapping:
CWE References:
Tags: #unicode #case-mapping #flask #session #access-control #hackinghub