FlowingDev

Unicode 的隐藏幽灵:那些在你文本里捣乱的秘密字符

了解看不见的 Unicode 字符和“长得像”的字母是如何破坏你的代码、引发安全风险,并制造出那些令人抓狂的微妙 bug 的。

试用工具: Unicode 文本检查器

一句话概括

Unicode 是让全球互联网成为可能的通用文本标准,但它的庞大体系也包含了一些看不见的字符、长相相似的字符和其他怪异的东西,这些都可能把一个看似简单的字符串变成 bug 雷区。

它解决了什么问题

在计算世界的混沌之初,一切都很简单。我们有 ASCII。它给了我们 127 个字符:大写字母、小写字母、数字、标点符号和一些控制码。它简洁、整齐,而且正好能塞进一个字节里。但它也无可救药地以英语为中心。如果你想写 ¿Qué pasa?、你好 或 спасибо,那就没辙了。

这就导致了一个被称为“代码页地狱”的混乱时代。不同地区的计算机使用不同的 8 位字符集来扩展 ASCII。一份用 Windows-1252(西欧语言)写的文档,在用 KOI8-R(俄语)的系统上打开时,就会变成一团乱码(我们亲切地称之为 mojibake)。在国际间共享文本就像在玩有杂音的传话游戏。

然后,Unicode 联盟骑着白马闪亮登场。他们的使命是:为所有现代和历史上的书写系统创建一个单一、统一的字符集。一个标准,统领一切。每一个字符——从 'A' 到 '€' 再到“一坨便便”的 emoji (💩)——都会得到一个独一无二的编号,即“码点”(code point)。

这是一项支撑我们现代世界的丰功伟绩。但这个伟大的统一也创造了它自己的一系列非常极客范儿的新问题。为了适应人类语言的复杂性,Unicode 不得不包含比可见字母更多的东西。它需要:

  • 组合字符(Combining characters): 像重音符号(´)本身就是一个独立的字符,设计用来放在另一个字符(如 e)的上面。
  • 零宽度字符(Zero-width characters): 一些看不见的标记,可以建议在哪里换行(U+200B 零宽空格),或者把 emoji 粘在一起(U+200D 零宽连字)。
  • 歧义字符(Ambiguous characters): 几十种不同类型的空格、破折号和引号。
  • 形近字(Look-alikes / homoglyphs): 拉丁字母 a 和西里尔字母 а 在很多字体里看起来一模一样,但对计算机来说,它们就像 a 和 b 一样截然不同。

突然之间,你所看到的并非你所得到的。一个看起来像 "cat" 的字符串可能包含一个看不见的字符,使其长度变成 4 而不是 3。一个名为 safeString 的变量名可能藏着一个希腊字母 'α' 而不是拉丁字母 'a'。这就是文本检查器要解决的问题:它戴上 X 光眼镜,向你展示字符串真正是由什么组成的原始、未经过滤的真相,揭示出机器中隐藏的幽灵。

底层原理

要剖析一个字符串,我们需要理解它的三个基本层面:抽象的字符、它的字节表示,以及藏在两者之间的那些怪东西。

Code Points: 字符的地址

从核心上讲,Unicode 就是一个巨大的列表。每个字符都被分配了一个唯一的数字,称为码点(code point)。这是该字符在 Unicode 世界中的永久地址。我们用 U+XXXX 的形式来表示它,其中 XXXX 是一个十六进制数。

  • U+0041 是 A (Latin Capital Letter A)
  • U+00E9 是 é (Latin Small Letter E with Acute)
  • U+20AC 是 € (Euro Sign)
  • U+1F4A9 是 💩 (Pile of Poo)

一个码点是个抽象概念。它不是一个字节,也不是一种字体。它只是一个映射到某个字符的数字。我们如何存储这个数字则是另一回事了。

Encodings: 把码点存成字节

