Back to glossary

Homoglyph Attack

A homoglyph attack swaps in lookalike characters, like Cyrillic а for Latin a, so a malicious domain, package, or identifier passes as a trusted one.

Also known as homograph attack, IDN homograph attack, Unicode spoofing, confusable characters

A homoglyph attack, also called a homograph attack or Unicode spoofing, substitutes characters that render identically, such as Cyrillic "а" (U+0430) for Latin "a" (U+0061), into a domain, package name, or code identifier so a different string passes as a trusted one. Software compares code points; people compare shapes. The attack lives in that gap.

The cleanest supply chain example we have analyzed is Netherеum.All, a malicious NuGet package. Its name carries one Cyrillic "е" (U+0435) where Nethereum, the standard .NET Ethereum library, has a Latin "e". It was published on October 16, 2025, and NuGet removed it on October 20, 2025. Inside was a routine that sent wallet secrets to a hardcoded server.

Our position is that, in package registries, the package name is no longer where homoglyphs do their damage. As of November 2026, every major registry we checked rejects new non-ASCII package names, NuGet included. The homoglyph has moved to surfaces nobody normalizes: ASCII lookalikes such as I for l, marketplace display names, domains in links, and identifiers inside the code. A name check is a triage signal. What the code does is the verdict.

What is a homoglyph attack?

#

A homoglyph attack exploits the fact that Unicode encodes characters by identity, not by appearance. Latin "a" is U+0061, Cyrillic "а" is U+0430, and Greek "α" is U+03B1. Most fonts draw the first two with the same outline. Ordinary string comparison, from === to a database index to a registry lookup, compares code points or the bytes that encode them. So "Nethereum" === "Netherеum" is false, while every person reading both strings sees one word.

Each cell: rendered glyph, Unicode code point (hex), UTF-8 bytes.

Legitimate: Nethereum

N004E4E
e006565
t007474
h006868
e006565
r007272
e006565
u007575
m006D6D

Malicious: Netherеum (from Netherеum.All on NuGet, October 2025)

N004E4E
e006565
t007474
h006868
e006565
r007272
е0435D0 B5
u007575
m006D6D
What a person seesThe same word
String equalityfalse: 9 vs 10 bytes
UTS #39 skeletonBoth Nethereurn: confusable
Figure 1. One code point differs: U+0065 LATIN SMALL LETTER E versus U+0435 CYRILLIC SMALL LETTER IE.

Figure 1 compares the real name Nethereum with the malicious Netherеum, character by character. Eight of the nine characters are identical code points. The seventh character differs: the real name has U+0065 LATIN SMALL LETTER E, encoded in UTF-8 as the single byte 65. The fake has U+0435 CYRILLIC SMALL LETTER IE, encoded as the two bytes D0 B5. On screen the two are indistinguishable. Under the Unicode skeleton transform described below, both names reduce to the same comparison key, Nethereurn.

"Homoglyph" names the lookalike character; "homograph" names the whole lookalike string. Unicode's own term is "confusable". Unicode Technical Standard #39 publishes the mapping data, and the confusables.txt file for Unicode 18.0.0 contains 6,712 mappings from one character to its lookalike.

Homoglyphs come in two kinds, and the difference decides which defenses work:

  • Cross-script homoglyphs borrow letters from Cyrillic, Greek, Armenian, or other scripts: а for a, Τ (Greek tau) for T, օ (Armenian oh) for o.
  • ASCII lookalikes need no Unicode at all: I for l, 1 for l, 0 for O, rn for m. Any rule that only bans non-ASCII characters leaves these untouched.

The technique predates package registries. Evgeniy Gabrilovich and Alex Gontmakher described it in "The Homograph Attack" (Communications of the ACM, February 2002), after registering a Cyrillic lookalike of microsoft.com. In April 2017 Xudong Zheng registered xn--80ak6aa92e.com, which Chrome, Firefox, and Opera displayed as "аррӏе.com", every letter Cyrillic. That variant, against domain names, is the IDN homograph attack.

How do homoglyph attacks differ from typosquatting?

#

A homoglyph attack targets reading, while typosquatting targets typing. A typosquat such as a hypothetical reqeusts waits for a slipped finger. Netherеum.All waits for a developer to copy a name from a search result, click a link in a README, or approve a pull request without inspecting code points. Few people type U+0435 by accident, so homoglyphs arrive through copy-paste, links, AI suggestions, and review.

