Security

Text Encoding and Unicode

Course 1 Β· Chapter 9 Β· Security

Everything that makes Unicode useful also makes it a place to hide things. Characters from different alphabets can look identical. Some characters are invisible. Others change the direction in which text is displayed, so that what you read on screen is not the order in which a program reads it. This chapter shows the main tricks, with code to detect them, and the rule that ties the course together: validate text only after you have normalized it. The programs here are defensive checks, written to be run on suspicious text, not to produce it.

What was run, and what was not
Every program in this chapter was run on Python 3.14 and the output shown is the real output. Facts come from the Unicode Technical Standard #39 (security mechanisms), the Unicode Bidirectional Algorithm annex (UAX #9) and the Trojan Source project page, each read through a summarising tool (see "Not Yet Verified" at the end). One further older security report I tried to use is no longer maintained and held no usable content. Details not checked against a source are marked "(check)". The checks below are teaching examples, not a complete security solution.

Look-Alikes: Homoglyphs

Different scripts contain letters that look almost the same. The Cyrillic small letter a (U+0430) and the Latin small letter a (U+0061) are different code points, so a string using one is a different string. UTS #39 gives the classic example of "paypal" with the Cyrillic a, and calls such pairs confusables. Normalization does not help, because these characters are not canonically or compatibly equivalent: they are simply different letters that look alike.

import unicodedata as ud pairs = [("Latin a", "a"), ("Cyrillic a", "\u0430"), ("Greek omicron", "\u03bf"), ("Latin o", "o"), ("Cyrillic o", "\u043e")] for label, ch in pairs: print(f"{label:14} U+{ord(ch):04X} {ud.name(ch)}") real = "paypal" fake = "p\u0430ypal" print(real == fake, real, fake) print(len(real), len(fake), [f"{ord(c):04X}" for c in fake][:2]) print(real.isascii(), fake.isascii())
Latin a U+0061 LATIN SMALL LETTER A Cyrillic a U+0430 CYRILLIC SMALL LETTER A Greek omicron U+03BF GREEK SMALL LETTER OMICRON Latin o U+006F LATIN SMALL LETTER O Cyrillic o U+043E CYRILLIC SMALL LETTER O False paypal pΠ°ypal 6 6 ['0070', '0430'] True False

On screen the two words in line six look identical (in most fonts, check), yet they compare unequal and the second is not ASCII. Both have six characters, so a length check sees nothing. Two defences are common. The simplest is to restrict identifiers such as user names to ASCII, or to a single script. UTS #39 describes graduated restriction levels (from ASCII-only and single-script up to unrestricted), and a detection method: reduce each string to a skeleton by mapping every character to a prototype, and treat strings with equal skeletons as confusable. The standard stresses that skeletons are for internal comparison and should not be shown or stored in place of the original. A small mixed-script check, a rough version of the first idea, uses the Unicode character name to find the script:

import unicodedata as ud def scripts(s): found = set() for c in s: if c.isalpha(): found.add(ud.name(c).split()[0]) return sorted(found) for word in ["paypal", "p\u0430ypal", "\u043f\u0440\u0438\u0432\u0435\u0442", "caf\u00e9"]: sc = scripts(word) print(f"{ascii(word):22} scripts={sc} mixed={len(sc) > 1}")
'paypal' scripts=['LATIN'] mixed=False 'p\u0430ypal' scripts=['CYRILLIC', 'LATIN'] mixed=True '\u043f\u0440\u0438\u0432\u0435\u0442' scripts=['CYRILLIC'] mixed=False 'caf\xe9' scripts=['LATIN'] mixed=False