你无法把一个“码点”直接存到文件里。你必须存字节。**编码(encoding)**就是一套将码点序列转换为字节序列的规则。

当今编码界的王者是 UTF-8。它的天才之处在于其可变长度的设计。

  • 对于任何同时存在于原始 ASCII 集中的字符(比如 A, U+0041),UTF-8 只用一个字节——和 ASCII 用的那个字节完全一样。这使得它向后兼容,易于采用。
  • 对于其他字符,它会使用一个 2、3 或 4 字节的序列。每个字节的头几个 bit 会作为信号,告诉计算机当前这个字符由多少个字节组成。

让我们来看看 ¡Hola!:

字符 码点 UTF-8 字节 (十六进制)
¡ U+00A1 C2 A1
H U+0048 48
o U+006F 6F
l U+006C 6C
a U+0061 61
! U+0021 21

文本检查器执行的是这个逆向过程。它读取你字符串的原始字节,根据一种编码(通常是 UTF-8)来解释它们,然后向你展示构成这个字符串的码点序列。

看不见的捣蛋鬼

这才是好玩的地方。文本检查器的主要工作就是把那些看起来什么都不是的字符揪出来,让它们无所遁形。

类别 示例字符 & 码点 狡猾的用途
零宽空格 U+200B 看起来什么都没有。这是一个隐形字符,用于在一个长单词或 URL 中建议一个合适的换行位置。
零宽连字 U+200D emoji 的神奇胶水。👨 + ZWJ + 👩 + ZWJ + 👧 = 👨‍👩‍👧。它把通常不会连接在一起的字符连接起来。
不换行空格 U+00A0 看起来像个普通空格,但它禁止在此处换行。对 100 km 或 Dr. Strange 这样的东西很有用。
组合标记 U+0301 (Combining Acute Accent) 一个重音符号(´),它本身就是一个字符。它被绘制在前一个字符的上方。
形近字 (Homoglyph) U+0430 (Cyrillic Small Letter A) 在大多数字体中,它看起来和拉丁字母 a (U+0061) 完全一样,但它是一个完全不同的码点。

这就引出了**规范化(normalization)**的概念。字符 é 可以用两种方式表示:

  1. 组合形式 (NFC): 一个单独的码点,U+00E9。
  2. 分解形式 (NFD): 两个码点,e (U+0065) 后面跟着组合重音符 ´ (U+0301)。

在视觉上,它们是相同的。但对于一个进行简单逐字节比较的计算机来说,"\u00E9" 不等于 "e\u0301"。文本检查器可以揭示你拥有的是哪种形式,并帮助你在它们之间进行转换。

真实世界的案例

复制粘贴引发的灾难

一个初级开发正在加班改 bug。他在一个博客上找到了解决方案,一行 JavaScript 代码:const timeout = 100;。他复制了这行代码,粘贴到他的代码编辑器里,然后保存。整个应用程序的构建都失败了,报了一个神秘的 SyntaxError: Invalid or unexpected token 错误。

他盯着那行代码看。它完美无瑕。他手动重新敲了一遍。成功了。他又把复制的那行粘贴进去。又崩了。他是不是要疯了?在他抓狂了一个小时后,一个资深开发眯着眼看了看那行代码,说:“把这个粘贴到文本检查器里看看。”

结果是:const[U+00A0]timeout[U+00A0]=[U+00A0]100;。那个博客的 CSS 把代码美化了,把标准的空格(U+0020)替换成了不换行空格(U+00A0)。它们看起来一模一样,但 JavaScript 引擎根本不知道在那个上下文里“不换行空格”是什么鬼。

教训: 从网上(或 PDF、Word 文档)复制来的文本,在被证明无辜之前,都是有罪的。它常常被“智能”引号、非标准空格和其他看不见的小魔怪所污染。

无法登录的幽灵用户

