Confusables
Check confusable characters
Paste text to fold homoglyphs toward Latin under both of disarm's digit policies at once. They are not two spellings of one answer — where they disagree, one reading is a number and the other is a word, and which you want depends on what you are about to do with it.
The tool
Three lines, each making a different point. The first spells a brand with
Cyrillic letters, which fold the same way under either policy because no digits
are involved. The second hides two Devanagari zeros in another brand, where the
policies disagree and one reading is the brand being imitated. The third is the
trio from disarm’s own documentation —
० ೦ ١ — which
TR39 folds to three different letters, o, O and
l, where the numeric policy gives 0, 0
and 1.
Reads as paypal.com, and is not.
6 characters here fold onto that Latin string, so it collides with the ordinary spelling in anything that compares folded forms — a username table, a filter, a lookup. Both digit policies agree here.
Which characters are impostors
Your text again, with every confusable character called out. This is the part
a folded result cannot show you: раураl
and paypal are drawn identically, so an output reading
“paypal” looks the same whether anything was wrong or not.
Numeric policy
A non-Latin digit becomes the ASCII digit. What prose means.
paypal.com
TR39 policy
A non-Latin digit becomes a Latin letter. What a skeleton needs.
paypal.com
Character by character
| Codepoint | Name | Numeric | TR39 | |
|---|---|---|---|---|
| U+0440 | CYRILLIC SMALL LETTER ER | p | p | |
| U+0430 | CYRILLIC SMALL LETTER A | a | a | |
| U+0443 | CYRILLIC SMALL LETTER U | y | y | |
| U+0440 | CYRILLIC SMALL LETTER ER | p | p | |
| U+0430 | CYRILLIC SMALL LETTER A | a | a | |
| U+04CF | CYRILLIC SMALL LETTER PALOCHKA | l | l |
Running disarm 0.14.1, compiled to WebAssembly. Your text 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 folding written out, so it is legible without running anything.
| Input | Numeric | TR39 | Agree? |
|---|---|---|---|
| g००gle | g00gle | no | |
| раураl | paypal | paypal | yes |
| ०೦١ | 001 | oOl | no |
| paypal | paypal | paypal | yes — nothing to fold |
The first row is the argument for having two policies. Under the numeric policy
g००gle becomes g00gle, which reads as a
string with two zeros in it. Under TR39 it becomes google, which
collides with the brand being imitated. If you are storing the text, the first
is right. If you are asking whether someone is impersonating a domain, only the
second answers the question.
The second row involves no digits, so both policies agree: five Cyrillic letters fold to their Latin lookalikes either way.
Where the table comes from
The bulk of it is generated from
Unicode
TR39's confusables.txt, version 17.0.0. Two smaller sets are
layered on top, and the second is the interesting one.
confusables_supplement.tsv adds cross-script pairs that TR39 leaves
without a shared prototype. confusables_attested.tsv adds 31
codepoints attested in real attacker text, mined from the BitCore
subset of the BitAbuse corpus, which TR39 does not list as sources at all.
Twenty-three of those are optical twins of a Latin letter —
ɴ → n,
ʍ → m,
ʀ → r. Eight are not: seven are
glyphs an attacker used positionally rather than because they resemble
the letter (ժ → d,
ᚱ → r,
Ⴝ → s), and one is a reading
convention (щ → w). The rule for
those rows is observed attacker substitution, which is wider than visual
confusability, and they are marked tier 2a and 2b.
One consequence is visible in this tool: the attested rows fold to
lowercase targets. ɴ is a small capital letter, and
TR39's own pairing would send it to N; disarm sends it to
n, because the job is recovering the word an attacker obscured
rather than preserving letter case. Paste aɴd ʙig
ᴇgg above and the folded column reads and big egg.
The confusables guide
carries the full account.
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. disarm on GitHub →
# Fold confusables under both digit policies. They answer different questions.
# pip install disarm
from disarm import normalize_confusables
# A brand spelled with two Devanagari zeros standing in for the letter o.
SPOOF = "g००gle"
numeric = normalize_confusables(SPOOF) # the default
tr39 = normalize_confusables(SPOOF, digit_policy="tr39")
# Numeric keeps a digit a digit, which is what stored text means. TR39 folds it
# to a letter, which is what makes a spoof collide with the brand it imitates.
assert numeric == "g00gle", numeric
assert tr39 == "google", tr39
assert numeric != tr39, "the policies must disagree here"
print(f'ok: numeric gives "{numeric}", tr39 gives "{tr39}"')
// Fold confusables under both digit policies. They answer different questions.
// cargo add disarm
use disarm::api::{normalize_confusables_with, DigitPolicy, TargetScript};
fn main() {
// A brand spelled with two Devanagari zeros standing in for the letter o.
let spoof = "g\u{0966}\u{0966}gle";
let numeric = normalize_confusables_with(spoof, TargetScript::Latin, DigitPolicy::Numeric);
let tr39 = normalize_confusables_with(spoof, TargetScript::Latin, DigitPolicy::Tr39);
// Numeric keeps a digit a digit, which is what stored text means. TR39 folds
// it to a letter, which is what makes a spoof collide with the brand.
assert_eq!(numeric, "g00gle");
assert_eq!(tr39, "google");
assert_ne!(numeric, tr39, "the policies must disagree here");
println!(r#"ok: numeric gives "{numeric}", tr39 gives "{tr39}""#);
}
// Fold confusables under both digit policies. They answer different questions.
// npm i disarm
const assert = require("node:assert/strict");
const { normalizeConfusables } = require("disarm");
// A brand spelled with two Devanagari zeros standing in for the letter o.
const SPOOF = "g००gle";
const numeric = normalizeConfusables(SPOOF); // the default
const tr39 = normalizeConfusables(SPOOF, { digitPolicy: "tr39" });
// Numeric keeps a digit a digit, which is what stored text means. TR39 folds it
// to a letter, which is what makes a spoof collide with the brand it imitates.
assert.equal(numeric, "g00gle");
assert.equal(tr39, "google");
assert.notEqual(numeric, tr39, "the policies must disagree here");
console.log(`ok: numeric gives "${numeric}", tr39 gives "${tr39}"`);
# Fold confusables under both digit policies. They answer different questions.
# gem install disarm
require "disarm"
# A brand spelled with two Devanagari zeros standing in for the letter o.
SPOOF = "g००gle"
numeric = Disarm.normalize_confusables(SPOOF) # the default
tr39 = Disarm.normalize_confusables(SPOOF, digit_policy: :tr39)
# Numeric keeps a digit a digit, which is what stored text means. TR39 folds it
# to a letter, which is what makes a spoof collide with the brand it imitates.
raise "expected g00gle, got #{numeric}" unless numeric == "g00gle"
raise "expected google, got #{tr39}" unless tr39 == "google"
raise "the policies must disagree here" if numeric == tr39
puts %(ok: numeric gives "#{numeric}", tr39 gives "#{tr39}")
// Fold confusables under both digit policies. They answer different questions.
// implementation("dev.disarm:disarm:0.14.1")
import dev.disarm.Disarm;
import dev.disarm.DigitPolicy;
import dev.disarm.TargetScript;
public class FoldConfusables {
public static void main(String[] args) {
// A brand spelled with two Devanagari zeros standing in for the letter o.
String spoof = "g००gle";
String numeric = Disarm.normalizeConfusables(spoof, TargetScript.LATIN, DigitPolicy.NUMERIC);
String tr39 = Disarm.normalizeConfusables(spoof, TargetScript.LATIN, DigitPolicy.TR39);
// Numeric keeps a digit a digit, which is what stored text means. TR39
// folds it to a letter, which is what makes a spoof collide with the brand.
if (!numeric.equals("g00gle")) throw new AssertionError("expected g00gle, got " + numeric);
if (!tr39.equals("google")) throw new AssertionError("expected google, got " + tr39);
if (numeric.equals(tr39)) throw new AssertionError("the policies must disagree here");
System.out.printf("ok: numeric gives \"%s\", tr39 gives \"%s\"%n", numeric, tr39);
}
}
// Fold confusables under both digit policies. They answer different questions.
// implementation("dev.disarm:disarm-kotlin:0.14.1")
import dev.disarm.DigitPolicy
import dev.disarm.TargetScript
import dev.disarm.kotlin.*
fun main() {
// A brand spelled with two Devanagari zeros standing in for the letter o.
val spoof = "g००gle"
val numeric = spoof.normalizeConfusables(TargetScript.LATIN, DigitPolicy.NUMERIC)
val tr39 = spoof.normalizeConfusables(TargetScript.LATIN, DigitPolicy.TR39)
// Numeric keeps a digit a digit, which is what stored text means. TR39 folds
// it to a letter, which is what makes a spoof collide with the brand.
check(numeric == "g00gle") { "expected g00gle, got $numeric" }
check(tr39 == "google") { "expected google, got $tr39" }
check(numeric != tr39) { "the policies must disagree here" }
println("""ok: numeric gives "$numeric", tr39 gives "$tr39"""")
}
/* Fold confusables under both digit policies. They answer different questions.
*
* The C ABI is not published to any registry, so this links against a cdylib
* built from bindings/cabi in the disarm repository. Unlike the strip functions,
* the confusable entry points are fallible: they return a DisarmResult_t with
* exactly one of value / error set, and the caller frees whichever it is.
*/
#include <stdio.h>
#include <string.h>
#include "disarm.h"
int main(void) {
/* A brand spelled with two Devanagari zeros standing in for the letter o. */
const char *spoof = "g\U00000966\U00000966gle";
DisarmResult_t n = disarm_normalize_confusables_opts(spoof, "latin", "numeric");
DisarmResult_t t = disarm_normalize_confusables_opts(spoof, "latin", "tr39");
if (n.error || t.error) {
fprintf(stderr, "disarm: %s\n", n.error ? n.error : t.error);
return 1;
}
/* Numeric keeps a digit a digit, which is what stored text means. TR39 folds
* it to a letter, which is what makes a spoof collide with the brand. */
if (strcmp(n.value, "g00gle") != 0) { fprintf(stderr, "expected g00gle, got %s\n", n.value); return 1; }
if (strcmp(t.value, "google") != 0) { fprintf(stderr, "expected google, got %s\n", t.value); return 1; }
if (strcmp(n.value, t.value) == 0) { fprintf(stderr, "the policies must disagree here\n"); return 1; }
printf("ok: numeric gives \"%s\", tr39 gives \"%s\"\n", n.value, t.value);
disarm_string_free(n.value);
disarm_string_free(t.value);
return 0;
}
Choosing a policy
| You are | Use | Because |
|---|---|---|
| Cleaning text you will store or display | numeric | A digit keeps its value. Folding ० to o would turn a quantity into a word. |
| Comparing usernames or identifiers | TR39 | A skeleton only has to collide. Whether it reads sensibly is not the question being asked. |
| Checking a domain against a brand | TR39 | The spoof must land on the same skeleton as the thing it imitates. |
| Building a search index | numeric | Query and document should agree, and a user typing a digit means a digit. |
| Comparing against a published TR39 benchmark | TR39 | It is the upstream mapping; anything else will differ from the reference by design. |
Folding is one control, not the whole answer. It catches cross-script substitution, where a Latin word borrows a Cyrillic letter. It does not by itself separate a label written entirely in Cyrillic that skeletons to a Latin brand from a legitimate Russian word — that needs a mixed-script or whole-script check, which disarm reports separately.
Found a string this gets wrong? The confusables table grew out of exactly that kind of report. Open an issue with it.
Related tools
- Detect script spoofing — the same folding applied to a hostname, label by label.
- Remove invisible characters — characters that look like nothing rather than like something else.
- Detect zalgo text — combining marks, the other way a character stops being itself.