TechniqueWhat it exploitsExampleBlocked by ASCII-only names?
TyposquattingTyping errorsA hypothetical reqeusts for requestsNo
Cross-script homoglyphReading, with non-Latin lettersNetherеum.All (U+0435)Yes
ASCII-lookalike homoglyphReading, with Latin letters and digitsjeIlyfish (capital I for l)No
Bidirectional text (Trojan Source)Display reordering by invisible control charactersCVE-2021-42574Not applicable: targets source code

Bidirectional-text attacks are a separate mechanism. Nicholas Boucher and Ross Anderson's Trojan Source research filed two CVEs in 2021. CVE-2021-42574 covers bidirectional control characters that reorder how code displays. CVE-2021-42694 covers homoglyphs: functions whose names look identical to legitimate ones but are different identifiers. Their summary applies to both: "These adversarial encodings produce no visual artifacts."

Bidi attacks hide characters; homoglyph attacks show the wrong ones. A bidi defense, such as warning on invisible control characters, does nothing against a visible Cyrillic "а".

Registries now reject Unicode package names

#

Attackers cannot publish cross-script homoglyph package names on the major registries as of November 2026. Every registry below restricts new names to ASCII. The rules differ on ASCII lookalikes, which is where the remaining name-level risk lives.

RegistryRule for new namesCross-script homoglyphASCII lookalikes
npmName must survive encodeURIComponent unchanged and be lowercaseRejectedFolds punctuation only, since December 2017
PyPI^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$, case-insensitiveRejectedFolds l/L/i/I to 1, o/O to 0, strips . _ -
crates.ioASCII letters, digits, -, _RejectedFolds case and - to _ only
Go modulesLeading path element: lowercase ASCII letters, digits, dots, dashesRejectedNo registration step to check
NuGetASCII letters, digits, dots, dashes, since June 2026Rejected for new IDs since June 15, 2026; all pushes since July 15, 2026None documented

You can reproduce the npm and Go rules locally. The npm check uses validate-npm-package-name 8.0.0, the name validator the npm CLI depends on:

JavaScript
const validate = require("validate-npm-package-name");
console.log(validate("axios"));
console.log(validate("аxios")); // Cyrillic а
Plain text
{ validForNewPackages: true, validForOldPackages: true }
{
  validForNewPackages: false,
  validForOldPackages: false,
  errors: [ 'name can only contain URL-friendly characters' ]
}
Plain text
$ go mod init "github.com/аxios/axios"
go: malformed module path "github.com/аxios/axios": invalid char 'а'

NuGet was the outlier until mid-2026. When we reported Netherеum.All in October 2025, NuGet's package ID rules accepted Unicode letters. In June 2026, NuGet announced ASCII-only package IDs: new IDs must "use only ASCII letters, digits, dots, and dashes." The announcement estimated that about 119 existing IDs across 65 owners, roughly 0.02% of the catalog, break the rule. NuGet's stated reason was inconsistent string normalization across platforms, not spoofing, but the effect is the same. Netherеum.All could not be published today.

ASCII lookalikes are the gap that remains

#

ASCII lookalikes pass every ASCII-only rule. The canonical case is jeIlyfish, a malicious PyPI package that impersonated the jellyfish library and stole SSH and GPG keys. Lukas Martini disclosed it on December 1, 2019, describing it as "a fake version of the jellyfish package, that first L is an I."

Two years later, PyPI closed that class of name at the registry. A December 2021 database migration added an ultranormalize_name function, and project creation now rejects any name whose ultranormalized form matches an existing project. Ported to Python:

Python
import re

def ultranormalize(name):
    name = re.sub(r"[._-]", "", name)
    name = re.sub(r"[lLiI]", "1", name)
    name = re.sub(r"[oO]", "0", name)
    return name.lower()

for n in ["jellyfish", "jeIlyfish", "lodash", "1odash", "matplotlib", "rnatplotlib"]:
    print(f"{n:12} -> {ultranormalize(n)}")
Plain text
jellyfish    -> je11yf1sh
jeIlyfish    -> je11yf1sh
lodash       -> 10dash
1odash       -> 10dash
matplotlib   -> matp10t11b
rnatplotlib  -> rnatp10t11b

