“Rust真能防住C代码里的那些老问题吗?我们做了个实验验证”
仅用于站内搜索,没有排版格式,具体信息请跳转上方微信公众号内链接
C和C++是广泛用于系统开发的传统强者,但也因为内存不安全问题频频“背锅”。那么,使用Rust,真的能让软件变得更安全吗?系统软件工程师Marc最近做了一项实验,亲自验证Rust在处理真实世界漏洞时能否真正提升软件的安全性和稳定性。
原文链接:https ://tweedegolf.nl/en/blog/152/does-using-rust-really-make-your-software-safer
作者|Marc
翻译工具|ChatGPT责编|苏宓
出品|CSDN(ID:CSDNnews)
我们常说,Rust是让软件更安全的方式。在这篇博客中,我们将分析一个真实存在的漏洞,把它“用Rust重写”,并展示我们通过实证研究得到的结果——既有高层次的概览,也有技术细节的深入剖析。
一个现实中的严重漏洞
(…)超过30亿台设备使用这个实时操作系统,包括超声波设备、存储系统、航空电子系统等关键应用。
换句话说,这段代码的使用场景极其广泛,而且其中不少都是“绝不能发生事故”的关键系统。那么,到底出了什么问题?
使用Nucleus的联网设备需要通过DNS服务器解析域名,比如tweedegolf.nl。Nucleus中负责读取DNS响应的那部分代码,在一切正常的“理想路径”下可以运行良好:做到真实的响应,正确处理信息。
但问题是,攻击者可以伪造DNS响应,在其中故意加入“错误”。恶意黑客可以利用这些伪造的响应诱使Nucleus向不该写入的内存位置写数据。
一旦发生这样的事情,后果将非常严重:只需覆盖几个关键内存位置,攻击者就能让设备崩溃。更糟的是,程序本身也是储存在内存中的,技术更高明的攻击者甚至可以借此重新编程设备,让它做任何他们想让它做的事。
不过现在不用担心!Nucleus这个漏洞已经修复了,大家可以放心睡觉了。
为什么你应该关心这件事?
我们从安全咨询公司MidnightBlue那里知晓了这个案例。他们向我们提出一个问题:Rust能避免这种问题吗?
这篇博客就是我们的回答。前半部分是一个不涉及太多技术细节的高层次说明;后半部分面向C、C++或Rust程序员,会深入分析Nucleus的实际代码,并演示如何用现代Rust编写等效代码。
我们的观点是:Rust的确可以防止这种问题。但我们不会仅仅停留在“Rust是内存安全的”这一表面(虽然它确实如此)。我们将更进一步!我们做了一次小型的工程实验,结果让我们确信,如果当初使用了现代Rust:
程序员根本不会引入这些漏洞;
即使有人尝试利用漏洞,也只会触发可恢复的错误;
代码会被更彻底地测试;
节省时间,也节省了成本。
根本原因
为什么会出现这样的错误?作为程序员,我们往往容易关于细节,但从概念上来说,答案其实很简单:
现有的编程工具并不会主动帮你避免错误,反而在你犯错之后还很难发现问题;
程序在处理外部输入时,默认是“信任”的,而不是明确地进行验证。
我们当然可以轻松地指出:“哈哈!又是那些写C的程序员搞出来的缓冲区溢出!”但也别太苛刻:很多这样的代码写于安全意识还未普及的早期岁月。说到底,谁会想到DNS服务器会发送有问题的响应消息呢?而且Nucleus是1993年开发的,当时写实时操作系统,难道还有比C更现实的选择吗?
Rust在实践中表现如何?
Rust是一门内存安全的语言。这意味着在大多数情况下,Rust编写的程序可以保证不会读取或修改本不该访问的内存区域。
但针对RFC1035格式(它的规则并不是我们平时看到的”www.example.com"这种普通字符串,而是一种更底层、更节省空间的二进制表示方式)的域名解码问题,我们的假设是:除了天然具备内存安全性之外,Rust还有两个额外优势:
它是一种更具表现力的算法语言,换句话说,用惯用的Rust写出的解决方案,往往比用C写的解决方案包含更少需要“特殊注意”的地方。
写单元测试和模糊测试非常简单,这会鼓励程序员对自己的代码进行更批判性的审视。
实验过程
我们决定用自己作为小白鼠来验证这个假设。首先,我们整理了RFC1035风格的DNS消息编码的描述,然后把它作为一个编程练习,发给了几位同事,要求他们在3到4小时内完成。参与者包括两位实习生和两位正式员工。
与此同时,我们分析了该DNS_Unpack_Domain_Name函数,并基于它的所有问题,设计了一套压力测试。同时,我们还编写了一个模糊测试工具,用来发现DNS实现中常见的一些其他漏洞。这些内容我们都对参与者保密。
题目本身是故意留有空白的:只给出了一个RFC1035的链接,但没有强制要求他们研究文档。我们想模拟的是一种“在周五下午随便搞搞”的编程场景——信息不完整,时间也有点紧——正是漏洞最容易滋生的条件。
(顺便说一句,我们也把这道题丢给了ChatGPT玩玩,不过这就是另一个故事了!)
实验结果
我们的测试集中包含:
6个“正常路径”测试用例(NucleusNET可以通过);
12个“异常路径”测试用例,这些用例会导致崩溃、错误结果,或引发NucleusNET中可被利用的漏洞。
下表总结了每组代码在这些测试中的表现,并与NucleusNET原始实现进行了对比:
✅绿色表示测试通过:程序对输入做出了正确的处理。正常路径的测试中,这意味着域名被正确解析;压力测试中则表示输入被正确拒绝。
🟧橙色表示“普通的测试失败”:程序错误地拒绝了合法输入,或接受了解析出错的内容。这种属于小bug,不至于能被黑客利用。
🔴红色则是更严重的失败:比如运行时崩溃(Rust中的panic!)、陷入死循环,或向不该写的内存地址写数据。简而言之,红色就意味着“存在可利用漏洞”。
一些观察结果:
所有参与的工程师都使用了模糊测试(fuzzing)来检测程序是否会panic,因此,没有任何一个Rust实现出现了红色标记。
第七个压力测试让NucleusNET陷入了死循环,仅这一点就足以造成拒绝服务(DoS)攻击。即使没有提前提醒,所有参与者都发现了这个问题,其中三位工程师是通过模糊测试发现的。
大多数剩下的“普通bug”其实是对RFC1035规范的细微违反,比如忽略了长度限制。
第六个压力测试相对较为“较真”:它测试DNS解码器是否能基于RFC1035中对“prior”这个词的严格解读,拒绝某种虽然看起来合理但不规范的解码。
在某些测试用例中,RFC1035本身没有明确该怎么处理。在这些情况下,如果能做出两种都算合理的反应,都可以被视为通过(绿色)。
让我们回顾一下最开始提出的四个论点:
Rust更不容易产生漏洞:确实如此,没有任何工程师引入了任意代码执行的漏洞;没人感到有必要使用unsafeRust。
任何利用尝试都会变成可恢复的错误:所有的实现都具备panic安全性,即程序不会异常终止。
Rust代码经过更彻底的测试:所有工程师都在限定时间内编写了单元测试并进行了模糊测试,其中几位就是通过这些测试发现了关键错误。
使用Rust节省了时间和金钱:所有这些实现都开发得很快。我们也尝试让一位有经验的C程序员写出等效的C版本,即便借助本次实验积累的所有知识,写出一个安全的版本仍然耗费了三倍以上的时间。而且还没算上:二十年后打补丁的维护成本,或者如果这些漏洞真的被利用,可能造成的经济损失和社会影响。
这些发现,对于写过Rust的人或研究过软件安全的人来说也许并不意外。但我们希望,这些结果能帮助你从一个新的角度看待Rust——它不仅仅是“那个限制特别多的语言”。
在我们公司内部,我们使用Rust,不只是因为它能防止我们犯错,更因为它让我们能写出更安全的软件,而且写得更快。
更深入的技术探讨
我们已经听到程序员们的呼声了:“给点代码看看!”我们在这里简单地说明一下问题的本质。
简单来说,RFC1035在DNS消息中,一个域名是由一系列标签(label)构成的,每个标签前面都有一个长度字节。把这些标签拼接起来(中间用点.分隔),就构成了人类可读的域名。一个0字节表示域名的结束。
比如,域名google.com可以表示为:
下面是一个用C写的、非常粗略的DNS域名解码函数:
(注:这个函数其实是参考了Nut/OS中的实现,Nut/OS是一款嵌入式操作系统,曾经也因为其TCP/IP协议栈中类似的实现而曝出一系列漏洞——所以这段代码非常贴近现实!)
在你准备好之前,先花点时间看看:这段代码中有哪些地方可能导致写入非法内存?
潜在错误:
攻击者可以在“域名”的某些部分嵌入空字节(nullbytes),这会让strlen报告错误的字符串长度,导致malloc分配的内存不足,实际写入数据时就可能发生溢出。
在while循环中,没有检查len是否超出了buf的容量,也就是没有边界检查。
最后一行的dst[-1]=0也有问题:如果src正好指向一个空字节(即字符串结束),这个操作就会写入malloc()分配内存之前的地址,属于典型的越界写入。
你可以试着把这段代码翻译成一个Rust函数,并且观察:仅仅通过使用Rust,就可以大幅提升这段代码的安全性,过程并不复杂。
fnunpack_dns(mutsrc:&[u8])->Option>{todo!()}
值得一提的是:NucleusNET中的实际代码比这段更复杂一些,因为它还实现了RFC1035中定义的一种压缩方案:
如果一个长度字节的高两位是1(即字节值大于等于0xC0),那它和紧接着的下一个字节共同构成了一个14位的偏移地址,这个地址指向DNS消息中域名的剩余部分。也就是说,这种编码支持“后跳转”,可以通过偏移来重用前面已经解析过的域名部分。
举个例子,如果在DNS响应的偏移地址0x14A处存放的是a.net,那么0x14A就编码了a.net,如果0x152是跳转到0x14A,那么0x152表示的是b.net。
你也应该能看出来:如果不加检查就盲目接受输入中提供的偏移地址,很容易就会访问超出边界的内存。
虽然我们很想深入讲解DNS实现中可能出现的各种灾难性问题,但老实说这已经有人做得很好了:
RFC9267(2022年发布,https ://datatracker.ietf.org/doc/rfc9267/):对这些问题进行了深入讨论,内容非常易读,而且还列举了不少真实世界中曾经发生的错误。
我们对RFC1035本身也有一些吐槽。虽然它是基础协议文档,但我们认为它有几个明显的设计缺陷:
某些编码方式完全没有实际意义,却依然被协议允许。
举个例子:我们更希望文档明确禁止使用“跳到另一个跳转偏移地址”(doublejumping)或者跳到空字节的行为。
在一些压力测试中,我们特意用了这些无用但合法的编码——因为它们能让NucleusNET崩得很精彩。但我们同时也接受另一种结果:如果程序正确解析了它,或者抛出了错误,都是合理的。
甚至连“空的域名是否有效”这种问题,RFC1035也没讲清楚。
漏洞示例:NucleusNET的C代码(旧版本)
最后,我们放出原始的NucleusNET漏洞代码(v5.2之前的版本,漏洞已在后续版本中修复),这段代码摘自Forescout报告,我们对类型做了一些简化,并添加了注释以便阅读。
让我们来列一下这段代码中的几个问题:
该表达式&buf_begin[(size&0x3F)256+src];存在多个严重缺陷:
它完全信任输入中提供的偏移量,并直接跳转到那个内存地址。
它可能跳回已经访问过的内存位置,从而导致我们之前提到的“无限循环”问题。
如果这行代码让src指向了一个包含空字节的内存地址,这个空字节会被直接跳过,代码还会“很有勇气地”往结果里写一个空的域名部分,然后继续往后解析……
for循环中也存在两个问题:
没有任何边界检查来确认解析结果是否会超出dst指向的缓冲区,也没有检查是否超过了RFC1035中规定的最大域名长度(255字节)。
for循环条件中的size&0x3F只掩盖了长度字节的高两位,但没有真正检查该长度值是否合法。比如一个无效的长度指示符65会被当成1来处理,而之后的一切行为就都由输入控制了。
如果src指向的是空字节,那么这段代码和我们前面提到的“快又脏”版本一样,会出错:
在这种情况下,函数末尾的(–dst)=0很可能会写入内存分配器内部使用的区域,属于经典的越界写入漏洞。
这段代码用Rust来实现会是什么样子?
综合我们几位工程师写出的版本,我们整理出一个“示范性”的Rust实现,来解决上面提到的这些问题。
如果有嵌入式程序员看到我们在这里分配了一个向量(Vec),或许会笑我们的话,但其实用heapless::Vec替代Vec完全没问题。真的,试试看!事实上,用它反而能让代码更简洁,因为这样就不需要match表达式中第二个分支的if条件判断了。
当然我们承认有些偏向Rust,但我们也确实认为,这个Rust版本的实现,更清晰地表达了它在做什么。
总结
“C语言存在内存安全问题”、“现实中确实有很多危险的内存不安全代码”、“Rust可以解决这个问题”——这些说法并不新鲜。甚至连大公司都已经拿出了实打实的证明。
但这次我们接受了一个挑战,自己做了一次实验。即便给工程师的时间和说明都很有限,最终写出来的Rust代码,确实避开了那些跟内存安全相关的漏洞。如果你愿意,也完全可以自己试试看。
我们一直说“Rust是我们打造更安全软件的方式”。希望这篇文章的整体介绍或技术细节分析,能够帮你理解我们为什么这么说,以及它到底是怎么做到的。
好啦,今天的内容分享就到这,感觉不错的同学记得分享点赞哦!
PS:程序员好物馆持续分享程序员学习、面试相关干货,不见不散!
点分享
点收藏
点点赞
点在看