# 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

Source: https://socket.dev/glossary/homoglyph-attack

Last updated: 2026-11-09

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](https://socket.dev/blog/malicious-nuget-packages-typosquat-nethereum-to-exfiltrate-wallet-keys), a malicious [NuGet](https://socket.dev/glossary/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.

> Figure 1. One code point differs: U+0065 LATIN SMALL LETTER E versus U+0435 CYRILLIC SMALL LETTER IE.
>
> Nethereum versus a homoglyph lookalike, character by character

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](https://www.unicode.org/reports/tr39/) 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`](https://www.xudongz.com/blog/2017/idn-phishing/), 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](https://socket.dev/glossary/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.

| Technique | What it exploits | Example | Blocked by ASCII-only names? |
| --- | --- | --- | --- |
| Typosquatting | Typing errors | A hypothetical reqeusts for requests | No |
| Cross-script homoglyph | Reading, with non-Latin letters | Netherеum.All (U+0435) | Yes |
| ASCII-lookalike homoglyph | Reading, with Latin letters and digits | jeIlyfish (capital I for l) | No |
| Bidirectional text (Trojan Source) | Display reordering by invisible control characters | CVE-2021-42574 | Not applicable: targets source code |

Bidirectional-text attacks are a separate mechanism. Nicholas Boucher and Ross Anderson's [Trojan Source](https://trojansource.codes/) 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.

| Registry | Rule for new names | Cross-script homoglyph | ASCII lookalikes |
| --- | --- | --- | --- |
| npm | Name must survive encodeURIComponent unchanged and be lowercase | Rejected | Folds punctuation only, since December 2017 |
| PyPI | ^(\[A-Z0-9\]\|\[A-Z0-9\]\[A-Z0-9.\_-\]\*\[A-Z0-9\])$, case-insensitive | Rejected | Folds l/L/i/I to 1, o/O to 0, strips . \_ - |
| crates.io | ASCII letters, digits, -, \_ | Rejected | Folds case and - to \_ only |
| Go modules | Leading path element: lowercase ASCII letters, digits, dots, dashes | Rejected | No registration step to check |
| NuGet | ASCII letters, digits, dots, dashes, since June 2026 | Rejected for new IDs since June 15, 2026; all pushes since July 15, 2026 | None 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:

```js
const validate = require("validate-npm-package-name");
console.log(validate("axios"));
console.log(validate("аxios")); // Cyrillic а
```

```text
{ validForNewPackages: true, validForOldPackages: true }
{
  validForNewPackages: false,
  validForOldPackages: false,
  errors: [ 'name can only contain URL-friendly characters' ]
}
```

```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](https://github.com/NuGet/Announcements/issues/75): 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](https://socket.dev/blog/how-socket-combats-insidious-typosquatting-supply-chain-attacks), a malicious [PyPI](https://socket.dev/glossary/python-package-index-pypi) package that impersonated the `jellyfish` library and stole SSH and GPG keys. Lukas Martini [disclosed it on December 1, 2019](https://github.com/dateutil/dateutil/issues/984), 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](https://github.com/pypi/warehouse/blob/main/warehouse/migrations/versions/d18d443f89f0_ultranormalize_name_function.py), 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)}")
```

```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](https://blog.npmjs.org/post/168978377570/new-package-moniker-rules) 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.

## Where homoglyphs still work: listings, links, and phishing

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](https://socket.dev/blog/firefox-wallet-impersonators). 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](https://socket.dev/blog/firefox-crypto-wallet-theft), an OKX impersonator was listed as `⭘K✖ WaIIet`, with two capital I's standing in for "ll". [Browser extension security](https://socket.dev/glossary/browser-extension-security) reviews that read the listing will not catch either.

**Phishing pages behind extensions.** The [fake imToken Chrome extension](https://socket.dev/blog/fake-imtoken-chrome-extension-steals-seed-phrases-via-phishing-redirects) 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:

```js
for (const u of ["https://github.com/axios/axios",
                 "https://gіthub.com/axios/axios",   // Cyrillic і
                 "https://аррӏе.com/"]) {
  console.log(new URL(u).hostname);
}
```

```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](https://chromium.googlesource.com/chromium/src/+/main/docs/idn.md) 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](https://socket.dev/glossary/phishing) against maintainers, but the recent ones we analyzed needed no Unicode. The [Qix compromise](https://socket.dev/blog/npm-author-qix-compromised-in-major-supply-chain-attack) 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](https://socket.dev/blog/npm-phishing-email-targets-developers-with-typosquatted-domain) 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](https://socket.dev/glossary/maintainer-account-takeover): in the [axios compromise](https://socket.dev/blog/axios-maintainer-confirms-social-engineering-behind-npm-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:

```js
const isAdmin = false;
const isАdmin = true;            // U+0410 CYRILLIC CAPITAL LETTER A
if (isАdmin) console.log("access granted");
```

```text
$ node admin.js
access granted
```

A reviewer reads `if (isAdmin)` and moves on. The Go equivalent passes `go vet` cleanly. In [code review](https://socket.dev/glossary/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](https://rust-lang.github.io/rfcs/2457-non-ascii-idents.html), 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:

```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](https://socket.dev/blog/malicious-nuget-package-typosquats-popular-net-tracing-library) 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](https://socket.dev/glossary/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](https://code.visualstudio.com/updates/v1_63) 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)}")
```

```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.

> 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.
>
> Tracing a homoglyph listing name through NFKC and the UTS 39 skeleton

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.

| Defense | Catches | Misses | Cost |
| --- | --- | --- | --- |
| ASCII-only identifiers (npm, PyPI, crates.io, Go, NuGet) | Every cross-script package name | ASCII lookalikes, display names, domains, code | Excludes legitimate non-English names |
| NFKC normalization (Python identifiers) | Compatibility forms such as mathematical bold | Cyrillic, Greek, and Armenian lookalikes | Can merge names that look different |
| PyPI ultranormalize\_name | l/I/i/1, o/O/0, separators | rn/m, everything outside those classes | Occasional false collisions on legitimate names |
| Mixed-script check (Chrome, Rust lint) | Latin mixed with Cyrillic or Greek in one token | Whole-script spoofs such as "аррӏе" | Needs a separate whole-script check |
| UTS #39 skeleton vs. popular names | 6,712 mapped confusables, across scripts and within ASCII | Unmapped symbols such as † and ⭘ | Needs a curated target list; skeletons are not display text |
| Behavioral analysis of the package | Malicious code regardless of its name | Benign impersonation with no payload | Requires 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](https://socket.dev/blog/malicious-nuget-packages-typosquat-nethereum-to-exfiltrate-wallet-keys), 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](https://socket.dev/alerts/didYouMean) 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.
