Skip to content

Commit f7ae5a2

Browse files
committed
update
1 parent 97b1ab5 commit f7ae5a2

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

‎fundamentals/homoglyphs.md‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
---
2+
title: Homoglyphs in Python Code
3+
short_title: Homoglyphs
4+
---
5+
6+
:::{note} What are homoglyphs?
7+
Homoglyphs are Unicode characters that look visually identical (or nearly identical) to ASCII characters or to each other.
8+
Python’s Unicode support allows many of these characters in identifiers, which creates real security risks (see [PEP 672](https://peps.python.org/pep-0672/) and [Unicode Technical Report #39](https://www.unicode.org/reports/tr39/)).
9+
10+
A homoglyph is one of two or more graphemes, characters, or glyphs whose shapes appear identical or very similar ([Wikipedia](https://en.wikipedia.org/wiki/Homoglyph)).
11+
:::
12+
13+
## How Homoglyphs Can Be Misused in Python Code
14+
15+
Homoglyphs introduce security risks in several scenarios. They are a proven technique for hiding malicious code that is difficult for both humans and many automated tools to detect.
16+
17+
### Typosquatting / supply-chain attacks
18+
19+
A malicious package named `reqυests` (using a Greek upsilon `υ` instead of the Latin `u`) can be published to PyPI. Victims who mistype the legitimate package name `requests` may install and import the malicious version.
20+
21+
### Backdooring code
22+
23+
Two functions that look identical can coexist because Python treats them as different names:
24+
25+
```python
26+
def transfer(amount): # ASCII 'a'
27+
return real_transfer(amount)
28+
29+
def trаnsfer(amount): # Cyrillic 'а' — different identifier!
30+
exfiltrate(amount) # backdoor
31+
```
32+
33+
### Bypassing security checks
34+
35+
Regex filters and string comparisons that assume ASCII can be trivially bypassed:
36+
37+
```python
38+
>>> "раyment" == "payment"
39+
False
40+
>>> "раyment".isascii()
41+
False
42+
```
43+
44+
### Misuse of built-ins such as `exec`
45+
46+
The following call works because Python applies **NFKC normalisation** to identifiers:
47+
48+
```python
49+
ℯ𝓍ℯ𝒸("print(2 + 2)") # becomes exec after NFKC normalisation
50+
```
51+
52+
Python normalises identifiers with [NFKC](https://docs.python.org/3/library/unicodedata.html) at parse time, so `ℯ𝓍ℯ𝒸` is treated as `exec`. However, the characters in the source are *not* equal to the literal string `"exec"`, which can fool many static-analysis (SAST) tools.
53+
54+
:::{seealso} What is NFKC normalisation?
55+
:class: dropdown
56+
NFKC stands for *Normalization Form Compatibility Composition*.
57+
Characters are first decomposed by compatibility equivalence, then recomposed by canonical equivalence.
58+
See [Unicode equivalence](https://en.wikipedia.org/wiki/Unicode_equivalence) and the authoritative reference [Unicode Normalization Forms (UAX #15)](https://unicode.org/reports/tr15/).
59+
:::
60+
61+
Consequently, the following also executes successfully and is frequently missed by SAST scanners:
62+
63+
```python
64+
ℯ𝓍ℯ𝒸("import os; os.system('rm -rf ~/.*')")
65+
```
66+
67+
## Defensive Tips
68+
69+
To protect your codebase from homoglyph attacks and unintended identifier spoofing, implement the following defensive practices:
70+
71+
* **Enforce ASCII-Only Identifiers:** PEP 8 recommends restricting identifiers to ASCII characters. You can enforce this automatically using modern linters (such as Ruff, Flake8, or Pylint) or with a simple pre-commit hook or CI check that rejects non-ASCII characters in source files.
72+
73+
* **Validate Dynamic Identifiers:** If your application accepts or constructs identifiers dynamically (such as through user input, plugins, or reflection), validate them immediately using `str.isascii()` before execution or lookup.
74+
75+
* **Detect Normalization Anomalies:** Use Python's `unicodedata` module to check if an identifier changes under NFKC normalization or deviates from standard ASCII:
76+
77+
```python
78+
import unicodedata
79+
80+
def is_safe_identifier(identifier: str) -> bool:
81+
if identifier.isascii():
82+
return True
83+
84+
# Flag identifiers that change under NFKC normalization
85+
normalized = unicodedata.normalize("NFKC", identifier)
86+
if normalized != identifier:
87+
return False
88+
89+
return True
90+
```
91+
92+
93+
* **Configure Review Tooling and SAST:** Ensure that your code-review interfaces, IDEs, and static analysis (SAST) scanners are configured to explicitly highlight non-ASCII characters, zero-width characters, and mixed-script identifiers so reviewers can catch obfuscated code.
94+
95+
:::{note}
96+
Remember that disabling UTF-8 mode (`python -X utf8=0` / `PYTHONUTF8=0`) only affects runtime I/O encoding.
97+
98+
Normally, when UTF-8 Mode is active (enabled via `-X utf8` or automatically in most environments like POSIX `C` locales), and becoming the default in modern Python versions, Python ignores the system locale and forces UTF-8 for things like `open()`, standard input/output streams, and filenames.
99+
:::
100+
101+
:::{danger}
102+
Disabling `UTF8` is **NOT** recommended and a bad security practice!
103+
It does **not** restrict Unicode in source code and is not a useful defence.
104+
:::

‎toc.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ project:
1616
- file: fundamentals/threatmodel.md
1717
- file: fundamentals/attacklandscape.md
1818
- file: fundamentals/dependencies.md
19+
- file: fundamentals/homoglyphs.md
1920
- file: fundamentals/whatissast.md
2021
- file: fundamentals/pypi_security.md
2122
- file: fundamentals/enhancedsecurity.md

0 commit comments

Comments
 (0)