Unicode 同形字攻击:用视觉相似但编码不同的字符绕过关键词过滤

想象一下:你收到一封邮件,发件人是你最常去的那家银行,地址栏里清清楚楚写着 bank.com。你点进去,输入账号密码,然后你的钱就没了。问题出在哪?出在那个 "a" 上——它其实不是拉丁字母 a,而是西里尔字母 а。你的眼睛看不出区别,但电脑知道:这是两个完全不同的字符。

AI technology illustration

这不是科幻情节。这是 Unicode 同形字攻击的标准操作。这类攻击不依赖病毒、木马,也不依赖缓冲区溢出,它只依赖一个残酷的事实:人类用视觉读文字,计算机用编码读文字,而这两种系统从来就不是一一对应的。

同一个 "a",在 Unicode 里却住着几个不同的灵魂

Unicode 的目标是为世界上所有书写系统的每个字符分配一个唯一的数字编号,称为码点。比如拉丁小写字母 a 是 U+0061,西里尔小写字母 а 是 U+0430,希腊小写字母 α 是 U+03B1。这三个字符在大多数现代字体里看起来几乎一样,但它们是三个完全不同的码点。

代码里做字符串比较时,机器只看码点是否相等。所以 "hello""hеllo"(第二个 e 是西里尔字母 е)在电脑看来就是两个不同的字符串,但你在屏幕上看,它们一模一样。这就是同形字攻击的根基。

同形字(Homoglyph)
两个或多个字符在视觉上相同或高度相似,但码点不同。例如拉丁 a 和西里尔 а。
同形字攻击(Homograph Attack)
利用同形字制作仿冒域名、伪装文本,从而欺骗用户或绕过安全检查。

下面列出几组极易被利用的同形字:

你看到的字符 码点 来自哪个语言
a U+0061 拉丁字母
а U+0430 西里尔字母
α U+03B1 希腊字母
o U+006F 拉丁字母
о U+043E 西里尔字母
ο U+03BF 希腊字母
e U+0065 拉丁字母
е U+0435 西里尔字母

攻击者用同形字在三个战场兴风作浪

  1. 域名仿冒(IDN Homograph Attack)安全研究者很早就证明,可以注册一个与 paypal.com 外观几乎一致的域名。由于国际化域名(IDN)会把Unicode 字符直接显示在地址栏,用户很难看出差异。
  2. 关键词过滤绕过:把敏感词里的某个字符替换成异语系同形字。比如把英文脏话中的 c 换成西里尔 с,或者把中文文本里的某些笔画换成相似的全角字符。多数基于字符串匹配的过滤系统会直接放行。
  3. 用户名冒充:在社交平台注册与官方账号使用同形字的用户名,然后私信发钓鱼链接。平台风控若不检测视觉相似,这种冒充几乎无法被算法识别。

我踩过的坑:Normalize 并不能救你

我最早听说同形字攻击时,第一反应是:用 Python 的 unicodedata.normalize 把字符串都转成标准形式不就行了?结果一试发现完全不行。NFC 规范化解决的是 规范等价——比如带音调的 é 可以由 e 加组合符号构成,规范化后变成同一个码点。但西里尔 а 和拉丁 a 在 Unicode 定义里没有任何等价关系,它们天生就是两个字符,规范化根本不会改变它们。

想通这一点后我才明白,这类攻击的难点不在编码处理,而在视觉相似性的量化。计算机没有眼睛,怎么判断两个字符“看起来像”?这需要对字形、笔画、字体渲染做大量的统计分析,而且不同字体、不同分辨率下结果还会变。

这里有个典型的“规范等价 vs 视觉相似”的对比:

规范等价 视觉相似
例子 é = e + 组合重音符号 拉丁 a = 西里尔 а
Unicode 是否认为同一个字符 是(规范化后相同) 否(始终是不同码点)
能否用 normalize 合并 不能
攻击价值 低(容易防御) 高(难以穷举)

Unicode 官方的回答:confusable 机制

Unicode 联盟其实早就意识到这个问题了。他们在 UTR #36(Unicode Security Considerations)里专门讨论了同形字风险,后来又发布了 UTS #39(Unicode Security Mechanisms),其中引入了 confusable detection 机制。UTS #39 定义了一个“易混淆字符映射表”,列出哪些字符在视觉上可能被混淆,并给出算法来检测一个字符串是否由易混淆字符构成。这个机制也是很多堡垒式过滤产品的底层依据。

在应用层,一些浏览器和邮件客户端已经开始内置这种做法。比如 Chrome 对 IDN 域名有严格的限制:如果域名里的字符混合了多个语系,就强制用 punycode 显示(即 xn-- 开头的 ASCII 形式)。这样你就能看到真实的域名,而不是被同形字伪装的样子。

一个检测同形字的 Python 简易实现
# 部分 confusable 映射,实际应使用 Unicode 官方数据
confusables = {
    'а': 'a',  # 西里尔 a
    'е': 'e',  # 西里尔 e
    'о': 'o',  # 西里尔 o
    'ρ': 'p',  # 希腊 rho
}

def normalize_homoglyphs(s):
    return ''.join(confusables.get(c, c) for c in s)

text = 'hеllo'  # 第二个字符是西里尔 e
print(normalize_homoglyphs(text))  # 输出 hello

为什么这道题至今无解

既然有官方检测表,为什么同形字攻击还是层出不穷?原因很现实:视觉相似是一个连续谱,而字符编码是离散的。同一个字符在不同字体下可能长得完全不同——比如大写字母 I、小写字母 l 和数字 1,在衬线字体里极难分辨;而在等宽字体里,某些全角字符和半角字符看起来也几乎一样。攻击者永远能找到检测表之外的漏网之鱼。

更麻烦的是,用户输入不只有字母和数字,还有 Emoji、数学符号、汉字异体字、假名变体等。整个 Unicode 目前收录了超过 14 万个字符,人工维护的混淆表很难穷尽。

“Unicode 同形字攻击的本质不是编码漏洞,而是人机认知鸿沟的副作用。任何试图在字符层面彻底解决的方案,都会不可避免地陷入字体的泥沼。”

你该怎么防身?

对开发者来说,有几条实用思路,但都不是银弹:

  • 对敏感场景采用允许列表:比如域名注册、支付页面,只允许 IDN 白名单内的字符集,或者强制显示 punycode。
  • 在过滤前做视觉归一化:根据 confusable 表把容易混淆的字符映射到基准字符,再执行关键词匹配。映射表需要持续更新,误判率也要小心控制。
  • 引入视觉 AI 检测:用 OCR 或视觉模型直接渲染字符串再比对。算力成本高,但能捕捉到很多编码规则抓不到的相似。
  • 用户教育不能省:告诉用户看到 https 不等于安全,看到熟悉的域名也要核对地址栏细节。但说实话,这要求有点反人性。

同形字攻击让我想起一个道理:安全防线每加固一层,攻击者就会从你想不到的维度绕过去。这次是字符编码,下次可能是字体渲染、设备屏幕、甚至激光投影。我们能做的不是追求一劳永逸,而是让攻击成本高到对方不划算。

下次写代码时,如果你在匹配用户输入,不妨多想一步:你的正则表达式,真的认识所有 "a" 吗?

原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/557.html

(0)
上一篇 2天前
下一篇 2天前

相关推荐