不可见字符的世界 - 零宽字符与不可见字符引发的问题

约 9 分钟阅读

你输入的字符串应该是 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 混入,就会出现密码看起来正确却无法登录的情况。在考虑密码长度与安全性时,不可见字符的存在不容忽视。

零宽连接符 (U+200D) - 合成表情符号的魔法字符

零宽连接符 (ZWJ) 在不可见字符中扮演着最积极的角色。如表情符号字数统计中详细介绍的,ZWJ 将多个表情符号组合成新的表情符号。

显示的表情符号组成要素码位数字数统计 (JavaScript)
👨‍👩‍👧‍👦 (家庭)👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦711 (含代理对)
👩‍💻 (女性技术人员)👩 + ZWJ + 💻35
🏳️‍🌈 (彩虹旗)🏳️ + ZWJ + 🌈46
👨‍🍳 (男性厨师)👨 + ZWJ + 🍳35

家庭表情符号 👨‍👩‍👧‍👦 看起来是一个表情符号,但内部由 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 被许多程序忽略,但在以下情况会引发问题:

利用零宽字符的隐写术 (水印技术)

隐写术 (数字水印) 是一种反向利用不可见字符"看不见"特性的技术。通过在文本中嵌入零宽字符的模式,可以在不改变外观的情况下植入隐藏信息。

方法使用的字符用途检测难度
零宽字符编码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 filenamexxd 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
PythonSyntaxError作为字符串的一部分保留pylint, flake8
Java编译错误作为字符串的一部分保留Checkstyle
Go编译错误作为字符串的一部分保留go vet
Rust编译错误 (带警告)作为字符串的一部分保留clippy
C/C++取决于编译器作为字符串的一部分保留clang-tidy

JavaScript 需要特别注意。ZWSP (U+200B) 在 JavaScript 规范中不被视为"空白字符",因此可能被解释为变量名的一部分。也就是说,var hellovar he\u200Bllo 被视为不同的变量。看起来是相同的"hello",却是不同的变量。这种情况导致 bug 的案例确实有过报告。

在考虑变量名和函数名长度指南时,也应该注意不可见字符混入的风险。由于在代码审查中无法通过目视检测,因此通过 linter 和编辑器设置建立自动检测机制非常重要。

实际发生的问题案例

以下是一些由不可见字符引起的实际问题。

字数统计工具与不可见字符

字数统计工具如何处理不可见字符因工具而异。一些工具在统计时忽略不可见字符,另一些则原样计数。如果不了解 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 上也能找到

分享这篇文章