不可见字符的世界 - 零宽字符与不可见字符引发的问题
你输入的字符串应该是 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 | 编码识别 | 通常不计数 | 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:启用"显示控制字符" |
| 命令行 | 文件中的不可见字符 | cat -v filename 或 xxd filename |
| PHP | 主要零宽字符 | preg_replace('/[\x{200B}-\x{200F}\x{FEFF}]/u', '', $str) |
JavaScript 正则表达式 /[\u200B-\u200F\u2028-\u202F\uFEFF]/g 可以一次性去除最常见的零宽字符和方向控制字符。在将表单输入值发送到服务器之前应用此过滤器,可以防止不可见字符导致的字数统计不一致。
但是,无条件去除所有不可见字符是危险的。ZWJ 是表情符号合成所必需的,去除它会导致表情符号分解。ZWNJ 对波斯语和印地语的正确显示不可或缺。不可见字符的去除必须在理解用途和上下文的基础上谨慎进行。
各编程语言对不可见字符的处理
不同编程语言对源代码中不可见字符的处理方式不同。一些语言会忽略它们,另一些则将其检测为错误。
| 语言 | 源代码中的 ZWSP | 字符串字面量中的 ZWSP | 检测工具 |
|---|---|---|---|
| JavaScript | 可能不会导致语法错误 | 作为字符串的一部分保留 | ESLint 的 no-irregular-whitespace |
| Python | SyntaxError | 作为字符串的一部分保留 | pylint, flake8 |
| Java | 编译错误 | 作为字符串的一部分保留 | Checkstyle |
| Go | 编译错误 | 作为字符串的一部分保留 | go vet |
| Rust | 编译错误 (带警告) | 作为字符串的一部分保留 | clippy |
| C/C++ | 取决于编译器 | 作为字符串的一部分保留 | clang-tidy |
JavaScript 需要特别注意。ZWSP (U+200B) 在 JavaScript 规范中不被视为"空白字符",因此可能被解释为变量名的一部分。也就是说,var hello 和 var he\u200Bllo 被视为不同的变量。看起来是相同的"hello",却是不同的变量。这种情况导致 bug 的案例确实有过报告。
在考虑变量名和函数名长度指南时,也应该注意不可见字符混入的风险。由于在代码审查中无法通过目视检测,因此通过 linter 和编辑器设置建立自动检测机制非常重要。
实际发生的问题案例
以下是一些由不可见字符引起的实际问题。
- GitHub 代码审查:ZWSP 混入了 Pull Request 的代码,导致测试通过但生产环境中字符串比较失败。在 diff 中无法检测到,最终通过二进制编辑器才发现
- 电商网站搜索:商品名称中包含零宽字符,导致用户按商品名搜索时无法匹配,影响了销售额
- 数据库重复检查:看起来相同的邮箱地址被判定为"无重复",为同一用户创建了多个账户。原因是邮箱地址中的 ZWSP
- PDF 复制粘贴:从 PDF 文件复制文本时大量混入软连字符 (U+00AD),导致表单输入验证判定字数超出
字数统计工具与不可见字符
字数统计工具如何处理不可见字符因工具而异。一些工具在统计时忽略不可见字符,另一些则原样计数。如果不了解 Unicode 基础知识,就无法找出工具间字数不一致的原因。
如果想准确统计文本字数,建议先检查是否存在不可见字符,必要时去除后再统计。仅仅知道"不可见字符"的存在,就能预防许多与字数相关的问题。
不可见字符与安全 - 看不见的威胁
不可见字符也可能成为安全威胁。2021 年发表的"Trojan Source"攻击利用方向控制字符 (U+202A, U+202B, U+202C, U+202D, U+202E, U+2066, U+2067, U+2068, U+2069) 使源代码的外观与实际执行逻辑产生偏差。
| 攻击方法 | 使用的不可见字符 | 影响 | 对策 |
|---|---|---|---|
| Trojan Source | 方向控制字符 (U+202A-U+2069) | 代码审查中无法发现的恶意逻辑 | 启用编译器警告 |
| 同形字攻击 | 外观相同的不同字符 (U+0430 vs U+0061) | 钓鱼 URL 伪装 | 确认 Punycode 显示 |
| ZWSP 注入 | U+200B | 绕过输入验证 | 服务器端去除不可见字符 |
| BOM 注入 | U+FEFF | 文件解析器故障 | 自动 BOM 去除处理 |
以下是 Trojan Source 攻击的具体示例。下面的代码在人眼看来是"仅在访问被允许时执行处理",但由于嵌入了方向控制字符,实际上访问检查被禁用了。
这种攻击特别危险,因为它使代码审查这一人眼验证过程失效。对策包括启用编译器和 linter 中关于方向控制字符使用的警告设置,以及在 CI/CD 流水线中加入不可见字符检测步骤。
正如Git 提交消息写法一文中提到的,利用 linter 对代码质量管理不可或缺。检测不可见字符也是 linter 的重要职责之一。
Unicode 和文本处理的技术书籍在 Amazon 上也能找到。