The fold catches jeIlyfish and 1odash. It misses rnatplotlib, because rn for m is a two-character lookalike the function does not model. npm has no equivalent fold. Its December 2017 rule blocks names that differ from an existing package "in punctuation only." That is why 1odash has coexisted with lodash on npm since its registry record was created on January 10, 2018.

Homoglyphs still work wherever a registry or marketplace shows free text that nobody normalizes. The recent cases we have analyzed cluster on four surfaces outside the code itself.

Marketplace display names. A package's identifier may be ASCII-only while its listing title is arbitrary Unicode. In October 2026 we found malicious Firefox extensions impersonating Phantom, Jupiter, and Bitget. The Phantom listing's display name was 𝐏ha𝐧†օ𝗺: mathematical bold letters, a dagger, and an Armenian "օ". Mozilla had signed the packages, and the full stealer was present in the reviewed code. In a separate cluster of 77 Firefox extensions linked to wallet and credential theft, an OKX impersonator was listed as ⭘K✖ WaIIet, with two capital I's standing in for "ll". Browser extension security reviews that read the listing will not catch either.

Phishing pages behind extensions. The fake imToken Chrome extension we reported in March 2026 opened a lookalike page titled іmΤоken: Cyrillic "і", Greek "Τ", Cyrillic "о". Its seed-phrase prompt read Sееd-Phrase, with both "e" characters Cyrillic. Mixed scripts defeat simple text matching in the detection rules that defenders write.

Domains in links. A hostname with a homoglyph is a different domain. The WHATWG URL parser, which Node.js and browsers implement, converts it to its Punycode form:

JavaScript
for (const u of ["https://github.com/axios/axios",
                 "https://gіthub.com/axios/axios",   // Cyrillic і
                 "https://аррӏе.com/"]) {
  console.log(new URL(u).hostname);
}
Plain text
github.com
xn--gthub-n2e.com
xn--80ak6aa92e.com

The last line reproduces Zheng's 2017 domain exactly. Chrome now shows the Punycode form in its address bar for many such labels. Its IDN display policy bans mixing Latin, Cyrillic, and Greek in one label, flags all-Cyrillic labels that look Latin outside TLDs known to host many Cyrillic domains, and compares each domain's skeleton against a list of top domains. None of that applies to a README, a package.json repository field, an email, a terminal, or a chat message, where the Unicode form renders as written. Tooling should compare hostnames only after new URL() or IDNA ToASCII, never as display text.

Maintainer phishing. Lookalike domains carry phishing against maintainers, but the recent ones we analyzed needed no Unicode. The Qix compromise in September 2025, which hit packages with 2 to 3 billion weekly downloads, started with a 2FA-reset email from support@npmjs.help. Every character was ASCII and spelled correctly; the trick was the top-level domain. Two months earlier we analyzed an npm phishing email that linked to npnjs.com, with "n" in place of "m", aimed at a maintainer of packages with 34 million weekly downloads. Neither domain matches npmjs.com under the skeleton check described below; we ran both. Phishing-resistant authentication such as WebAuthn defeats both variants, because the credential is bound to the real origin and a lookalike domain cannot request it. That, not glyph inspection, is the defense against credential phishing. It is not a complete defense against maintainer account takeover: in the axios compromise, the attacker controlled the maintainer's own machine, so 2FA and OIDC-based publishing did not stand in the way.

Homoglyph identifiers in source code

#

Homoglyph identifiers hide in source code because most languages accept Unicode letters in names and say nothing when two names look alike. JavaScript, Go, and Python all run this without a warning:

JavaScript
const isAdmin = false;
const isАdmin = true;            // U+0410 CYRILLIC CAPITAL LETTER A
if (isАdmin) console.log("access granted");
Plain text
$ node admin.js
access granted

A reviewer reads if (isAdmin) and moves on. The Go equivalent passes go vet cleanly. In code review, the diff looks correct because it is rendered by the same fonts that make the attack work.

Python adds a twist. PEP 3131 normalizes identifiers with NFKC, so compatibility variants collapse together while cross-script lookalikes stay distinct:

Python
isAdmin = False
isАdmin = True          # Cyrillic А: a different variable
print(isAdmin)          # False
𝐢𝐬𝐀𝐝𝐦𝐢𝐧 = True          # mathematical bold: NFKC turns this into isAdmin
print(isAdmin)          # True

