最后更新:
不可见字符的世界 - 零宽字符与不可见字符引发的问题
你输入的字符串应该是 10 个字符,但系统坚持说是 12 个。无论怎么仔细看,都看不到多余的字符。罪魁祸首是"零宽字符" - 在屏幕上完全不显示,但作为数据确实存在的不可见字符。本文介绍 Unicode 中定义的不可见字符的种类和用途、对字数统计的影响,以及它们混入之后会出现哪些典型的故障模式和应对方法。
不可见字符一览 - 看不见却存在的字符们
Unicode 定义了多个不在屏幕上显示 (或宽度为零) 的字符。这些不是"bug",而是出于文本处理的正当需要而存在的。
| 字符名称 | 码位 | 用途 | 字数统计 | 显示宽度 |
|---|---|---|---|---|
| 零宽空格 (ZWSP) | U+200B | 指定可换行位置 | 计为 1 个字符 | 0 |
| 零宽连接符 (ZWJ) | U+200D | 连接字符 (表情符号合成) | 计为 1 个字符 | 0 |
| 零宽非连接符 (ZWNJ) | U+200C | 防止字符连接 | 计为 1 个字符 | 0 |
| 从左到右标记 (LRM) | U+200E | 文本方向控制 | 计为 1 个字符 | 0 |
| 从右到左标记 (RLM) | U+200F | 文本方向控制 | 计为 1 个字符 | 0 |
| 字节顺序标记 (BOM) | U+FEFF | 编码识别 | 计为 1 个字符 (在读取时将其去除的运行环境中会消失) | 0 |
| 软连字符 (SHY) | U+00AD | 指定连字位置 | 计为 1 个字符 | 通常为 0 (仅在换行时显示) |
| 词连接符 (WJ) | U+2060 | 指定禁止换行位置 | 计为 1 个字符 | 0 |
这些字符在文本处理中都有正当的作用。问题在于,当它们无意中混入文本时,会在不可见的情况下扰乱字数统计。
零宽空格 (U+200B) - 最棘手的不可见字符
零宽空格 (ZWSP) 是在文本中嵌入"此处可以换行"信息的字符。它用于泰语和高棉语等不在单词间使用空格的语言,使浏览器能在适当位置换行。
然而,ZWSP 在从网页复制粘贴文本时容易混入,导致以下问题:
- 表单输入被判定为"超出字数限制" (视觉上看起来在限制内)
- 密码复制粘贴失败 (ZWSP 混入导致变成不同的字符串)
- 搜索不匹配 (看起来相同的字符串在搜索中无法命中)
- CSV 文件数据无法正确解析
- 混入程序源代码导致编译错误
密码混入尤其严重。当从网站复制密码时 ZWSP 混入,就会出现密码看起来正确却无法登录的情况。在考虑密码长度与安全性时,不可见字符的存在不容忽视。
零宽连接符 (U+200D) - 合成表情符号的魔法字符
零宽连接符 (ZWJ) 在不可见字符中扮演着最积极的角色。如表情符号字数统计中详细介绍的,ZWJ 将多个表情符号组合成新的表情符号。
| 显示的表情符号 | 组成要素 | 码位数 | 字数统计 (JavaScript) |
|---|---|---|---|
| 👨👩👧👦 (家庭) | 👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦 | 7 | 11 (含代理对) |
| 👩💻 (女性技术人员) | 👩 + ZWJ + 💻 | 3 | 5 |
| 🏳️🌈 (彩虹旗) | 🏳️ + ZWJ + 🌈 | 4 | 6 |
| 👨🍳 (男性厨师) | 👨 + ZWJ + 🍳 | 3 | 5 |
家庭表情符号 👨👩👧👦 看起来是一个表情符号,但内部由 4 个表情符号和 3 个 ZWJ 组成。JavaScript 的 .length 属性返回 11。在有字数限制的社交媒体上,这样一个表情符号可能消耗大量字数。
方向控制字符 - 从右到左书写语言的机制
阿拉伯语和希伯来语是从右到左 (RTL) 书写的语言。在这些语言与英语 (从左到右,LTR) 混合的文本中,需要控制文本方向的不可见字符。
U+200E (从左到右标记) 和 U+200F (从右到左标记) 是用于明确指定文本方向的字符。当这些字符无意中混入时,可能导致文本显示顺序混乱或字数统计出错。
2021 年,利用方向控制字符的安全漏洞"Trojan Source"被报告。通过在源代码中嵌入方向控制字符,使人眼看起来正常的代码被编译器解释为不同的逻辑。这一漏洞表明不可见字符也可能构成安全风险。
BOM (U+FEFF) - 潜伏在文件开头的不可见字符
字节顺序标记 (BOM) 是添加在文本文件开头用于识别编码的字符。UTF-8 的 BOM 为 3 字节 (EF BB BF),Windows 记事本保存文件时有时会添加。
BOM 被许多程序忽略,但在以下情况会引发问题:
- PHP 文件开头有 BOM 时,
header()函数无法工作 (被判定为输出已经开始) - CSV 文件开头有 BOM 时,第一个列名无法正确识别
- JSON 文件中有 BOM 时,解析器可能返回错误
- Shell 脚本开头有 BOM 时,shebang (
#!/bin/bash) 无法被识别
利用零宽字符的隐写术 (水印技术)
隐写术 (数字水印) 是一种反向利用不可见字符"看不见"特性的技术。通过在文本中嵌入零宽字符的模式,可以在不改变外观的情况下植入隐藏信息。
手法本身只有一种,使用的字符也仅限于 U+200B, U+200C, U+200D, U+FEFF 这类零宽字符。区别只在于"把嵌入的比特串当作什么信息来读取"。
| 用途 | 嵌入的内容 | 读取时所需的操作 |
|---|---|---|
| 传递隐藏消息 | 任意的比特串 | 接收方用同一张对照表解码 |
| 确定泄露源 (为每个接收者添加的水印) | 用于识别接收者的比特串 | 与分发时记录的模式进行比对 |
| 检测未经授权的复制 | 表明出处的比特串 | 逐个码位检查复制后的文本 |
例如,将 4 种零宽字符作为 2 位信息处理 (U+200B = 00, U+200C = 01, U+200D = 10, U+FEFF = 11),在文本的每个单词之间插入零宽字符,就可以在其中隐藏二进制数据。
这项技术有时被企业用于确定机密文件的泄露源。为每个接收者嵌入不同的零宽字符模式,当文件泄露到外部时,就可以确定是从哪个接收者处泄露的。
不可见字符的检测与去除
要正确处理被不可见字符混入的文本,需要了解检测和去除方法。
| 方法 | 对象 | 代码示例 |
|---|---|---|
| JavaScript 正则表达式 | 主要零宽字符 | str.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF]/g, '') |
| Python 正则表达式 | 同上 | re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff]', '', text) |
| 文本编辑器 | 不可见字符和容易混淆的字符 | VS Code:editor.unicodeHighlight.invisibleCharacters |
| 命令行 | 文件中的不可见字符 | cat -v filename 或 xxd filename |
| PHP | 主要零宽字符 | preg_replace('/[\x{200B}-\x{200F}\x{FEFF}]/u', '', $str) |
JavaScript 正则表达式 /[\u200B-\u200F\u2028-\u202F\uFEFF]/g 覆盖的范围仅限于"主要的零宽字符"。实际跑一遍就会发现,ZWSP (U+200B) 和 BOM (U+FEFF) 确实被去掉了,但本文开头表格中列出的词连接符 (U+2060) 和软连字符 (U+00AD),以及后面提到的分离型方向控制字符 (U+2066-U+2069) 都在范围之外,会原样留下。在服务器端清洗表单的输入值是有效的对策,但想去除哪些字符需要自己逐个列举出来,写进字符类里。
但是,无条件去除所有不可见字符是危险的。ZWJ 是表情符号合成所必需的,去除它会导致表情符号分解。ZWNJ 对波斯语和印地语的正确显示不可或缺。不可见字符的去除必须在理解用途和上下文的基础上谨慎进行。
各编程语言对不可见字符的处理
ZWSP 混入源代码后的行为,因语言而异,也因具体实现的版本而异。有些语言会报错停下来,有些语言则把它当作标识符的一部分默默接受,后者要麻烦得多。下表是截至 2026 年 8 月的代表性行为,同一种语言也可能因编译器或版本而变化,所以最终还是在自己的工具链上实际试一遍最为可靠。
| 语言 | 混入标识符的 ZWSP | 字符串字面量中的 ZWSP | 检测的线索 |
|---|---|---|---|
| JavaScript (Node.js) | 语法错误 (不是标识符可以使用的字符) | 作为字符串的一部分保留 | ESLint 的 no-irregular-whitespace |
| Python | SyntaxError (作为无法打印的字符被拒绝) | 作为字符串的一部分保留 | 解释器本身就会停下来 |
| Rust | 编译错误 (无法解析为词法单元) | 作为字符串的一部分保留 | 如果是 ZWNJ 或 ZWJ 则有 uncommon_codepoints 警告 |
| C / C++ (clang) | 可以作为标识符通过,仅有警告 | 作为字符串的一部分保留 | clang 的 -Wunicode-zero-width |
这里最容易搞反的,是 ZWSP 与 ZWNJ、ZWJ 之间的区别。JavaScript 的标识符中可以使用的字符包含 ZWNJ (U+200C) 和 ZWJ (U+200D),而不包含 ZWSP (U+200B)。也就是说,var he\u200Bllo 会以语法错误停下来,而 var he\u200Cllo 则原样通过,声明出一个与看起来完全相同的 hello 不同的变量。
这个差别在实务上的含义正好相反。ZWSP 在编辑器里看不见,但一运行就会立刻报错停下,所以只要混进来就一定会被发现。麻烦的是能够通过的 ZWNJ、ZWJ 一侧:声明和引用都能成功,而后来用手重新敲一遍的 hello 却变成了"未定义的变量"。字符始终不可见,留下来的只有变量名不一致这一个现象。
在考虑变量名和函数名长度指南时,也应该注意不可见字符混入的风险。由于在代码审查中无法通过目视检测,因此通过 linter 和编辑器设置建立自动检测机制非常重要。
不可见字符引发的典型故障模式
混入的途径和损坏的方式都有固定的类型。它们的共同点是原因不可见,所以定位起来很花时间。
- 字符串比较静静地失败:看起来相同的两个字符串却不相等。通常的表现形式是,手动输入的测试数据可以通过,只有复制得来的真实数据无法匹配
- 搜索命中不了:商品名称或文章标题中混入零宽字符后,用户即使照着屏幕上看到的样子输入也无法匹配
- 绕过唯一约束和重复检查:邮箱地址或 ID 中间夹着 ZWSP 时,系统会当作不同的值来处理,于是看起来相同的数据被并排登记进去
- 从 PDF 和网页复制粘贴时被一起带进来:为排版而嵌入的软连字符 (U+00AD) 和零宽空格被原样粘贴进来,导致表单输入验证判定字数超出
- 差分显示里找不到:审查界面和 diff 都不会绘制宽度为零的字符,所以无论是代码审查还是稿件校对都会被它溜过去
字数统计工具与不可见字符
字数统计工具如何处理不可见字符因工具而异。一些工具在统计时忽略不可见字符,另一些则原样计数。如果不了解 Unicode 基础知识,就无法找出工具间字数不一致的原因。
如果想准确统计文本字数,建议先检查是否存在不可见字符,必要时去除后再统计。仅仅知道"不可见字符"的存在,就能预防许多与字数相关的问题。
不可见字符与安全 - 看不见的威胁
前面提到的 Trojan Source,利用的是双向文本控制所使用的 9 种不可见字符 (U+202A-U+202E 的嵌入、覆盖类,以及 U+2066-U+2069 的隔离类),通过它们使源代码的外观与实际执行逻辑产生偏差。不可见字符构成安全威胁的类型,除此之外还有几种已经为人所知。
| 攻击方法 | 使用的不可见字符 | 影响 | 对策 |
|---|---|---|---|
| Trojan Source | 方向控制字符 (U+202A-U+202E, U+2066-U+2069) | 代码审查中无法发现的恶意逻辑 | 启用编译器警告 |
| 同形字攻击 | 外观相同的不同字符 (U+0430 vs U+0061) | 钓鱼 URL 伪装 | 确认 Punycode 显示 |
| ZWSP 注入 | U+200B | 绕过输入验证 | 服务器端去除不可见字符 |
| BOM 注入 | U+FEFF | 文件解析器故障 | 自动 BOM 去除处理 |
这种攻击之所以成立,机制其实很简单。只要在注释或字符串字面量中埋入方向控制字符,编辑器和网页上的差分显示就会按照指示重新排列字符来绘制,而编译器解释的是重排之前的字节序列。这样一来就能制造出这样的落差:屏幕上读起来是"先确认权限再执行处理"的代码,实际上却不经确认就把处理执行了。被滥用的并不是非法的字符,而是为了正确显示双向文本而存在的正当机制本身。
这种攻击特别危险,因为它使代码审查这一人眼验证过程失效。对策包括启用编译器和 linter 中关于方向控制字符使用的警告设置,以及在 CI/CD 流水线中加入不可见字符检测步骤。
正如Git 提交消息写法一文中提到的,利用 linter 对代码质量管理不可或缺。检测不可见字符也是 linter 的重要职责之一。