The mixed-script word is caught; an all-Cyrillic word is not flagged, because it is a legitimate word in one script (a fully Cyrillic string made only of look-alike letters would still pass: another reason to prefer a real library implementing UTS #39 for anything important, check what is available for your language). My first-word-of-the-name trick is a crude stand-in: UTS #39 uses the Unicode Script_Extensions property and treats Common and Inherited characters as matching any script, which this sketch ignores.

Invisible Characters

Unicode has characters that take no space on screen. Python classifies them as category Cf (format). Some are essential in some languages (the zero-width joiner of Text Encoding and Unicode 6 builds emoji sequences), but in a user name or a file name they are a way to make two different strings look the same:

import unicodedata as ud invisible = ["\u200b", "\u200c", "\u200d", "\u2060", "\ufeff", "\u00ad"] for c in invisible: print(f"U+{ord(c):04X} {ud.name(c):24} category={ud.category(c)} isprintable={c.isprintable()}") name1 = "admin" name2 = "admin\u200b" print(name1 == name2, len(name1), len(name2)) print(ascii(name2))
U+200B ZERO WIDTH SPACE category=Cf isprintable=False U+200C ZERO WIDTH NON-JOINER category=Cf isprintable=False U+200D ZERO WIDTH JOINER category=Cf isprintable=False U+2060 WORD JOINER category=Cf isprintable=False U+FEFF ZERO WIDTH NO-BREAK SPACE category=Cf isprintable=False U+00AD SOFT HYPHEN category=Cf isprintable=False False 5 6 'admin\u200b'

"admin" with a trailing zero-width space prints as "admin" but is a different, longer string. You have already met two of these characters: U+FEFF, the byte order mark (Text Encoding and Unicode 4), and the joiner. UTS #39 removes Default_Ignorable characters when building skeletons for exactly this reason. Always display suspicious strings through ascii() or a similar escaping view when debugging.

Direction Changers: Bidirectional Controls

Scripts such as Arabic and Hebrew run right to left. The Unicode Bidirectional Algorithm (UAX #9) therefore defines invisible control characters that set the direction of a stretch of text. Per UAX #9 these are: embeddings (LRE U+202A, RLE U+202B), overrides (LRO U+202D, RLO U+202E), the terminator PDF (U+202C), and the isolates LRI U+2066, RLI U+2067, FSI U+2068 with their terminator PDI U+2069. The standard itself advises avoiding overrides where possible because of security concerns.

The security problem, named Trojan Source by researchers Nicholas Boucher and Ross Anderson (University of Cambridge; USENIX Security 2023), is that compilers and interpreters "adhere to the logical ordering of source code, not the visual order", so bidirectional controls hidden in comments or strings can make code look different in a review from what runs. Their page lists CVE-2021-42574 for the bidirectional attack and CVE-2021-42694 for a related homoglyph attack, and recommends that tools warn or error on unterminated bidi controls and on confusable identifiers, and that editors and repository front ends make such characters visible. A detector is short:

import unicodedata as ud BIDI = {0x202A: "LRE", 0x202B: "RLE", 0x202C: "PDF", 0x202D: "LRO", 0x202E: "RLO", 0x2066: "LRI", 0x2067: "RLI", 0x2068: "FSI", 0x2069: "PDI"} for cp, abbr in BIDI.items(): print(f"U+{cp:04X} {abbr} {ud.name(chr(cp))} bidi class={ud.bidirectional(chr(cp))}") def bidi_controls(s): return [(i, BIDI[ord(c)]) for i, c in enumerate(s) if ord(c) in BIDI] line = "access = 'user\u202e\u2066 // check admin\u2069\u2066'" print(bidi_controls(line)) print(ascii(line))
U+202A LRE LEFT-TO-RIGHT EMBEDDING bidi class=LRE U+202B RLE RIGHT-TO-LEFT EMBEDDING bidi class=RLE U+202C PDF POP DIRECTIONAL FORMATTING bidi class=PDF U+202D LRO LEFT-TO-RIGHT OVERRIDE bidi class=LRO U+202E RLO RIGHT-TO-LEFT OVERRIDE bidi class=RLO U+2066 LRI LEFT-TO-RIGHT ISOLATE bidi class=LRI U+2067 RLI RIGHT-TO-LEFT ISOLATE bidi class=RLI U+2068 FSI FIRST STRONG ISOLATE bidi class=FSI U+2069 PDI POP DIRECTIONAL ISOLATE bidi class=PDI [(14, 'RLO'), (15, 'LRI'), (31, 'PDI'), (32, 'LRI')] "access = 'user\u202e\u2066 // check admin\u2069\u2066'"

The names and positions are the printed proof. Without the detector, the line would simply display with its pieces reordered. Legitimate right-to-left text uses these controls too, so a detector should flag them for human review in source code, file names and identifiers, not delete them everywhere.

Order of Operations: Normalize First, Then Validate

Several earlier parts of this course have pointed at one rule. If you check text for forbidden content and then normalize it, the normalization can create what the check was meant to prevent. Compatibility normalization turns fullwidth angle brackets into ordinary ones, so a filter that looks for ASCII markup misses the fullwidth version, and the later step produces the dangerous text:

import unicodedata as ud def blocked(s): return "<script" in s.lower() attack = "\uff1cscript\uff1e" # fullwidth < and > print(blocked(attack)) after = ud.normalize("NFKC", attack) print(ascii(after), blocked(after)) def safe_check(s): return blocked(ud.normalize("NFKC", s)) print(safe_check(attack))
False '<script>' True True

The first False is the bug: the filter passed text that becomes <script> after NFKC. Checking the normalized form (last line) catches it. The same logic applies to the overlong UTF-8 forms of Text Encoding and Unicode 2: decode strictly first, then check the decoded text, never the raw bytes. (A blocklist is a weak defence on its own; the point here is only the ordering. Better defences allow known-good input and escape output for its context, check.) Combining all the checks of this chapter into one reporting function:

import unicodedata as ud BIDI = set(range(0x202A, 0x202F)) | set(range(0x2066, 0x206A)) def problems(s): found = [] n = ud.normalize("NFC", s) if n != s: found.append("not NFC") if any(ud.category(c) == "Cf" and ord(c) not in (0x200D, 0x200C) for c in s): found.append("invisible format character") if any(ord(c) in BIDI for c in s): found.append("bidi control") scripts = {ud.name(c).split()[0] for c in s if c.isalpha()} if len(scripts) > 1: found.append("mixed scripts: " + "+".join(sorted(scripts))) if any(ud.category(c).startswith("C") and c not in "\t\n\r" and ud.category(c) != "Cf" for c in s): found.append("control or unassigned") return found tests = ["alice", "p\u0430ypal", "admin\u200b", "Zoe\u0308", "user\u202e", "\u043f\u0440\u0438\u0432\u0435\u0442", "bad\x07bell"] for t in tests: print(f"{ascii(t):28} {problems(t) or 'ok'}")
'alice' ok 'p\u0430ypal' ['mixed scripts: CYRILLIC+LATIN'] 'admin\u200b' ['invisible format character'] 'Zoe\u0308' ['not NFC'] 'user\u202e' ['invisible format character', 'bidi control'] '\u043f\u0440\u0438\u0432\u0435\u0442' ok 'bad\x07bell' ['control or unassigned']

Note the policy choices inside the function: it tolerates the joiner and non-joiner (needed by some scripts and by emoji, check which your users need), accepts a fully Cyrillic word, and reports rather than rejects. A real policy depends on what the field is: a password field should accept almost anything, a user name should accept very little, and a file name sits somewhere between.

Common mistake
Checking raw input and then transforming it, or showing the original string in a log and assuming it is safe to read. Decode strictly, normalize, validate, and only then use the text. Log suspicious strings in escaped form so that invisible and direction-changing characters cannot hide in your own logs.

Exercises

Exercise 1

Write scripts(s) returning the scripts used in a string (the first word of each letter's Unicode name) and classify six names as ASCII-only, single script or MIXED. Include a name using a Greek omicron, one using two Cyrillic o's, an all-Cyrillic name and an accented Latin name. What does the all-Cyrillic result show about the limits of the check?

πŸ“„ View solution
Exercise 2

A site reserves the names "admin" and "root". Write key(name) using NFKC, casefold() and removal of format characters (category Cf), and test eight variants: upper case, fullwidth letters, a trailing zero-width space, a soft hyphen in the middle, and a Cyrillic o. Which variants does the naive check miss, and which does even your key miss?

πŸ“„ View solution
Exercise 3

Write scan(text) that reports the line, column and name of every bidirectional control in a multi-line text, and strip_bidi(text) that removes them. Run both on a short sample containing a comment with two such characters, and compare the escaped view of the line before and after.

πŸ“„ View solution

Chapter 9 Quick Reference

  • Homoglyphs (Cyrillic a vs Latin a) are different letters, not equivalents: normalization does not merge them; restrict scripts or use UTS #39 skeletons
  • Invisible format characters (category Cf: zero-width space, joiners, soft hyphen, BOM) make different strings look identical
  • Bidi controls LRE, RLE, LRO, RLO, PDF (U+202A-202E) and LRI, RLI, FSI, PDI (U+2066-2069) can make code display differently from how it runs (Trojan Source)
  • Decode strictly, normalize, then validate; checking before normalizing lets fullwidth or compatibility forms slip past
  • Log suspicious text with ascii() so hidden characters become visible
  • Detectors should flag for review rather than delete: legitimate text uses joiners and bidi controls
  • Prefer allow-lists and context-specific output escaping to blocklists (check against current guidance)
Not Yet Verified
Checked, through a summarising fetch tool and not by reading line by line, against: UTS #39 (confusables and skeletons, mixed-script detection, restriction levels; the page was read except its final few thousand characters), the Trojan Source project page (the attack idea, the quoted sentence, the two CVE identifiers, the authors and venue, the recommended defences) and UAX #9 (the code points and names of the bidirectional controls and the advice about overrides; read only in part, the first 100,000 of about 134,000 characters). The older Unicode technical report on security considerations is marked as no longer maintained and its page held none of the content I needed, so nothing is drawn from it. From general knowledge or from this run, and so flagged (check): how fonts render the look-alike letters, which libraries implement the UTS #39 mechanisms for a given language, the need for joiners in specific scripts, and the advice to prefer allow-lists. The checkers in this chapter are teaching sketches, not complete defences. Sources: unicode.org/reports/tr39/, trojansource.codes, unicode.org/reports/tr9/.
Coming next
Text Encoding and Unicode 10 brings the whole course together: a diagnosis workflow for any encoding problem, the tools to use, and a capstone that tracks a garbled string back to its cause.