In Python, a line that looks like a new variable can silently overwrite an existing one, and a line that looks like an existing variable can create a new one.

Rust is the exception. RFC 2457, which allowed non-ASCII identifiers, specified two lints, and rustc enables both by default. Compiling the same logic with rustc 1.97.0 prints two warnings. The binary still builds and still prints "access granted", but the reviewer gets told:

Plain text
warning: found both `is_admin` and `is_аdmin` as identifiers, which look alike
 --> main.rs:3:9
  |
2 |     let is_admin = false;
  |         -------- other identifier used here
3 |     let is_аdmin = true; // U+0430 CYRILLIC SMALL LETTER A
  |         ^^^^^^^^ this identifier can be confused with `is_admin`
  |
  = note: `#[warn(confusable_idents)]` on by default

warning: the usage of Script Group `Cyrillic` in this crate consists solely of mixed script confusables
 --> main.rs:3:9
  |
3 |     let is_аdmin = true; // U+0430 CYRILLIC SMALL LETTER A
  |         ^^^^^^^^
  |
  = note: the usage includes 'а' (U+0430)
  = note: please recheck to make sure their usages are indeed what you want
  = note: `#[warn(mixed_script_confusables)]` on by default

The same trick shows up in compiled packages. The Tracer.Fody.NLog wallet stealer on NuGet set its DLL's AssemblyCompany attribute to Тrасer.Fоdy, with four Cyrillic letters, and named its payload host class Guаrd with a Cyrillic "а". Homoglyph identifiers are a form of code obfuscation that survives decompilation, because the decompiler faithfully prints the lookalike name.

Editors can surface the problem. Since version 1.63 in November 2021, VS Code highlights ambiguous characters by default through editor.unicodeHighlight.ambiguousCharacters, and its release notes concede the detection "is done heuristically." That helps the person at the keyboard. It does nothing for code that arrives inside a dependency and is never opened.

How can developers detect homoglyph attacks?

#

Developers detect homoglyph attacks by comparing skeletons, not strings. UTS #39 section 4 defines skeleton(X) as: decompose X with NFD, replace each character using confusables.txt, then decompose again with NFD. Two strings are confusable when their skeletons match. A skeleton is a comparison key, not readable text: both Nethereum and Netherеum become Nethereurn, because the data maps m to rn.

The whole algorithm is a dozen lines. We ran it against the strings from our own investigations, using confusables.txt for Unicode 18.0.0:

Python
import unicodedata

M = {}
for line in open("confusables.txt", encoding="utf-8-sig"):
    line = line.split("#", 1)[0].strip()
    if not line:
        continue
    src, dst, _ = (f.strip() for f in line.split(";"))
    M[chr(int(src, 16))] = "".join(chr(int(c, 16)) for c in dst.split())

def skeleton(s):
    s = unicodedata.normalize("NFD", s)
    return unicodedata.normalize("NFD", "".join(M.get(c, c) for c in s))

pairs = [("Netherеum.All", "Nethereum.All"), ("Тrасer.Fоdy", "Tracer.Fody"),
         ("іmΤоken", "imToken"), ("WaIIet", "Wallet"), ("𝐏ha𝐧†օ𝗺", "Phantom"),
         ("1odash", "lodash"), ("rnatplotlib", "matplotlib")]
for fake, real in pairs:
    nfkc = unicodedata.normalize("NFKC", fake) == real
    print(f"{fake:15} nfkc={nfkc!s:5} skeleton={skeleton(fake) == skeleton(real)}")
Plain text
Netherеum.All   nfkc=False skeleton=True
Тrасer.Fоdy     nfkc=False skeleton=True
іmΤоken         nfkc=False skeleton=True
WaIIet          nfkc=False skeleton=True
𝐏ha𝐧†օ𝗺         nfkc=False skeleton=False
1odash          nfkc=False skeleton=True
rnatplotlib     nfkc=False skeleton=True

NFKC normalization caught none of the seven. The skeleton caught six, including the ASCII lookalikes and rnatplotlib, which PyPI's fold misses. It missed the Phantom listing.

