转自:https://www.zhihu.com/collection/205613149
原作者:最后的绅士
你以为最安全的那堵墙,可能已经裸奔了18年
2008年,你在干什么?
那一年,比特币白皮书刚刚发表,iPhone 3G发布,北京奥运会开幕。也是那一年,一个工程师在NGINX源码里提交了一段看似人畜无害的代码。他大概永远不会想到——这段代码会在18年后,把全球近三分之一的网站直接送到攻击者的枪口下。
最近,depthfirst的系统扫了一遍NGINX源码。六小时后,系统吐出了5个内存破坏漏洞。其中4个被NGINX官方确认。最狠的那个,CVSS评分9.2,是一个堆缓冲区溢出,2008年引入,潜伏至今,横跨了整整一代互联网基础设施。
先说结论:只要你用了rewrite和set这两个指令——相信我,用它们的人比你想象的要多得多——你就处于风险之中。
但比起漏洞本身,更值得深挖的是它背后的故事。为什么一个被无数双眼睛审视过的项目,能让一个致命漏洞藏了18年?攻击者又是怎么用URI里的加号,一步步把服务器变成自己的傀儡?
这里面有一套完整的”脆弱性哲学”。
1. 两遍扫描的”搬家悖论”
先说一个生活场景。
你找了家搬家公司。他们的流程非常专业:先派一个人上门估算,记下所有家具的尺寸,算出需要多大一辆车。然后第二天,另一队人按着清单搬。
NGINX的脚本引擎,在骨子里就是这么干的。
当它处理rewrite和set这类指令时,不会傻乎乎地每次分配一点内存拼起来。它做了一个聪明的优化——两遍处理:
- 第一遍(长度计算):先跑一遍,算出最终字符串有多长,一次性分配恰好的内存。
- 第二遍(数据复制):再跑一遍,把真正的数据填进去。
这个设计的初衷是”精打细算”,避免频繁的小块内存分配。听起来完美,对吧?
但问题恰恰出在这个”两遍”上。
如果第一遍和第二遍之间,引擎的某个状态悄悄变了——那第一遍算出来的长度,就不再等于第二遍实际写入的数据量。
这就像量房的人用的是标准卷尺,但搬家具那天工人换了一把”特殊”的尺子——同一件沙发,量出来1米,搬进去变成了3米。车装不下了,只能硬塞,塞到邻居家的车位里。
2. 那个忘记清零的标志位
现在我们把镜头推到源码层面。这个漏洞的核心,在src/http/ngx_http_script.c里。
关键角色是一个叫is_args的标志位。
它的作用很简单:当NGINX在重写URI时遇到一个问号,就设置e->is_args = 1,意思是”嘿,后面跟着的是查询参数,里面的特殊字符需要转义”。
void ngx_http_script_start_args_code(ngx_http_script_engine_t *e)
{
e->is_args = 1;
e->args = e->pos;
e->ip += sizeof(uintptr_t);
}
逻辑上看,没毛病。需要转义的时候,设置标志位,让后续处理知道要转义。
但有一个问题:这个标志位,从来没有人去清零它。
它就像一个开关被按下之后,再也没有弹起来过。
如果只是一条rewrite指令自己跑完就结束,那也没事——反正我设了标志,我用了,流程结束。但现实中的NGINX配置往往是多条指令组合使用。比如:
location ~ ^/api/(.*)$ {
rewrite ^/api/(.*)$ /internal?migrated=true;
set $original_endpoint $1;
}
先rewrite——带问号,触发is_args = 1。然后set——它本身跟问号无关,不应该受这个标志影响。
但标志已经被污染了,而set根本不知道。
现在,我们进入了”两遍扫描”那个设计的致命区。
3. “你是新来的,你当然不知道”
当set指令执行时,它调用了ngx_http_script_complex_value_code函数。这个函数在第一遍(计算长度)时,干了一件看似合理的事:
它创建了一个全新的、干干净净的子引擎。
ngx_memzero(&le, sizeof(ngx_http_script_engine_t)); // 全新,全零
这个子引擎叫le。它是从零开始的——le.is_args = 0,清清白白,没有任何历史包袱。
于是第一遍计算长度时,它走进这个判断:
if ((e->is_args || e->quote)
&& (e->request->quoted_uri || e->request->plus_in_uri))
{
// 需要转义,长度 = 原始长度 + 每个特殊字符多2字节
return cap[n + 1] - cap[n]
+ 2 * ngx_escape_uri(NULL, ...);
} else {
// 不需要转义,长度 = 原始长度
return cap[n + 1] - cap[n];
}
因为le.is_args = 0,它走了else分支。它只计算了原始字符串的长度,完全没有给转义留空间。
第一遍结束。内存分配好了——刚刚好等于原始字符串的长度。
然后第二遍来了。
第二遍是在主引擎上跑的。主引擎的is_args仍然是1——那个标志位到现在也没有人清过。
同一个判断,这次走进了if分支。
它开始转义:每个加号从1字节膨胀到3字节(%2B),每个与号从1字节膨胀到3字节(%26)……
写出的数据远超缓冲区边界。溢出发生了。
4. “你最好祈祷这个worker不会crash”——以及为什么crash反而是好事
到这里,一个有趣的问题浮现了:溢出之后怎么办?
在大多数软件里,堆缓冲区溢出意味着crash,crash意味着进程死掉。你只有一次机会,失败了就没了。
但NGINX不是大多数软件。
NGINX使用的是多进程架构:一个master进程fork出一堆worker。关键点在于——fork出的worker,内存布局和master完全一致。而且这个布局是确定的。
这意味着什么?
如果你的exploit把worker搞崩了,master会淡定地重新fork一个。新worker的内存布局和刚才死掉的那个一模一样。
你可以在同一个靶子上试错无数次,直到命中红心。
是的,你没看错——多进程架构,这个设计初衷是为了稳定性和性能,结果成了攻击者可以反复”读档重来”的帮凶。你crash了?没关系,下一把。内存布局不变,你的上一次尝试不会白白浪费,它告诉了你偏移量差了多少,下一把微调一下就行。
当你无论如何都要面对一个能无限重试的对手时,你最好确保自己的防御是完美的。因为对方只需要成功一次。
5. 加号能干什么?——”你只能写URI安全字符”
溢出是有了,但攻击者手里能用的武器很有限。
溢出的数据来自URI。URI里的字符是经过严格过滤的——只有字母、数字和一小撮特殊符号能通过。你没法在里面塞一个\x00,也没法嵌入任意二进制地址。
攻击者的处境是:你有一把枪,但子弹是橡皮的。
这就是整个exploit中最精彩的部分。
攻击者盯上了NGINX的内存池结构——ngx_pool_t。每个连接、每个请求都从池里分配内存。池结构里有一个字段叫cleanup,它指向一个链表,链表的每个节点里存着一个函数指针(handler)和一个参数(data)。当池被销毁时,NGINX会遍历这个链表,依次调用所有handler。
typedef void (*ngx_pool_cleanup_pt)(void *data);
struct ngx_pool_cleanup_s {
ngx_pool_cleanup_pt handler;
void *data;
ngx_pool_cleanup_t *next;
};
如果攻击者能把这个cleanup指针改掉,让它指向自己精心构造的假链表——那池销毁的那一刻,就是任意代码执行的那一刻。
但路径上横着好几座大山。
要覆盖到cleanup(偏移64字节),你得先把前面的d、max、current、chain、large全部碾过去。这些字段如果被橡皮子弹打烂——也就是被你的URI安全字符填成垃圾值——池分配器在后续任何一次内存操作中都会立刻crash。
你还没走到cleanup,先死在了路上。
6. 堆风水:一场精心编排的时间差手术
怎么绕过?
攻击者祭出了一个堪称优雅的方案。它利用了跨请求的堆布局控制——或者你可以叫它”堆风水”。
思路是这样的:
第一步:开一个连接A,发送不完整的HTTP头部。NGINX会为这个连接分配一个请求池,但因为头部没发完,请求还没正式开始处理。这个池就在堆上稳稳地占着位置,紧邻着攻击者想要覆盖的目标区域。
第二步:开第二个连接B。NGINX为B分配一个新的请求池。由于堆分配的顺序性,B的池刚好紧挨着A的池。
第三步:把A的头部补全,触发rewrite+set的溢出。溢出的数据从A的池冲出来,直接覆盖到B的池头部——精准命中B池的cleanup指针所在的位置。
第四步:立刻关闭连接B。NGINX开始销毁B的池,遍历cleanup链表——而此时的cleanup指针,已经是攻击者通过溢出写进去的值了。
这里最巧妙的是:销毁池的时候,NGINX只遍历cleanup链表,不会去碰那些被橡皮子弹打烂的池元数据字段。 你不需要它们完好,你只需要它们在你到达终点之前不惹事。
这就是”时间差手术”的精髓——在垃圾值被使用之前,让池先被销毁。
7. 橡皮子弹怎么变成真子弹?——POST请求的”暗度陈仓”
只剩最后一个问题了。
攻击者用溢出覆盖了cleanup指针。但他只能写URI安全字符——字母、数字、百分号编码。一个真正的内存地址里必然有不可打印的字节,甚至有\x00。你怎么用有限的字母表写出一个有效的地址?
答案是:你不需要自己写。你让别人帮你写。
攻击者提前发送了大量POST请求。POST请求的body不像URI那样被严格解析——它可以包含任意二进制数据,包括null字节、不可打印字符、完整的内存地址。
他在POST body里精心构造了一个假的ngx_pool_cleanup_s结构:handler指向libc的system函数,data指向一串命令字符串。
这些假结构被喷到堆上,大量喷洒。由于NGINX的堆布局高度确定,攻击者可以精确计算它们落在哪个地址。
然后,他在这些地址中筛选出一个特殊的——这个地址的值,碰巧只用到了URI安全字符范围内的字节。
他不需要”写”一个地址。他只需要”选”一个地址——一个已经在堆上、恰好能用加号和百分号表示的地址。
溢出发生时,他把这个”安全”的地址写进cleanup指针。连接关闭,池销毁,cleanup链表被遍历,handler被执行。system("你注入的命令")。
代码执行了。
8. 脆弱性的底层逻辑:为什么”假设”是万恶之源
回顾整个链条,你会发现一个令人不安的事实:每一步单独看,都是合理的。
- 两遍扫描优化内存分配?合理,高效。
- 用
is_args标志位记录是否在查询参数中?合理,简洁。 - 子引擎从零初始化避免污染?合理,干净。
- 多进程fork保证稳定性?合理,优雅。
- URI严格过滤防止注入?合理,安全。
但所有这些”合理”叠加在一起,变成了一个致命的漏洞。
根源在于一个跨越多层抽象的假设不一致:脚本引擎假设标志位在两次扫描之间不会改变,但rewrite指令的设计假设它设置的标志位会对后续指令产生影响,而set指令又假设它用的子引擎是干净的副本。
三个假设彼此矛盾。而那个2008年的commit,恰好触发了这个矛盾的开关。
这就是为什么最危险的漏洞,往往不是某一个程序员写出了烂代码,而是一群优秀的程序员各自写出了”正确”的代码,然后这些代码在运行时相遇了。
就像三辆遵守交通规则的车,在十字路口同时到达。每辆车都开在自己的绿灯下——但路口的设计者从来没想过,三个方向的绿灯会同时亮。
9. 最后的反思:我们检视代码的方式,可能从根本上就是错的
这篇文章不是来嘲笑NGINX的。恰恰相反——NGINX是过去二十年里最成功、最稳健的基础软件之一。它的代码质量放在整个开源世界里,都堪称标杆。
正因如此,这个漏洞才让人脊背发凉。
如果连NGINX——被那么多企业、那么多安全研究员、那么多好奇的眼睛审视过的代码——都可以让一个漏洞潜伏18年,那我们还有多少基础设施,是在裸奔?
更让人不安的是,这个漏洞不是被人类发现的,而是被一个自动化系统发现的。它不需要睡觉,不需要喝咖啡,不需要在30万行源码中迷失方向。它只是在六小时内,按自己的方式把代码”读”了一遍。
人类review代码的方式,也许从一开始就错了。
我们习惯于盯着diff——”这次改了哪几行?”我们检查新代码的逻辑,测试新功能是否正常。但几乎没有人会问:这个新变量,它对十八年前那段运行的代码意味着什么?
漏洞不在diff里。漏洞在diff和它周围沉默的历史之间。