curl 0day 启示录 |连接破碎的旧世界与安全的新纪元
“安全的内存程序都是相同的;每个 C 程序却各有各的不安全之处。
引子
距离上一次关注 curl 还是 2020 年的 10月。
当时 ISRG (互联网非盈利安全组织)[1] 发文宣称,他们推出了内存安全版本的 curl。文章中声称,curl 软件作者 Daniel Stenberg 和 WolfSSL 合作,使 curl 代码库的关键部分具备内存安全性。
具体来说,这个安全关键的部分就是 curl 的 http 后端(可选)。在 ISRG 的资助下,curl 的安全后端 hyper (Rust 实现的 http 库)于 同年 12 月被合并到 curl 主分支[2]。并且在这之后的两年内,不定期进行了很多测试,最后一次测试是在 2022 年 12 月。
curl 也算是我日常的使用工具,但是我第一次通过这件事知道 curl 的用户数达到数十亿之多(全球范围内安装量大约超过 200亿次)。这件事,也标志着 curl 从破碎的旧世界迈向了安全的新纪元。
本以为 curl 这次内存安全了。没想到就在今天,竟然看到 curl 曝出了 0Day 漏洞( CVE-2023-38545[3])。libcurl 几乎已经成为在进行互联网传输时的事实标准,这个 0Day 漏洞基本上会让黑客梦里笑醒。
“漏洞简单来说:在使用 curl socket5 代理时,输入的域名太长,就会导致内存溢出。
这个问题已经被Jay Satiro报告、分析和修复。并且因此拿到了迄今为止最高金额的 curl 漏洞赏金:4,660美元(根据IBB政策[4],另外支付1,165美元给 curl 项目)。
curl 0day 漏洞解析
以下是对 curl 作者博客[5]文章的摘录,他为了让大家对这个 0day 漏洞有深入的理解,特别写了这篇博客。
一些背景
curl 开始支持SOCKS5 是 2002年8月开始的,距今有 21 年。
SOCKS5 是一种代理协议。它是一种通过专用的“中间人”来建立网络通信的相对简单的协议。该协议通常用于通过 Tor 进行通信,也用于在组织和公司内部访问互联网。
SOCKS5 有两种不同的主机名解析模式。客户端可以在本地解析主机名并将目标地址作为已解析的地址传递,或者客户端将整个主机名传递给代理服务器,让代理服务器自己远程解析主机名。
client (local hostname resolution) -> socket5 proxy -> remote web
client (send hostname)-> socket5 proxy ( name resolution )-> remote web
2020 年时,作者为了提升 curl 处理大量数据并行传输性能时,修改了代码:将连接到SOCKS5 代理的函数从阻塞调用转换为非阻塞状态机。2020年2月14日,他在主分支上提交了这个更改。它作为首个具备此增强功能的版本发布(7.69.0)。同时,也成为了首个受CVE-2023-38545 漏洞影响的版本。
“难道真应了那句话:能持续工作的陈年代码能不动就不要动。
代码赏析:如何编写 0day 漏洞
当有更多的网络数据需要处理时,状态机会被重复调用,直到完成为止。当连接建立时:
/*
https://github.com/curl/curl/blob/d1b0317f9b3e4535fd9006b1faab41cbfa912753/lib/socks.c#L573
*/
bool socks5_resolve_local =
(proxytype == CURLPROXY_SOCKS5) ? TRUE : FALSE;
这个布尔变量保存了关于 curl 是否应该解析主机还是只是将名称传递给代理的信息。这个赋值是在函数顶部完成的,因此在状态机运行时每次调用都会执行。
状态机从INIT状态开始。这个缺陷是从之前的函数继承而来,当时它还没有转变成状态机。
if(!socks5_resolve_local && hostname_len > 255) {
socks5_resolve_local = TRUE;
}
SOCKS5 允许主机名字段的长度最长为255个字节,这意味着 SOCKS5 代理无法解析更长的主机名。当发现主机名过长时,curl代码会做出错误的决定,转而切换到本地解析模式。它将用于此目的的本地变量设置为 TRUE
。(这个条件是很久以前添加的代码的剩余部分)。状态机然后切换状态并继续。
如果状态机无法继续运行,因为它没有更多的数据可供处理,比如 SOCKS5 服务器性能不够快,它就会返回。当有可用的数据继续处理时,它会再次被调用。
状态机的每一次调用都是一个独立的事件,它不会保留上一次调用的状态或变量值。socks5_resolve_local
这个布尔变量被定义在函数的顶部,并且每次状态机运行时都会重新赋值。这就意味着,即使在一次状态机调用中由于某种条件(如主机名过长)而改变了这个变量的值,下一次状态机调用时这个改变也会被“重置”。
当主机名长度超过 SOCKS5 协议规定的 255 字节时,代码会将 socks5_resolve_local
设置为 TRUE
,从而切换到本地解析模式。这本身就是一个逻辑错误,因为如果用户明确要求进行远程解析,curl 应该坚持这一点或者失败,而不是随意切换到本地解析。
当状态机在等待更多数据以继续工作时返回,然后再次被调用时,socks5_resolve_local
的值会被重置,忽略了由于主机名过长而进行的更改。这最终导致了内存溢出问题。
curl 在内存缓冲区中构建协议帧,并将目标复制到该缓冲区中。由于代码错误地认为应该传递主机名,即使主机名过长无法容纳,内存复制也可能溢出分配的目标缓冲区。当然,这取决于主机名的长度和目标缓冲区的大小。一旦内存溢出发生,它就有可能覆盖相邻的堆内存,这通常会导致未定义的行为,甚至可能被恶意利用来执行任意代码。
curl 工具将缓冲区大小设置为100kB。最小可接受的大小为1024字节。如果缓冲区大小设置小于65541字节,就有可能发生溢出。大小越小,溢出可能性越大。
漏洞利用难度
URL中的主机名没有真正的大小限制,但是libcurl的URL解析器拒绝接受超过65535字节的名称。DNS只接受最长253字节的主机名。因此,超过253字节的合法名称是不寻常的。超过1024字节的真实名称几乎是闻所未闻的。
因此,基本上需要一个恶意的行为者将一个超长的主机名输入到这个方程中,以触发这个漏洞。为了在攻击中使用它,这个名称需要比目标缓冲区更长,以使内存复制覆盖堆内存。
URL的主机名字段只能包含一组八位字节的子集。一系列字节值是无效的,会导致URL解析器拒绝它。如果libcurl构建时使用了一个IDN库,那个库也可能会拒绝无效的主机名。因此,只有在主机名中使用了正确的字节集时,才会触发此错误。
漏洞利用构思
一个攻击者控制一个HTTPS服务器,一个使用 libcurl 的客户端通过 SOCKS5 代理访问该服务器(使用代理解析模式),可以通过HTTP 30x 响应使其返回一个精心构造的重定向到应用程序。
这样的30x重定向将包含一个类似于Location:头部的样式:
Location: https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/
主机名长度超过16kB但不超过64kB。如果使用 libcurl 的客户端启用了自动重定向跟随功能,并且SOCKS5代理足够“慢”以触发本地变量错误,它将把精心构造的主机名复制到分配过小的缓冲区和相邻的堆内存中。然后发生了堆缓冲区溢出。
安全启示录
为什么没有提前发现该漏洞?
作者有足够的时间(大约 1315 天)来发现该漏洞,但为什么没有发现呢?原因很简单,开发人员基本只有他一人。他多次对代码运行了几个静态代码分析器,但是它们都没有发现这个函数中的任何问题。
用 Rust 重写?
网上有很多让 curl 用内存安全语言重写的声音,比如用 Rust 语言。
确实,如果 curl 是用 Rust 实现而不是C语言编写的,这个错误将是不可能发生。甚至迄今为止 curl 中发现的安全漏洞中有 41% 可能不会发生。
但很可惜,完全用 Rust 语言或其他语言重写 curl 几乎不可能。因为将一个古老、成熟且广泛使用的代码库(如libcurl)移植到另一种语言是一项庞大而艰巨的任务。
“curl 作者说,如果你对 curl 中的 C 代码不满意,你想用 Rust 重写,那么请你卷起袖子亲自参与这项工作。
迄今为止,这样的尝试还没有被认真考虑过,即使是一些巨头公司也在考虑后撤销了这样的想法。由于 libcurl 具有稳定的API、不破坏ABI和不改变现有功能的行为,它才能获得如此广泛的流行和应用。
“因此有个梗:Rust 号称是非常擅于重写的语言。
那么,还有什么其他办法吗?有,就是旧瓶装新酒的把戏。
libcurl API <=> glue code in C <=> backend library
比如前面所说的,将 curl 的 http 后端用 Rust 实现的 hyper 来进行替换。对用户来说,libcurl API 会保持不变。用户可以在不改变任何代码的情况下,通过重新构建来使用其他后端组合来改进你的curl和libcurl二进制文件。
Hyper[6] 是一个用Rust编写的HTTP库。它旨在快速、准确和安全,并支持HTTP/1和HTTP/2。hyper 为了成为 curl 的后端,也做了不少努力,比如支持 C API。
“说明:用 Rust 提供核心功能,然后暴露 C-ABI 接口进行多语言支持,是 Rust 生态的最佳实践。hyper 相关的代码可以作为一个最佳实践学习参考。
使用 Rust 是否绝对安全?
这个世界上不存在绝对安全这种事。
但是使用 Rust 相对于 C/Cpp 实现来说更加安全一些。
内存安全的代码都是相同的,内存不安全的 Bug 各有各的不同。
话说回来,Hyper 还是更加安全可靠的。今天 hyper 官方作者发文宣布,hyper 的 HTTP/2 不受 HTTP/2 快速重置攻击的影响[7] 。如果你想阅读更多,请查看 CVE-2023-44487[8] 。
后记
互联网基础设施中包含了很多像 curl 这样的基础库,其中隐藏了不少 0day “宝藏”,在等着黑客挖掘和利用。
互联网安全任重道远啊。
参考资料
ISRG (互联网非盈利安全组织): https://www.abetterinternet.org/post/memory-safe-curl/
[2]hyper (Rust 实现的 http 库)于 同年 12 月被合并到 curl 主分支: https://github.com/curl/curl/wiki/Hyper
[3]CVE-2023-38545: https://curl.se/docs/CVE-2023-38545.html
[4]IBB政策: https://hackerone.com/ibb?type=team
[5]curl 作者博客: https://daniel.haxx.se/blog/2023/10/11/how-i-made-a-heap-overflow-in-curl/
[6]Hyper: https://hyper.rs/
[7]hyper 的 HTTP/2 不受 HTTP/2 快速重置攻击的影响: https://seanmonstar.com/post/730794151136935936/hyper-http2-rapid-reset-unaffected
[8]CVE-2023-44487: https://www.cve.org/CVERecord?id=CVE-2023-44487