GlyphCode pointNFKCSkeleton
𝐏U+1D40FMathematical bold capital PPP
hU+0068Latin small hhh
aU+0061Latin small aaa
𝐧U+1D427Mathematical bold small nnn
†U+2020Dagger††
օU+0585Armenian small ohօo
𝗺U+1D5FAMath sans-serif bold small mmrn
Fake skeletonPhan†orn
Real skeletonPhantorn: no match
Figure 2. The Phantom impersonator's Firefox listing name. U+2020 DAGGER has no entry in confusables.txt, so it survives both transforms and breaks the match.

Figure 2 traces the Phantom listing name through both transforms. U+1D40F MATHEMATICAL BOLD CAPITAL P becomes P under NFKC and under the skeleton map. h and a are plain Latin and pass through. U+1D427 MATHEMATICAL BOLD SMALL N becomes n under both. U+2020 DAGGER stays † under both, because confusables.txt has no entry for it. U+0585 ARMENIAN SMALL LETTER OH is unchanged by NFKC but maps to o in the skeleton. U+1D5FA MATHEMATICAL SANS-SERIF BOLD SMALL M becomes m under NFKC and rn in the skeleton. The fake's skeleton is Phan†orn; the real name's is Phantorn. One unmapped symbol is enough to break the match.

The confusables data covers characters that look like letters in common fonts. Attackers are not limited to that list: a dagger reads as a "t", and ⭘ reads as an "O", yet neither has a mapping. No defense in the table below is complete, which is why they stack.

DefenseCatchesMissesCost
ASCII-only identifiers (npm, PyPI, crates.io, Go, NuGet)Every cross-script package nameASCII lookalikes, display names, domains, codeExcludes legitimate non-English names
NFKC normalization (Python identifiers)Compatibility forms such as mathematical boldCyrillic, Greek, and Armenian lookalikesCan merge names that look different
PyPI ultranormalize_namel/I/i/1, o/O/0, separatorsrn/m, everything outside those classesOccasional false collisions on legitimate names
Mixed-script check (Chrome, Rust lint)Latin mixed with Cyrillic or Greek in one tokenWhole-script spoofs such as "аррӏе"Needs a separate whole-script check
UTS #39 skeleton vs. popular names6,712 mapped confusables, across scripts and within ASCIIUnmapped symbols such as † and ⭘Needs a curated target list; skeletons are not display text
Behavioral analysis of the packageMalicious code regardless of its nameBenign impersonation with no payloadRequires reading what the package does

Our verdict: registries should keep ASCII-only identifiers and add a skeleton collision check against their most-downloaded names, because the skeleton subsumes PyPI's fold and catches rn/m. Marketplaces should run the same skeleton check on display names and publisher names, the surface both Firefox cases above exploited. Treat any match as a reason to look closer, then decide on behavior.

What a name check cannot tell you

#

A homoglyph name check tells you that a package is impersonating something. It does not tell you what the package does, and the cases we have analyzed show why that second question matters more.

Netherеum.All ran nothing at install time. It referenced real Nethereum libraries, so a project restored cleanly, compiled, and performed real Ethereum operations. The payload sat in one method, EIP70221TransactionService.Shuffle, which XOR-decoded a hardcoded server address and posted whatever string it received, such as a mnemonic or private key, as a form field named message. Its download counter spiked within days of publication, a pattern we attributed to automated inflation, so the name and the popularity signal both lied. The same operator had already published an earlier typosquat, NethereumNet, with the same code and a plain ASCII name.

Tracer.Fody.NLog shows the inverse. Its package name was plain ASCII, its publisher alias csnemess differed from the real maintainer csnemes by one letter, and the homoglyphs sat inside the compiled DLL where no name check looks. It had been live on NuGet for more than five years, with roughly 2,000 downloads, when we reported it in December 2025.

That is why we analyze what is in the package, not what its metadata claims. Socket's Possible typosquat alert compares a name against similarly named packages with much higher download counts, and its documentation uses jeIlyfish as its example. On its own, that alert is a lead. The decision comes from the code: an XOR-decoded endpoint, a network call inside a cryptographic helper, a Guаrd class that reads WalletPassword by reflection.

The open problem is display text. Registries closed the name gap by restricting identifiers to ASCII, and PyPI went further with lookalike folding. Firefox Add-ons accepted 𝐏ha𝐧†օ𝗺 as a listing title in 2026. Chrome already compares every domain's skeleton against its top domains before rendering it. Stores that sign and distribute code should hold listing titles to at least the standard a browser applies to a URL.