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.
Paste source code to check it.
Nothing is uploaded. The engine runs inside this page.
What a reviewer sees
Your code as the browser renders it, with the bidi algorithm applied.
What the compiler sees
The same bytes in logical order, with each control shown where it sits.
Loading the engine…
A worked example
The tool above needs JavaScript. This is the same finding written out, so it is legible without running anything.
| 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 block is a file CI compiles and runs, so none can quietly stop working, and all seven print the same line. 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;
}
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.