The check that ran too early¶
Level: 301 · deep dive
One line: A check tells you something true about the string you checked, and every transformation that runs afterwards can hand the next stage a different string — so the order of canonicalise and check is the security property, and neither one is safe alone.
The shape¶
The previous page's bugs came from two readers disagreeing about the same bytes. This page's come from one reader, running twice, at two different moments — and the second run is almost always somebody being helpful. Normalising Unicode, folding case, resolving .., decoding a percent-escape, converting to the local code page: every one is correct hygiene, every one is recommended somewhere, and every one manufactures characters that were not in the string when the gate looked at it.
The fix is one word long — first — and it is the same word the overlong sequences page arrives at from the other direction. Canonicalise, then check, then pass on the canonical form. Three verbs, one order.
One character, one slash¶
The sharpest example needs no encoding trick at all. ℀ is a single code point, U+2100 ACCOUNT OF, and its compatibility decomposition is three characters: a, /, c.
A filter scanning for / cannot see a slash that does not exist yet. Then a database column with a normalising collation, a filename lookup on a normalising filesystem, a JSON library, or a template engine runs NFKC — and the slash appears. Nobody attacked the filter. The filter answered correctly, about a string that no longer exists.
The same holds for the whole fullwidth block: <script> normalises to <script>, and ../ to ../, so a path check and an HTML filter can both be walked past by typing the payload in the wrong-width Latin letters.
Two that do not normalise, and the table that gets them anyway¶
∕ U+2215 DIVISION SLASH and ⁄ U+2044 FRACTION SLASH look like separators and NFKC leaves both alone — Unicode says they are not slashes, and normalisation agrees. So normalising is not a defence against them either; it is not the relevant transformation.
The relevant transformation is a best-fit mapping: when a string is converted to an encoding that has no exact match for a character, some converters substitute one that looks similar rather than failing. Windows' WideCharToMultiByte does this by default, and its table maps a long list of look-alikes onto ASCII — fullwidth & to &, quotation variants to ', and slash-like characters to /. An application validates a clean Unicode string, passes it to an API, and the conversion below the application produces the metacharacters the validation just certified were absent.
Orange Tsai and splitline presented this as Worst Fit at Black Hat Europe 2024, with path traversal, argument injection and remote code execution across a range of widely deployed software. The defensive knob exists and is off by default: WC_NO_BEST_FIT_CHARS makes the conversion fail instead of approximating. Nothing on this page's programs can demonstrate it, because it is a Win32 behaviour and this library's examples run on macOS and Ubuntu — but it is the reason the rule is canonicalise then check rather than normalise then check: normalisation is one of the transformations, not the definition of them.
The oldest member of the same family is double decoding. A gate rejects ../, so the attacker sends %252e%252e%252f; the gate decodes once and sees %2e%2e%2f, which contains no slash; a later stage decodes again and gets ../. Same shape, no Unicode required.
In Python¶
Verified output of canonicalize_then_check_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. THE GATE SAYS YES
a tag gate(...) = True
a path gate(...) = True
one character, three after gate(...) = True
blocked substrings: '<' '>' '/' '\\' '..'
None of these strings contains any of them. The gate is not broken,
not bypassed, and not misconfigured. It is answering correctly.
2. AND THEN SOMETHING NORMALISES
a tag <script>
-> NFKC -> '<script>'
gate would now say: False
a path ../../etc/passwd
-> NFKC -> '../../etc/passwd'
gate would now say: False
one character, three after ℀
-> NFKC -> 'a/c'
gate would now say: False
Nobody attacked the gate. A later stage -- a database column with a
normalising collation, a JSON library, a filename lookup, a template
engine -- did the one thing everybody agrees is correct hygiene, and
manufactured the exact characters the gate exists to reject.
The third one is worth staring at. It is a SINGLE code point:
℀ U+2100 ACCOUNT OF
NFKC expands it to three characters, one of which is a path separator.
No filter that scans for '/' can see a slash that does not exist yet.
3. THE SAME TWO STEPS, SWAPPED
def safe(s): return gate(unicodedata.normalize('NFKC', s))
a tag safe(...) = False
a path safe(...) = False
one character, three after safe(...) = False
Same gate, same strings, same normalisation. Only the order changed.
Canonicalise, then check, then STORE THE CANONICAL FORM -- so nothing
downstream is ever handed the spelling you did not check.
4. TWO THAT NFKC DOES NOT TOUCH
U+2215 ∕ DIVISION SLASH
NFKC -> '∕' (unchanged)
U+2044 ⁄ FRACTION SLASH
NFKC -> '⁄' (unchanged)
They look like a slash and Unicode says they are not one, so
normalising is not a defence against them either. What turns THESE
into '/' is a different kind of table: a 'best fit' mapping, which
substitutes a similar-looking character when a target encoding has
no exact match. That conversion happens below the application, and
the page has the story.
5. THE RULE, IN ONE LINE
Any transformation that runs AFTER your check is part of your
attack surface, and normalisation is a transformation.
Decode once, canonicalise once, check the canonical form, and pass
the canonical form on. Three verbs, one order, no second spelling.
Section 3 is the whole fix and it is three words rearranged. Section 5's last clause is the part people leave out: store the canonical form. Checking the canonical form and storing the original puts the unchecked spelling back into the system, and the next stage that reads it starts the page over.
Every mapping in that program is a compatibility decomposition of a character assigned in Unicode 1.1, which the normalization stability policy freezes forever — so those results are safe in an answer key. A count over the whole table is not, which is what the exercise below is about.
In Rust¶
Verified output of canonicalize_then_check_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
1. THE GATE IS CORRECT, AND ASCII-NARROW
reserved name "kelvin"
candidate "Kelvin" (6 chars, 8 bytes)
...which is U+212A U+0065 U+006C U+0076 U+0069 U+006E
...written as "\u{212A}elvin"
Printed, it is indistinguishable from the ASCII word. That is
not a limitation of this program; it is the entire technique.
to_ascii_lowercase "Kelvin"
equal to reserved? false
eq_ignore_ascii_case? false
So the registration is allowed, and every line above is right:
U+212A is not an ASCII letter and an ASCII fold must not touch it.
2. THE STAGE AFTER IT IS NOT NARROW
to_lowercase "kelvin"
equal to reserved? true
Full Unicode lowercasing maps U+212A KELVIN SIGN to ASCII 'k'.
Two accounts now canonicalise to one name. Which of them the
next lookup returns is a question about a hash table, not policy.
3. AND CASE MAPPING CHANGES LENGTH
"ß" 1 chars 2 bytes -> upper "SS" 2 chars lower "ß" 1 chars
"fi" 1 chars 3 bytes -> upper "FI" 2 chars lower "fi" 1 chars
"İ" 1 chars 2 bytes -> upper "İ" 1 chars lower "i\u{307}" 2 chars
A length limit checked BEFORE a case fold does not hold after it.
Both directions exist: 'fi' as one ligature grows, and a string
trimmed to fit can be cut through the middle of a character.
4. WHAT std GIVES YOU, AND WHAT IT DOES NOT
to_lowercase / to_uppercase full Unicode, in std
to_ascii_lowercase ASCII only, and the name says so
eq_ignore_ascii_case ASCII only, and the name says so
NFC / NFD / NFKC / NFKD not in std at all -- a crate
Rust names its narrow operations honestly, which is a real
advantage: you cannot reach for an ASCII fold by accident.
But the wide one is a dependency, so the compiler has no opinion
about WHERE it runs -- and where it runs is the whole question.
5. THE ORDER THAT WORKS
let canonical = input.to_lowercase(); // canonicalise
if canonical == reserved { reject() } // then check
store(&canonical); // then store THAT
canonical = "kelvin" (U+006B U+0065 U+006C U+0076 U+0069 U+006E) -> rejected: true
The third line is the one people leave out. Checking the
canonical form and storing the original puts the two spellings
back in the database, and the next lookup gets to choose.
Rust makes the first half of this harder and the second half stranger.
Harder, because std names its narrow operations honestly. to_ascii_lowercase and eq_ignore_ascii_case say ASCII in the name, so you cannot reach for an ASCII fold thinking you got a Unicode one — which is exactly the confusion section 1 is built on, and in Rust you have to write it deliberately.
Stranger, because std has no normalisation at all. NFC, NFKC and their siblings live in the unicode-normalization crate, so on a Rust codebase the canonicalisation step is a dependency — and a dependency runs wherever somebody happened to call it, which is precisely the thing this page says must be decided once. The type system has nothing to say about it: String guarantees well-formed UTF-8 and makes no claim at all about normal form.
Section 3 is the quieter trap. ß uppercases to two characters, fi to two, İ lowercases to two — so a length limit checked before a fold does not hold after it, in both directions. A field that fits in 64 characters when you validated it can arrive at storage as 128, and a naive truncation back to 64 can cut through the middle of a character.
If you are coming from Python or ABAP¶
Python. unicodedata.normalize is an explicit call, which is the good part; the bad part is everything that normalises without one. pathlib and open() hand filenames to a filesystem that may normalise (macOS does), str.lower() is a fold you did not describe as one, and email.utils / idna / a JSON round-trip through a database can each return a differently-spelled string from the one you handed over. The habit that survives contact with all of it: build one function — def canonical(s): return unicodedata.normalize("NFKC", s).casefold() — call it at exactly one place, and let every check and every stored key be computed from its output. If two places in your code call normalize with different forms, that is the bug, and it is greppable.
ABAP. Two halves, and only one has a built-in. For case, TRANSLATE ... TO UPPER CASE and the to_upper/to_lower built-ins fold with the system's rules, so the ordering rule applies unchanged: fold, then check, then store what you folded. For normalisation there is no ABAP equivalent of unicodedata.normalize in the language, so a Unicode system's string holds whatever spelling arrived — which means the canonicalisation has to happen at the interface, before the data reaches ABAP, or be done by whatever the receiving system uses (a database collation, a middleware step). Say which, in writing, on the interface spec; "the data is normalised" with no named actor is how two systems each assume the other did it. And the practical version of section 3's length trap is the classic ABAP one: a CHAR(40) field is 40 characters in a Unicode system and the fold can change the count, so validate the length after the fold, never before. (Not machine-checked — CI cannot run ABAP.)
Try it¶
cd 12_Adversarial/canonicalize_then_check/examples
python3 canonicalize_then_check_py.py
rustc --edition 2024 canonicalize_then_check_rs.rs -o /tmp/cc && /tmp/cc
Search the table yourself. The program deliberately does not print a count, because "how many code points normalise into a string containing /" is a fact about your Python's Unicode version, not about Unicode. So go and get the number for yours:
import sys, unicodedata as ud
for target in ("/", "<", "'", ".."):
hits = [chr(cp) for cp in range(sys.maxunicode + 1)
if target in ud.normalize("NFKC", chr(cp)) and chr(cp) != target]
print(f"{target!r:6} {len(hits):4} {''.join(hits[:24])}")
Four lines, four short lists — which is the useful surprise: this is not a vast attack surface, it is a specific and enumerable one — and nobody enumerates it, because the filter is written before anyone asks the question.
Then check an order you own. Find any place in your code where a value is validated and later lowercased, normalised, url-decoded, or written to a filesystem. Write down which happens first. That single sentence is the finding; you do not need a payload to file it.
Practice¶
The same guard, two orders. A blocklist refuses /etc/passwd. Predict which of /etc/passwd, /etc/./passwd, /var/../etc/passwd, /etc/%70asswd and /tmp/ok get through when the check runs before canonicalisation, and when it runs after.
Then say why "check harder" is not the fix. Finish with the Unicode version of the same bug — four spellings of admin — and say why neither folding nor not-folding is safe on its own.
Answers
Verified output of canonicalize_then_check_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.
input check first canonicalise first
/etc/passwd REFUSED REFUSED
/etc/./passwd opened /etc/passwd REFUSED
/var/../etc/passwd opened /etc/passwd REFUSED
/etc/%70asswd opened /etc/passwd REFUSED
/tmp/ok opened /tmp/ok opened /tmp/ok
Four of the five reach /etc/passwd, and the guard that runs FIRST
stops exactly one of them -- the one spelled the obvious way. The
check was not wrong: it told the truth about the string it was given.
Everything after it handed the next stage a different string.
THE RULE, AND WHY IT IS NOT 'CHECK HARDER'
You cannot enumerate the spellings. Percent-encoding, dot segments,
symlinks, case folding, Unicode normalization and overlong UTF-8 all
produce the same resource from different bytes, and a blocklist has
to be right about all of them at once. Canonicalising first makes the
check compare ONE value against ONE value.
THE UNICODE VERSION OF THE SAME BUG
stored user 'ADMIN'
admin -> NFKC+casefold 'admin' MATCHES the reserved name
ADMIN -> NFKC+casefold 'admin' MATCHES the reserved name
admin -> NFKC+casefold 'admin' MATCHES the reserved name
Ⓐdmin -> NFKC+casefold 'admin' MATCHES the reserved name
If you compare before folding, all four are different users. If you
fold before comparing, all four are one. Neither is 'safe' on its
own -- what makes it safe is that ONE of them is the stored form and
everything is converted to it before any comparison happens.
AND THE ORDER THAT IS STILL WRONG
canonicalise -> check -> canonicalise again is not belt and braces;
the second canonicalisation can move the value again. Do it once, as
early as possible, and pass the CANONICAL value downstream -- never
the original alongside it, or something will use the wrong one.
See also¶
- Two readers, one byte string — the same disagreement between two tables rather than two moments
- Two people, one account — what the canonical form costs you once you have one
- Normalization — the four forms, and why NFKC is the aggressive one
- Confusables and scripts — the look-alikes that survive every normal form
- Preparing a string — RFC 3454, the standardised attempt to make "canonicalise then check" a protocol rather than a habit
- Unicode Standard Annex #15: Normalization Forms ↗ — what NFKC is allowed to do, in the words of the people who decide