Whole-script confusables
Detect script spoofing
A string can be written entirely in one script — nothing mixed, so every
mixed-script check passes it — and still consist only of characters that
look Latin. раураӏ is six
Cyrillic letters and is drawn exactly like paypal. Paste any string
to see it character by character; hostnames get the extra label analysis, because
that is where this usually bites.
The tool
The example spells a brand entirely in Cyrillic, so every character of the label agrees with every other and a mixed-script check reports nothing — yet it folds exactly onto the Latin name. Compare it with the others below.
Enter a hostname to check it.
Nothing is uploaded. The engine runs inside this page.
Character by character
Every character that has a Latin lookalike, with its codepoint and what it folds to. This is the part the string itself cannot show you.
Label by label
Each label with the scripts it uses. The top-level domain is shown too, because its script is half the question.
Folds to
The canonical form. When this is a familiar name, that is the point.
Loading the engine…
One script, entirely Latin lookalikes
The hard case is not a word with a foreign letter smuggled into it. That mixes scripts, and a mixed-script check catches it. The hard case is a word written wholly in one script where every character happens to have a Latin twin:
| Character | Codepoint | Script | Looks like |
|---|---|---|---|
| р | U+0440 | Cyrillic | p |
| а | U+0430 | Cyrillic | a |
| у | U+0443 | Cyrillic | y |
| р | U+0440 | Cyrillic | p |
| а | U+0430 | Cyrillic | a |
| ӏ | U+04CF | Cyrillic | l |
Six characters, one script, no mixing anywhere — and the word is drawn
exactly like paypal. That is what whole-script confusability means,
and it applies to a username or a filename as readily as to a domain.
Where it bites hardest is hostnames, because there a second question follows:
which script is the top-level domain in? Measured with disarm — note the
suspicious column, which cannot tell the second row from the fourth.
| Hostname | suspicious | mixed_script | whole_script_confusable | folds to |
|---|---|---|---|---|
| paypal.com | false | false | false | paypal.com |
| раураӏ.com | true | false | true | paypal.com |
| раураӏ.рф | true | false | true | paypal.pф |
| яндекс.рф | true | false | false | яндekc.pф |
| рaypal.com | true | true | false | paypal.com |
Rows two and four both report suspicious. One is a Cyrillic
imitation of a payment provider; the other is Russia's largest search engine.
Blocking on that flag alone rejects the second, and ignoring it lets the first
through.
whole_script_confusable separates them, because the imitation folds
onto a Latin name and the real domain does not. The third row shows why the
top-level domain matters as well: the same Cyrillic label under
.рф is what a site written in Russian looks like, so
the usable policy is a label that folds onto Latin, under a Latin top-level
domain — which is what per-label results let you express.
Row five is the easier case and the one browsers already handle: two scripts inside one label, which no ordinary name does.
The same thing in your own code
Each code block has been compiled and verified. Provided under the MIT license to illustrate disarm. disarm on GitHub →
# Tell a spoofed domain from a real one written in another script.
# pip install disarm
from disarm import is_suspicious_hostname
# paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka. Every character
# agrees with every other, so nothing is mixed-script.
SPOOF = "раураӏ.com"
LEGIT = "яндекс.рф" # Russia's largest search engine
spoof_flag, spoof = is_suspicious_hostname(SPOOF)
legit_flag, legit = is_suspicious_hostname(LEGIT)
# The blunt signal cannot tell them apart — it fires for both.
assert spoof_flag and legit_flag, "suspicious flags both"
assert not spoof.mixed_script, "the spoof is not mixed-script; every letter is Cyrillic"
# Whole-script confusability is what separates them.
assert spoof.whole_script_confusable, "the spoof folds onto a Latin name"
assert not legit.whole_script_confusable, "the real domain does not"
assert spoof.canonical == "paypal.com", spoof.canonical
print("ok: both flagged suspicious; whole-script confusability separates them")
// Tell a spoofed domain from a real one written in another script.
// cargo add disarm
use disarm::api::analyze_hostname_with;
fn main() {
// paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka.
let spoof = "\u{0440}\u{0430}\u{0443}\u{0440}\u{0430}\u{04CF}.com";
let legit = "\u{044F}\u{043D}\u{0434}\u{0435}\u{043A}\u{0441}.\u{0440}\u{0444}";
let s = analyze_hostname_with(spoof, false);
let l = analyze_hostname_with(legit, false);
// The blunt signal cannot tell them apart — it fires for both.
assert!(s.suspicious && l.suspicious, "suspicious flags both");
assert!(!s.mixed_script, "the spoof is not mixed-script; every letter is Cyrillic");
// Whole-script confusability is what separates them.
assert!(s.whole_script_confusable, "the spoof folds onto a Latin name");
assert!(!l.whole_script_confusable, "the real domain does not");
assert_eq!(s.canonical, "paypal.com");
println!("ok: both flagged suspicious; whole-script confusability separates them");
}
// Tell a spoofed domain from a real one written in another script.
// npm i disarm
const assert = require("node:assert/strict");
const { analyzeHostname } = require("disarm");
// paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka.
const SPOOF = "раураӏ.com";
const LEGIT = "яндекс.рф";
const s = analyzeHostname(SPOOF);
const l = analyzeHostname(LEGIT);
// The blunt signal cannot tell them apart — it fires for both.
assert.ok(s.suspicious && l.suspicious, "suspicious flags both");
assert.ok(!s.mixedScript, "the spoof is not mixed-script; every letter is Cyrillic");
// Whole-script confusability is what separates them.
assert.ok(s.wholeScriptConfusable, "the spoof folds onto a Latin name");
assert.ok(!l.wholeScriptConfusable, "the real domain does not");
assert.equal(s.canonical, "paypal.com");
console.log("ok: both flagged suspicious; whole-script confusability separates them");
# Tell a spoofed domain from a real one written in another script.
# gem install disarm
require "disarm"
# paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka.
SPOOF = "\u{0440}\u{0430}\u{0443}\u{0440}\u{0430}\u{04CF}.com"
LEGIT = "\u{044F}\u{043D}\u{0434}\u{0435}\u{043A}\u{0441}.\u{0440}\u{0444}"
s = Disarm.analyze_hostname(SPOOF)
l = Disarm.analyze_hostname(LEGIT)
# The blunt signal cannot tell them apart — it fires for both.
raise "suspicious flags both" unless s[:suspicious] && l[:suspicious]
raise "the spoof is not mixed-script" if s[:mixed_script]
# Whole-script confusability is what separates them.
raise "the spoof folds onto a Latin name" unless s[:whole_script_confusable]
raise "the real domain does not" if l[:whole_script_confusable]
raise "unexpected canonical #{s[:canonical]}" unless s[:canonical] == "paypal.com"
puts "ok: both flagged suspicious; whole-script confusability separates them"
// Tell a spoofed domain from a real one written in another script.
// implementation("dev.disarm:disarm:0.14.1")
import dev.disarm.Disarm;
import dev.disarm.HostnameAnalysis;
public class CheckHostname {
public static void main(String[] args) {
// paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka.
String spoof = "раураӏ.com";
String legit = "яндекс.рф";
HostnameAnalysis s = Disarm.analyzeHostname(spoof);
HostnameAnalysis l = Disarm.analyzeHostname(legit);
// The blunt signal cannot tell them apart — it fires for both.
if (!(s.suspicious() && l.suspicious())) throw new AssertionError("suspicious flags both");
if (s.mixedScript()) throw new AssertionError("the spoof is not mixed-script");
// Whole-script confusability is what separates them.
if (!s.wholeScriptConfusable()) throw new AssertionError("the spoof folds onto a Latin name");
if (l.wholeScriptConfusable()) throw new AssertionError("the real domain does not");
if (!s.canonical().equals("paypal.com")) throw new AssertionError(s.canonical());
System.out.println("ok: both flagged suspicious; whole-script confusability separates them");
}
}
// Tell a spoofed domain from a real one written in another script.
// implementation("dev.disarm:disarm-kotlin:0.14.1")
import dev.disarm.kotlin.*
fun main() {
// paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka.
val spoof = "раураӏ.com"
val legit = "яндекс.рф"
val s = spoof.analyzeHostname()
val l = legit.analyzeHostname()
// The blunt signal cannot tell them apart — it fires for both.
check(s.suspicious() && l.suspicious()) { "suspicious flags both" }
check(!s.mixedScript()) { "the spoof is not mixed-script" }
// Whole-script confusability is what separates them.
check(s.wholeScriptConfusable()) { "the spoof folds onto a Latin name" }
check(!l.wholeScriptConfusable()) { "the real domain does not" }
check(s.canonical() == "paypal.com") { s.canonical() }
println("ok: both flagged suspicious; whole-script confusability separates them")
}
/* Tell a spoofed domain from a real one written in another script.
*
* The C ABI returns the analysis as a JSON object rather than a struct, so this
* looks for the fields directly instead of pulling in a parser. Keys are
* snake_case here, matching Rust and Python rather than the camelCase the Node
* and Java bindings use.
*/
#include <stdio.h>
#include <string.h>
#include "disarm.h"
static int has(const char *json, const char *key, const char *value) {
char needle[128];
snprintf(needle, sizeof needle, "\"%s\":%s", key, value);
return strstr(json, needle) != NULL;
}
int main(void) {
/* paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka. */
const char *spoof = "\U00000440\U00000430\U00000443\U00000440\U00000430\U000004CF.com";
const char *legit = "\U0000044F\U0000043D\U00000434\U00000435\U0000043A\U00000441.\U00000440\U00000444";
char *s = disarm_analyze_hostname(spoof);
char *l = disarm_analyze_hostname(legit);
if (!s || !l) { fprintf(stderr, "analysis failed\n"); return 1; }
/* The blunt signal cannot tell them apart — it fires for both. */
if (!has(s, "suspicious", "true") || !has(l, "suspicious", "true")) {
fprintf(stderr, "suspicious should flag both\n"); return 1;
}
if (!has(s, "mixed_script", "false")) { fprintf(stderr, "spoof is not mixed-script\n"); return 1; }
/* Whole-script confusability is what separates them. */
if (!has(s, "whole_script_confusable", "true")) { fprintf(stderr, "spoof folds onto Latin\n"); return 1; }
if (!has(l, "whole_script_confusable", "false")) { fprintf(stderr, "real domain does not\n"); return 1; }
printf("ok: both flagged suspicious; whole-script confusability separates them\n");
disarm_string_free(s);
disarm_string_free(l);
return 0;
}