Frontend Labs
⋯

Chapter 7 — Unicode, normalisation, and the bugs they cause

In one sentence

Two strings that look identical to a human can be different bytes — and two systems that disagree about which is which is a security vulnerability.

The problem: visual identity is not byte identity

   'é' can be encoded TWO ways in Unicode:

     U+00E9              LATIN SMALL LETTER E WITH ACUTE   -> C3 A9
     U+0065 U+0301       'e' + COMBINING ACUTE ACCENT      -> 65 CC 81

   They render identically. They are different bytes.
   A byte comparison says they are different. A human says they are the same.

Normalisation converts to a canonical form so comparison works:

Form Does
NFC Compose: prefer the single combined character. Use this by default.
NFD Decompose: prefer base character + combining marks
NFKC / NFKD Also fold "compatibility" characters — fi → fi, ① → 1, full-width → ASCII. Lossy.

Why an attacker cares

Attack 1 — Account confusion. If registration normalises but login doesn't (or vice versa), you can register a "different" account that logs in as an existing one, or vice versa.

Attack 2 — Filter bypass through NFKC. This is the sharp one:

   A filter blocks the string "<script>".

   An attacker sends:   <script>      (full-width characters, U+FF1C/U+FF1E)

   The filter compares bytes -> no match -> ALLOWED.
   Later, some component normalises with NFKC:
        <  ->  <
        >  ->  >
   The output is now  <script>  in the page.

The rule that prevents this: normalise before you validate, never after.

Attack 3 — Homoglyphs. Different characters that look the same:

   'a'  U+0061  Latin small letter A
   'а'  U+0430  CYRILLIC small letter A     <-- visually identical

   раypal.com   is not   paypal.com

This is the basis of IDN homograph phishing. Browsers defend by showing punycode (xn--...) when a domain mixes scripts suspiciously.

Attack 4 — Invisible characters. Zero-width spaces (U+200B), bidirectional overrides (U+202E), and Unicode "tag" characters (U+E0000 block) are invisible in most renderings but present in the bytes. Uses:

  • Bypassing keyword filters
  • "Trojan Source" — bidi overrides make source code display differently from how it compiles, hiding a backdoor in plain sight during code review
  • Hiding prompt-injection instructions in text an AI system reads

The defensive rules

   1. NORMALISE ON INPUT, at the trust boundary. NFC for general text.
   2. Normalise BEFORE validating, not after.
   3. Strip invisible/control characters explicitly. Don't hope.
   4. For usernames and identifiers: restrict to an ALLOWLIST of characters
      (e.g. a-z 0-9 . _ -) rather than trying to block bad ones.
   5. Compare identifiers case-folded AND normalised, consistently, everywhere.
   6. Set explicit encodings: Content-Type with charset, database column
      collation, and your language's string handling.

Rule 4 is worth emphasising: allowlists beat blocklists, always. You can enumerate what's permitted; you cannot enumerate what's malicious.

Practice (25 min)

cd ~/sec-lab/scratch

python3 - <<'PY'
import unicodedata as ud

# 1. Two 'identical' strings
a = "café"                      # composed
b = "café"                # decomposed
print("look the same? ", a, b)
print("equal bytes?   ", a == b)
print("after NFC?     ", ud.normalize("NFC", a) == ud.normalize("NFC", b))
print("byte lengths:  ", len(a.encode()), len(b.encode()))

# 2. NFKC bypass -- the dangerous one
attack = "<script>"    # full-width < and >
print("\nfilter sees:   ", repr(attack), " blocked? ", "<script>" in attack)
print("after NFKC:    ", repr(ud.normalize("NFKC", attack)),
      " now dangerous? ", "<script>" in ud.normalize("NFKC", attack))

# 3. Homoglyphs
print("\npaypal (latin):  ", "paypal.com".encode())
print("paypal (cyrillic a):", "pаypal.com".encode())
print("equal? ", "paypal.com" == "pаypal.com")

# 4. Invisible characters
sneaky = "admin​"           # trailing zero-width space
print("\nlooks like 'admin'? ", sneaky)
print("is 'admin'?         ", sneaky == "admin")
print("stripped:           ", "".join(c for c in sneaky if ud.category(c)[0] != "C"))
PY

Break it: write a "username validator" that lowercases and compares to a blocklist containing admin. Then defeat it with:

  • Admin with a zero-width space
  • аdmin with a Cyrillic а
  • ADMIN in full-width characters

Then fix it with: NFKC normalise → strip non-printing → allowlist [a-z0-9_-] → compare.

Common confusions

  • "I'll just use NFKC everywhere, it's the most aggressive." NFKC is lossy — it destroys meaningful distinctions (x² → x2). Use NFC for storage and display; use NFKC only for comparison keys like usernames.
  • "Emoji are the security problem." Emoji are well-behaved. Invisible characters and homoglyphs are the problem.
  • "Our database handles this." Databases have their own collation rules that may differ from your application's. Normalise in the application, at the boundary, once.

Check yourself

  1. Give two Unicode representations of the same visible character.
  2. Why must you normalise before validating?
  3. What is a homoglyph attack?
  4. Why are allowlists better than blocklists for usernames?

Further reading

  • Report: "Trojan Source: Invisible Vulnerabilities" (Boucher & Anderson, 2021) — bidi attacks on source code. Genuinely unsettling, and short.
  • Standard: Unicode UTS #39, "Unicode Security Mechanisms" — the official guidance on confusables and restriction levels.
  • Tool: util.unicode.org/UnicodeJsps/confusables.jsp — see which characters are confusable with which.

End of Part 2 — Milestone check

  • I can explain Base64 without saying the word "encrypt"
  • I have decoded a JWT payload and understood the implication
  • I know why encoding provides no integrity protection
  • I can explain an NFKC filter bypass
  • I know to normalise before validating

Generated from real-life-guide.md · 8 Oct 2026 16:36