一个新用户注册了一个服务,名字叫 François。系统很高兴地创建了账户。第二天,François 试图登录。他输入名字,敲下回车……“用户名或密码无效。” 他小心翼翼地又试了一次。同样的结果。他被锁在外面了。

在数据库里,他的名字是用分解形式的字符存储的:F、r、a、n、c、o、i、s 和一个 U+0327 (Combining Cedilla)。然而,登录表单发送的却是预组合好的字符 ç (U+00E7)。视觉上,c + ¸ 和 ç 是一样的。但是服务器在做简单的字符串比较:François (分解形式) 不等于 François (组合形式)。WHERE username = '...' 这条查询失败了。

教训: 在将用户输入存入数据库或进行比较之前,务必将其规范化为一个统一的形式(NFC 是最常见的选择)。

骗人的域名

一位员工收到一封看似来自公司 IT 部门的邮件。“安全更新要求:请登录 microsоft.com/update 以保护您的账户。” 这个链接看起来很正规。域名就在那里。他们点击了链接,在一个看起来和真网站一模一样的页面上输入了他们的凭据,然后继续他们一天的工作。

他们刚刚被钓鱼了。那个域名不是 microsoft.com。它是 microsоft.com。第二个 'o' 不是拉丁字母 'o' (U+006F),而是西里尔字母 'о' (U+043E)。这就是一种 IDN Homograph Attack(国际化域名同形异义词攻击)。对人眼来说,这是一个完美的伪造。但对 DNS 系统来说,这是一个完全不同的地址,指向骗子的服务器。

教训: 对那些混合了不同字符集的标识符要抱有高度怀疑。虽然现代浏览器有一些保护措施,但同形异义词攻击的原理在用户名、验证规则以及任何使用字符串进行安全验证的地方都是一个持续的威胁。

常见错误和陷阱

  • 以为 string.length 是计算字符数。 在很多语言里(比如 JavaScript),它计算的是代码单元(code unit),而不是我们肉眼看到的字符数。例如,"👍🏽".length 在 JS 里是 4,因为它是由“翘大拇指” emoji(👍,2 个单元)和“中等肤色修饰符”(🏽,2 个单元)组成的。
  • 把所有空白字符都一视同仁。 对一个字符串运行 trim() 并不会移除藏在中间的 U+200B 零宽空格。一个匹配 \s+ 的 regex 可能捕获不到 U+00A0 不换行空格。你必须知道你到底在寻找什么。
  • 忽略规范化。 正如 François 的例子所见,比较那些看起来相同但底层字节表示不同的字符串是一个典型且令人沮丧的 bug。string1.normalize() === string2.normalize() 是你的好朋友。
  • 相信你的眼睛。 你无法通过观察渲染出来的文本来调试这些问题。一个能显示单个码点及其名称的文本检查器是唯一能确定真相的方法。
  • 自己动手写“坏字符”清理器。 尝试写一个 regex 来移除所有“奇怪”的字符是吃力不讨好的傻事。你要么会漏掉一些,要么更糟,会剥离掉其他语言所需的合法字符,从而破坏用户的名字和文本。

为什么你应该关注它

每当文本的行为出现异常时,你就应该求助于 Unicode 文本检查器。它是一个不可或缺的调试工具。在以下情况请想起它:

  • 一个字符串比较在“显而易见”应该成功的时候失败了。
  • 一行看起来完全合法的代码报了语法错误。
  • 你在验证用户提供的输入,如用户名、邮箱或 URL。
  • 你在处理来自多个系统的数据,特别是当它们涉及不同语言时。
  • 你需要理解为什么 string.length 给你的数字是“错的”。
  • 你在构建任何需要健壮、安全并为全球用户服务的系统。

简而言之,任何时候当计算机和人类对一段文本的含义产生分歧时,那计算机对字节的理解可能是对的,而文本检查器就是你的翻译官。

深入了解

理论搞定,动手试试吧——100% 在你的浏览器中运行。

试用工具: Unicode 文本检查器