Bidirectional controls
Detect Trojan Source in source code
Paste code to find the bidirectional control characters that make it render differently from how it compiles. You get a line and column for every one, and the two readings side by side — what a reviewer sees, and what the compiler sees.
The tool
The example is the commenting-out pattern from the 2021 Trojan Source paper:
a right-to-left override and two isolates make an if statement
appear to sit inside a comment, so the line beneath it looks unreachable and
is not.
6 bidirectional controls on 2 lines.
Rendered order does not match logical order. What a reviewer reads above is not what a compiler reads.
What a reviewer sees
Your code as the browser renders it, with the bidi algorithm applied.
#include <stdio.h>
int main() {
bool isAdmin = false;
/* } if (isAdmin) begin admins only */
printf("You are an admin.\n");
/* end admins only { */
return 0;
}
What the compiler sees
The same bytes in logical order, with each control shown where it sits.
4 lines without controls not shown.
Where the controls are
| Line | Col | Codepoint | Abbr | Name |
|---|---|---|---|---|
| 5 | 7 | U+202E | RLO | RIGHT-TO-LEFT OVERRIDE |
| 5 | 11 | U+2069 | PDI | POP DIRECTIONAL ISOLATE |
| 5 | 24 | U+2066 | LRI | LEFT-TO-RIGHT ISOLATE |
| 5 | 26 | U+2066 | LRI | LEFT-TO-RIGHT ISOLATE |
| 7 | 24 | U+202E | RLO | RIGHT-TO-LEFT OVERRIDE |
| 7 | 28 | U+2069 | PDI | POP DIRECTIONAL ISOLATE |
Running disarm 0.14.1, compiled to WebAssembly. Your code is never uploaded — the engine is loaded into this page and runs on your machine.
A worked example
The tool above needs JavaScript. This is the same finding written out, so it is legible without running anything.
Line 5 of the example, read the two ways. Both are the same bytes; the difference is only whether the bidi controls are honoured.
What a reviewer sees
/* } if (isAdmin) begin admins only */
Your browser applies the bidi algorithm here exactly as an editor would.
The if appears to sit inside the comment.
What the compiler sees
/*RLO } PDIif (isAdmin)LRI LRI begin admins only */
Stored order, with each control shown where it sits. The
if (isAdmin) is live code, and the comment ends before it.
With the controls removed the line reads
/* } if (isAdmin) begin admins only */ — which is what the compiler
compiles and what no reviewer was shown.
| Line | Col | Codepoint | Name |
|---|---|---|---|
| 5 | 7 | U+202E | RIGHT-TO-LEFT OVERRIDE |
| 5 | 11 | U+2069 | POP DIRECTIONAL ISOLATE |
| 5 | 24 | U+2066 | LEFT-TO-RIGHT ISOLATE |
| 5 | 26 | U+2066 | LEFT-TO-RIGHT ISOLATE |
| 7 | 24 | U+202E | RIGHT-TO-LEFT OVERRIDE |
| 7 | 28 | U+2069 | POP DIRECTIONAL ISOLATE |
Six controls across two lines. The printf beneath them appears to
be commented out and is not, so a reviewer approves a program that grants
administrator access. No character is misspelled and no identifier is
confusable; the file is exactly what it appears to be, read in a different
order.
The example carries no direction conflict — that is the other attack.
varonis.com.ו contains no control character at all, yet
reverses under the bidi algorithm because the final letter is a real Hebrew
one. Removing controls cannot fix it, which is why the two are reported apart.
The same thing in your own code
Each code block has been compiled and verified in CI. Provided under the MIT
license to illustrate disarm. These operate on the single commenting-out
line from the Trojan Source paper, which carries four of the six controls
in the sample file the tool above loads — hence controls == 4
beside a table listing six.
disarm on GitHub →
# Detect Trojan Source, and the direction conflict that stripping cannot fix.
# pip install disarm
from disarm import strip_bidi, has_bidi_conflict
# The commenting-out line from the Trojan Source paper: an override and two
# isolates, so the `if` appears to sit inside the comment.
TROJAN = " /* } if (isAdmin) begin admins only */"
# No control character at all — the final letter is a real Hebrew vav.
CONFLICT = "varonis.com.ו"
clean = strip_bidi(TROJAN)
controls = len(TROJAN) - len(clean)
# The two checks catch different attacks, which is why both are needed.
assert controls == 4, controls
assert not has_bidi_conflict(TROJAN), "overrides are not a direction conflict"
assert has_bidi_conflict(CONFLICT), "real RTL letters are"
assert strip_bidi(CONFLICT) == CONFLICT, "and there is nothing to strip"
print(f"ok: {controls} bidi controls stripped; a conflict detected where there are none")
// Detect Trojan Source, and the direction conflict that stripping cannot fix.
// cargo add disarm
use disarm::api::{has_bidi_conflict, strip_bidi};
fn main() {
// The commenting-out line from the Trojan Source paper: an override and two
// isolates, so the `if` appears to sit inside the comment.
let trojan = " /*\u{202e} } \u{2069}if (isAdmin)\u{2066} \u{2066} begin admins only */";
// No control character at all — the final letter is a real Hebrew vav.
let conflict = "varonis.com.\u{05d5}";
let clean = strip_bidi(trojan);
let controls = trojan.chars().count() - clean.chars().count();
// The two checks catch different attacks, which is why both are needed.
assert_eq!(controls, 4);
assert!(!has_bidi_conflict(trojan), "overrides are not a direction conflict");
assert!(has_bidi_conflict(conflict), "real RTL letters are");
assert_eq!(strip_bidi(conflict), conflict, "and there is nothing to strip");
println!("ok: {controls} bidi controls stripped; a conflict detected where there are none");
}
// Detect Trojan Source, and the direction conflict that stripping cannot fix.
// npm i disarm
const assert = require("node:assert/strict");
const { stripBidi, hasBidiConflict } = require("disarm");
// The commenting-out line from the Trojan Source paper: an override and two
// isolates, so the `if` appears to sit inside the comment.
const TROJAN = " /* } if (isAdmin) begin admins only */";
// No control character at all — the final letter is a real Hebrew vav.
const CONFLICT = "varonis.com.ו";
const clean = stripBidi(TROJAN);
const controls = [...TROJAN].length - [...clean].length;
// The two checks catch different attacks, which is why both are needed.
assert.equal(controls, 4);
assert.ok(!hasBidiConflict(TROJAN), "overrides are not a direction conflict");
assert.ok(hasBidiConflict(CONFLICT), "real RTL letters are");
assert.equal(stripBidi(CONFLICT), CONFLICT, "and there is nothing to strip");
console.log(`ok: ${controls} bidi controls stripped; a conflict detected where there are none`);
# Detect Trojan Source, and the direction conflict that stripping cannot fix.
# gem install disarm
require "disarm"
# The commenting-out line from the Trojan Source paper: an override and two
# isolates, so the `if` appears to sit inside the comment.
TROJAN = " /* } if (isAdmin) begin admins only */"
# No control character at all — the final letter is a real Hebrew vav.
CONFLICT = "varonis.com.ו"
clean = Disarm.strip_bidi(TROJAN)
controls = TROJAN.length - clean.length
# The two checks catch different attacks, which is why both are needed.
raise "expected 4, got #{controls}" unless controls == 4
raise "overrides are not a direction conflict" if Disarm.bidi_conflict?(TROJAN)
raise "real RTL letters are" unless Disarm.bidi_conflict?(CONFLICT)
raise "and there is nothing to strip" unless Disarm.strip_bidi(CONFLICT) == CONFLICT
puts "ok: #{controls} bidi controls stripped; a conflict detected where there are none"
// Detect Trojan Source, and the direction conflict that stripping cannot fix.
// implementation("dev.disarm:disarm:0.14.1")
import dev.disarm.Disarm;
public class DetectBidi {
public static void main(String[] args) {
// The commenting-out line from the Trojan Source paper: an override and
// two isolates, so the `if` appears to sit inside the comment. Written
// with literal characters rather than backslash-u escapes, because javac
// resolves those before tokenizing — even inside a comment. Writing that
// escape sequence here, in this very comment, is itself a compile error,
// which is the hazard stated more plainly than any wording could.
String trojan = " /* } if (isAdmin) begin admins only */";
// No control character at all — the final letter is a real Hebrew vav.
String conflict = "varonis.com.ו";
String clean = Disarm.stripBidi(trojan);
long controls = trojan.codePointCount(0, trojan.length())
- clean.codePointCount(0, clean.length());
// The two checks catch different attacks, which is why both are needed.
if (controls != 4) throw new AssertionError("expected 4, got " + controls);
if (Disarm.hasBidiConflict(trojan)) throw new AssertionError("overrides are not a direction conflict");
if (!Disarm.hasBidiConflict(conflict)) throw new AssertionError("real RTL letters are");
if (!Disarm.stripBidi(conflict).equals(conflict)) throw new AssertionError("and there is nothing to strip");
System.out.printf("ok: %d bidi controls stripped; a conflict detected where there are none%n", controls);
}
}
// Detect Trojan Source, and the direction conflict that stripping cannot fix.
// implementation("dev.disarm:disarm-kotlin:0.14.1")
import dev.disarm.kotlin.*
fun main() {
// The commenting-out line from the Trojan Source paper: an override and two
// isolates, so the `if` appears to sit inside the comment.
val trojan = " /* } if (isAdmin) begin admins only */"
// No control character at all — the final letter is a real Hebrew vav.
val conflict = "varonis.com.ו"
val controls = trojan.codePointCount(0, trojan.length) -
trojan.stripBidi().let { it.codePointCount(0, it.length) }
// The two checks catch different attacks, which is why both are needed.
check(controls == 4) { "expected 4, got $controls" }
check(!trojan.hasBidiConflict()) { "overrides are not a direction conflict" }
check(conflict.hasBidiConflict()) { "real RTL letters are" }
check(conflict.stripBidi() == conflict) { "and there is nothing to strip" }
println("ok: $controls bidi controls stripped; a conflict detected where there are none")
}
/* Detect Trojan Source, and the direction conflict that stripping cannot fix.
*
* The C ABI is not published to any registry, so this links against a cdylib
* built from bindings/cabi in the disarm repository.
*/
#include <stdio.h>
#include <string.h>
#include "disarm.h"
/* Count UTF-8 codepoints, so the control characters are counted as one each. */
static size_t cp_len(const char *s) {
size_t n = 0;
for (; *s; s++) if ((*s & 0xC0) != 0x80) n++;
return n;
}
int main(void) {
/* The commenting-out line from the Trojan Source paper: an override and two
* isolates, so the `if` appears to sit inside the comment. */
const char *trojan = " /*\U0000202E } \U00002069if (isAdmin)\U00002066 \U00002066 begin admins only */";
/* No control character at all — the final letter is a real Hebrew vav. */
const char *conflict = "varonis.com.\U000005D5";
char *clean = disarm_strip_bidi(trojan);
size_t controls = cp_len(trojan) - cp_len(clean);
/* The two checks catch different attacks, which is why both are needed. */
if (controls != 4) { fprintf(stderr, "expected 4, got %zu\n", controls); return 1; }
if (disarm_has_bidi_conflict(trojan)) { fprintf(stderr, "overrides are not a conflict\n"); return 1; }
if (!disarm_has_bidi_conflict(conflict)) { fprintf(stderr, "real RTL letters are\n"); return 1; }
char *conflict_clean = disarm_strip_bidi(conflict);
if (strcmp(conflict_clean, conflict) != 0) { fprintf(stderr, "nothing should be stripped\n"); return 1; }
disarm_string_free(conflict_clean);
printf("ok: %zu bidi controls stripped; a conflict detected where there are none\n", controls);
disarm_string_free(clean);
return 0;
}
Catching it before review, not during
A reviewer cannot be asked to spot this: the whole attack is that the rendered line looks correct. The check belongs in the same place as a formatter or a linter, where it runs on every commit and nobody has to remember it.
The rule is narrow on purpose. A bidi control inside a string literal is sometimes legitimate — right-to-left text in a UI label needs them — so failing on every control produces noise that gets the check disabled. Failing on a control outside a string literal or comment is the signal, because there is no reason for one to sit in code.
| Where | What to run | Fail when |
|---|---|---|
| Pre-commit hook | strip_bidi | the stripped file differs from the original, outside string literals. |
| CI, on the diff | strip_bidi | an added line contains a control the base did not. |
| CI, whole tree | has_bidi_conflict | a file mixes strong directions with no control to strip — the case a stripper cannot fix, so it needs a human. |
Both checks are needed, and they catch different attacks. strip_bidi
finds the controls; has_bidi_conflict finds the reordering that
happens without any. The Python example above runs both against one line and is
the body of the hook.
The characters involved
Bidirectional controls exist for a real purpose: text mixing Arabic or Hebrew with Latin needs them. In source code they have no legitimate use outside string literals and comments meant to contain such text, which is why their presence is worth flagging rather than silently removing.
| Codepoint | Abbr | Name | Effect |
|---|---|---|---|
| U+202D | LRO | Left-to-right override | Forces the following text left to right, whatever it contains. |
| U+202E | RLO | Right-to-left override | Forces the following text right to left. The usual vehicle for the attack. |
| U+202A | LRE | Left-to-right embedding | Opens a left-to-right run; ended by PDF. |
| U+202B | RLE | Right-to-left embedding | Opens a right-to-left run; ended by PDF. |
| U+202C | Pop directional formatting | Ends the most recent embedding or override. | |
| U+2066 | LRI | Left-to-right isolate | Opens an isolated run, ended by PDI. |
| U+2067 | RLI | Right-to-left isolate | Opens an isolated right-to-left run. |
| U+2068 | FSI | First strong isolate | Direction taken from the first strong character inside. |
| U+2069 | PDI | Pop directional isolate | Ends the most recent isolate. |
| U+200E | LRM | Left-to-right mark | An invisible strong character used to nudge ordering. |
| U+200F | RLM | Right-to-left mark | The right-to-left counterpart. |
| U+061C | ALM | Arabic letter mark | As RLM, for Arabic-script context. |
| U+00AD | SHY | Soft hyphen | Invisible unless a line breaks there. |
The Trojan Source paper recommends that build pipelines refuse, or at least warn
on, unterminated overrides. Compilers have since added their own checks —
rustc and GCC both warn — but they cover their own source, not
the data your program reads.
Found a string this gets wrong? The confusables table grew out of exactly that kind of report. Open an issue with it.
Related tools
- Remove invisible characters — strip the bidi controls, along with every other invisible class.
- Detect script spoofing — a direction conflict with no control character to strip.
- Check confusable characters — the other way source code can read differently than it means.