What you see is not what runs¶
Level: 301 · deep dive
One line: Source code is stored as an ordered list of code points and read as a rendering, the two are allowed to differ, and a code reviewer is the only reader in the pipeline who has no access to the first one.
The shape, with a person in it¶
Every other page in this chapter has two programs disagreeing. This one has a program and a person:
the compiler reads code points, in order, and has no screen
the reviewer reads a rendering, which reorders — and which draws
two different code points with the same picture
Both are reading the same file. Neither is malfunctioning. Unicode's bidirectional algorithm (UAX #9 ↗) exists because Arabic and Hebrew run right to left inside otherwise left-to-right text, and it does that job correctly — including when the text is a .rs file. The nine explicit formatting characters that steer it are invisible by design, because a mark that drew something would be in the way of the writing systems that need it.
So the attack writes itself: put an override in a comment or a string literal, and the line the reviewer reads is not the line the compiler compiles. Nicholas Boucher and Ross Anderson named it Trojan Source in 2021 and disclosed it to a dozen language teams at once, which is why so many compilers grew the same lint on the same day. It is CVE-2021-42574 ↗ for the bidi half and CVE-2021-42694 for the homoglyph half.
The two halves¶
Reordering. U+202E RIGHT-TO-LEFT OVERRIDE and its eight relatives change the display order of everything that follows without changing a single code point. A return inside what renders as a comment; a comment marker that renders in the middle of a line it does not begin; a string literal whose closing quote appears to arrive early. The compiler sees none of that, because the compiler has no concept of display order at all.
Homoglyphs. No control characters, no cleverness: аdmin with U+0430 CYRILLIC SMALL LETTER A and admin with ASCII a are two identifiers that are the same picture in every font. Define both, export the harmless one, call the other. The Python example does exactly this and prints which function actually ran.
The same primitive is old craft outside source code. A file named photo_ann + U+202E + gnp.exe renders in a file manager as photo_annexe.png, and the extension a user reads is a lie told by the renderer — malware campaigns have used exactly that for years. And at the level of a single letter it is the homograph domain: аpple.com spelled in Cyrillic, registered and demonstrated with a valid certificate in 2017. That end of the story is confusables and scripts; this page is the code-review end.
In Python¶
Verified output of trojan_source_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. TWO FUNCTIONS, ONE APPEARANCE
defined: 'is_admin'
U+0069 U+0073 U+005F U+0061 U+0064 U+006D U+0069 U+006E
defined: 'is_аdmin'
U+0069 U+0073 U+005F U+0430 U+0064 U+006D U+0069 U+006E
same name? False
is_admin('nobody') -> False
is_аdmin('nobody') -> True
One of those is a backdoor. Both lines above are eight characters
long and, in every font, identical. A reviewer has nothing to see.
Note the code points: only ONE character differs, at position four.
2. AND PYTHON SAYS NOTHING
source contains U+202E? True
compile() raised? no
result 2
No error, no warning, not even under -W error. CPython's tokenizer
has no opinion about text direction, because text direction is not
a property of the program -- only of the screen the program is
being read on. That gap is the whole of Trojan Source.
3. WHAT THIS PROGRAM REFUSES TO PRINT
U+202A LRE LEFT-TO-RIGHT EMBEDDING
U+202B RLE RIGHT-TO-LEFT EMBEDDING
U+202C PDF POP DIRECTIONAL FORMATTING
U+202D LRO LEFT-TO-RIGHT OVERRIDE
U+202E RLO RIGHT-TO-LEFT OVERRIDE
U+2066 LRI LEFT-TO-RIGHT ISOLATE
U+2067 RLI RIGHT-TO-LEFT ISOLATE
U+2068 FSI FIRST STRONG ISOLATE
U+2069 PDI POP DIRECTIONAL ISOLATE
Nine characters, none of them printed above as itself -- because an
answer key holding a raw override would reorder the page that shows
it, and this library's answer keys are read by people. Escapes only.
4. THE DETECTOR IS TEN LINES
the two definitions:
line 3 col 8: non-ASCII letter CYRILLIC SMALL LETTER A
the comment:
line 1 col 10: RIGHT-TO-LEFT OVERRIDE
That is the whole defence, and it belongs in CI rather than in a
reviewer's eyes: a rule a person cannot apply is not a control.
5. THE RULE
Two readers, again -- but this time one of them is a human being.
the compiler reads code points, in order, and has no screen
the reviewer reads a rendering, which reorders and which draws
two different code points with the same picture
So: forbid the nine controls outright in source, require identifiers
to be ASCII (or a single script), and render anything from outside
with escapes rather than glyphs. Rust does the first by default and
warns on the second; Python does neither, and both are lint rules
somebody has to switch on.
Section 2 is the finding: CPython compiles a file containing U+202E and says nothing — not an error, not a warning, not under -W error. That is not a CPython defect. Text direction is a property of the screen, not of the program, and a tokenizer has no screen. Nothing in the language can fix this, which is why the fix is a lint and not a language change.
Section 3 is a small piece of discipline worth copying. The program prints the nine controls by name and code point and never as themselves, because an answer key holding a raw override would reorder the page displaying it — and this library's answer keys are read by people. The example source file follows the same rule: every control and every Cyrillic letter in it is written as an escape, so the file that demonstrates the attack cannot perform it.
Section 4 is the whole defence, in ten lines, and its last claim is the important one: this belongs in CI, not in a reviewer's eyes. A rule a person cannot apply is not a control.
In Rust¶
Verified output of trojan_source_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
1. WHAT AN ESCAPE HOLDS
let s = "admin\u{202E} user";
chars 11 bytes 13
code points U+0061 U+0064 U+006D U+0069 U+006E U+202E U+0020 U+0075 U+0073 U+0065 U+0072
The override is the sixth character and it draws nothing at all.
Written as an escape it is six visible ASCII characters in the
source, greppable and diffable. Written as itself it is nothing,
and it reorders the line it sits in.
2. WHY THIS FILE COULD NOT HOLD THE RAW ONE
rustc denies text-direction-codepoint-in-literal and
text-direction-codepoint-in-comment BY DEFAULT -- not warn, deny.
So a Trojan Source commit does not compile here, and the error
offers the escape above as the fix. The page quotes both.
Three more lints (confusable-idents, mixed-script-confusables,
uncommon-codepoints) cover the homoglyph half, and those only
WARN -- so the confusable identifier still builds.
3. THE SCANNER YOU STILL NEED
a clean line:
clean
a filename from a zip:
line 1 col 10: RLO RIGHT-TO-LEFT OVERRIDE
a display name:
line 1 col 1: LRI LEFT-TO-RIGHT ISOLATE
line 1 col 7: PDI POP DIRECTIONAL ISOLATE
The second one renders as "photo_annexe.png" in a file manager
and is an executable. rustc protected your SOURCE; it has no
opinion about a string your program reads at run time, and that
is where these characters actually arrive.
4. THE RULE
Source: let the compiler deny them, and keep the deny on.
Data: scan on the way in, and render on the way out with
escapes rather than glyphs -- a name, a filename or a
commit message from outside is not source code, and it
is not protected by anything that checks source code.
Rust is the language that took this most seriously, and the levels are worth reading off the compiler rather than from memory:
$ rustc -W help
confusable-idents warn detects visually confusable pairs between identifiers
mixed-script-confusables warn detects Unicode scripts whose mixed script confusables codepoints are solely used
uncommon-codepoints warn detects uncommon Unicode codepoints in identifiers
text-direction-codepoint-in-comment deny invisible directionality-changing codepoints in comment
text-direction-codepoint-in-literal deny detect special Unicode codepoints that affect the visual representation of text on screen, changing the direction in which text flows
(rustc 1.98.0, macOS, 2026-09-06. Three UTS #39 lints at warn, two bidi lints at deny.)
So a Trojan Source commit does not build. The error is worth seeing in full, because of what the last two lines do:
error: unicode codepoint changing visible direction of text present in comment
--> demo.rs:3:5
|
3 | // � }rehcuov si resu fi{
| ^^^-^^^^^^^^^^^^^^^^^^^^^^
| | |
| | '\u{202e}'
| this comment contains an invisible unicode text flow control codepoint
|
= note: these kind of unicode codepoints change the way text flows on applications that support them, but can cause confusion because they change the order of characters on the screen
= note: `#[deny(text_direction_codepoint_in_comment)]` on by default
= help: if their presence wasn't intentional, you can remove them
Two details. The � is not a copying accident: rustc replaces the override with U+FFFD in its own diagnostic, because an error message that reordered itself would be worse than useless. And on the literal version of the same lint, the help does not just say remove it — it offers the escaped spelling as a suggested rewrite, which is the correct fix when you genuinely meant to include one.
The homoglyph half only warns, so it still builds:
warning: found both `аdmin` and `admin` as identifiers, which look alike
= note: `#[warn(confusable_idents)]` on by default
warning: the usage of Script Group `Cyrillic` in this crate consists solely of mixed script confusables
= note: the usage includes 'а' (U+0430)
= note: `#[warn(mixed_script_confusables)]` on by default
(Same run. Note the second one's condition — it fires because Cyrillic is used solely for confusables; a crate with real Cyrillic identifiers in it would not get this warning, which is the difference between a heuristic and a rule.)
Section 3 of the Rust example is the part that outlives the compiler's help. rustc protected your source. It has no opinion whatsoever about a filename read from a zip archive, a display name from a database, or a commit message from a stranger — and that is where these characters actually arrive.
If you are coming from Python or ABAP¶
Python. Nothing in the language will help you, so the two controls are external and both are cheap. Put a grep in CI for the nine controls — git grep -nP '[\x{202A}-\x{202E}\x{2066}-\x{2069}]' — and turn on your linter's non-ASCII identifier rule (ruff's RUF001/RUF002/RUF003 flag ambiguous characters in strings, docstrings and comments; PLC2401 covers non-ASCII names). Then remember the second half: print() and logging write whatever bytes you hand them to a terminal, so a display name from your database can carry an ESC or an override into your own console. ascii(s) and repr(s) escape everything non-printable and are the right way to log a value you did not write.
ABAP. ABAP source lives in the database and is read through SE38 / ADT rather than as a file, which changes the threat rather than removing it: what renders your code is the editor, and an editor that implements the bidirectional algorithm implements it for source too. There is no rustc-style lint here, so the control has to be a Code Inspector / ATC check or a review of transports, and it should look for the nine code points by number rather than by appearance. The run-time half applies unchanged and is the more likely one in practice: a customer name arriving through an interface can carry an override into a SAPscript form, a spool, an ALV column or an application-log entry, and none of those is a place you want a renderer following instructions. Escape or strip the controls when data crosses into a display, not when it is stored. (Not machine-checked — CI cannot run ABAP.)
Try it¶
cd 12_Adversarial/trojan_source/examples
python3 trojan_source_py.py
rustc --edition 2024 trojan_source_rs.rs -o /tmp/ts && /tmp/ts
Make rustc refuse something. The override is written as \u202e here on purpose — Python turns the escape into the real character in /tmp, so the thing that demonstrates the attack never exists in a file anybody reviews:
python3 -c 'open("/tmp/demo.rs","w").write("fn main() {\n // \u202e )(niam\n}\n")'
rustc --edition 2024 /tmp/demo.rs -o /tmp/demo # error: ... present in comment
Then scan something you did not write. The nine controls are one grep, and the answer is almost always zero — which is what makes a hit worth looking at:
(-P needs GNU grep; on a Mac, the tools chapter has the story, or use the Python detector in the example.)
And one to reason about. The Rust example refuses to contain a raw control, and the Python example refuses to print one. Both are the same judgement: an artefact that demonstrates this attack should not be able to perform it. Where else does that judgement apply — and what does it say about a test fixture, a security blog post, or a page like this one?
Practice¶
Two readers, one file. A source line contains five characters of General_Category Cf. Predict what the compiler does with them and what the reviewer sees, and say why both are behaving correctly.
Then: of every reader in the pipeline — compiler, linter, tests, git, reviewer — say which one is uniquely vulnerable and why. Finish with the three-line detection, and say why it belongs in CI rather than in a reviewer's eyes.
Answers
Verified output of trojan_source_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.
THE LINE, AS CODE POINTS
length 50 characters, 60 bytes
invisible format characters at index [24, 25, 27, 45, 47]:
24 U+202E RIGHT-TO-LEFT OVERRIDE
25 U+2069 POP DIRECTIONAL ISOLATE
27 U+2066 LEFT-TO-RIGHT ISOLATE
45 U+2069 POP DIRECTIONAL ISOLATE
47 U+2066 LEFT-TO-RIGHT ISOLATE
Every one of those has General_Category Cf -- FORMAT. They have no
width, no glyph and no effect on what the parser reads. They change
only the ORDER a renderer draws the surrounding text in.
THE TWO READERS
the compiler reads the ordered list of code points, in order, and
ignores Cf characters entirely
the reviewer reads a RENDERING, produced by a bidi algorithm that
is doing exactly what those characters ask
Both are correct. They are given different objects.
WHY THE REVIEWER IS THE VULNERABLE ONE
Every other reader in the pipeline sees the bytes: the compiler, the
linter, the test suite, git's own hashing. The code reviewer is the
only participant whose input is a picture -- and a picture is the one
representation the attacker gets to choose.
THE DETECTION, WHICH IS THREE LINES
[c for c in text if unicodedata.category(c) == 'Cf']
-> 5 hits on this line, 0 on ordinary source
That is a whole-repo grep, it has no false-negative story to worry
about, and it belongs in CI rather than in a reviewer's eyes. GitHub,
rustc and gcc all added warnings for exactly this after CVE-2021-42574.
THE GENERAL LESSON
Any time a human approves something on the strength of a rendering,
the rendering is part of the trust boundary. Source review, a diff, a
signed document, a URL in an address bar -- the question is always
whether the thing the person SAW is the thing that will ACT.
See also¶
- Confusables and scripts — the look-alikes, measured, and the whole-script case that defeats the detector
- Unicode in identifiers — what a compiler is allowed to bind, and the normalisation it applies first
- Logical and visual order — UAX #9 itself, and why a screenshot is not evidence about a string
- The byte that means something to somebody else — the same renderer, attacked with
ESCinstead of an override - Control characters — the older invisibles, and what they were for
- Trojan Source ↗ — the paper, the proof-of-concept files, and the coordinated-disclosure timeline