“Rust真能防住C代码里的那些老问题吗?我们做了个实验验证”

360影视 日韩动漫 2025-04-24 15:44 2

摘要:C 和 C++ 是广泛用于系统开发的传统强者,但也因为内存不安全问题频频“背锅”。那么,使用 Rust,真的能让软件变得更安全吗?系统软件工程师 Marc 最近做了一项实验,亲自验证 Rust 在处理真实世界漏洞时能否真正提升软件的安全性和稳定性。

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 重写”,并展示我们通过实证研究得到的结果——既有高层次的概览,也有技术细节的深入剖析。

一个现实中的严重漏洞

2021 年,有人在西门子出售的 Nucleus 实时操作系统中发现了一个漏洞。当时 Forescout 安全研究人员介绍道(https://www.forescout.com/blog/forescout-and-jsof-disclose-new-dns-vulnerabilities-impacting-millions-of-enterprise-and-consumer-devices/):

(…) 超过 30 亿台设备使用这个实时操作系统,包括超声波设备、存储系统、航空电子系统等关键应用。

换句话说,这段代码的使用场景极其广泛,而且其中不少都是“绝不能发生事故”的关键系统。那么,到底出了什么问题?

使用 Nucleus 的联网设备需要通过 DNS 服务器解析域名,比如 tweedegolf.nl。Nucleus 中负责读取 DNS 响应的那部分代码,在一切正常的“理想路径”下可以运行良好:做到真实的响应,正确处理信息。

但问题是,攻击者可以伪造 DNS 响应,在其中故意加入“错误”。恶意黑客可以利用这些伪造的响应诱使 Nucleus 向不该写入的内存位置写数据。

一旦发生这样的事情,后果将非常严重:只需覆盖几个关键内存位置,攻击者就能让设备崩溃。更糟的是,程序本身也是储存在内存中的,技术更高明的攻击者甚至可以借此重新编程设备,让它做任何他们想让它做的事。

不过现在不用担心!Nucleus 这个漏洞已经修复了,大家可以放心睡觉了。

为什么你应该关心这件事?

问题是,不仅仅是曾经的 Nucleus“中招”。另外还有四个网络库也被发现存在类似的漏洞。这些漏洞被统称为 NAME:WRECK(https://www.forescout.com/research-labs/namewreck/),说明这类代码的编写方式本身就存在普遍问题。

我们从安全咨询公司 Midnight Blue 那里知晓了这个案例。他们向我们提出一个问题: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 个“正常路径”测试用例(Nucleus NET 可以通过);

12 个“异常路径”测试用例,这些用例会导致崩溃、错误结果,或引发 Nucleus NET 中可被利用的漏洞。

下表总结了每组代码在这些测试中的表现,并与 Nucleus NET 原始实现进行了对比:

绿色表示测试通过:程序对输入做出了正确的处理。正常路径的测试中,这意味着域名被正确解析;压力测试中则表示输入被正确拒绝。

🟧 橙色表示“普通的测试失败”:程序错误地拒绝了合法输入,或接受了解析出错的内容。这种属于小 bug,不至于能被黑客利用。

🔴 红色则是更严重的失败:比如运行时崩溃(Rust 中的 panic!)、陷入死循环,或向不该写的内存地址写数据。简而言之,红色就意味着“存在可利用漏洞”。

一些观察结果:

所有参与的工程师都使用了模糊测试(fuzzing)来检测程序是否会 panic,因此,没有任何一个 Rust 实现出现了红色标记。

第七个压力测试让 Nucleus NET 陷入了死循环,仅这一点就足以造成拒绝服务(DoS)攻击。即使没有提前提醒,所有参与者都发现了这个问题,其中三位工程师是通过模糊测试发现的。

大多数剩下的“普通 bug”其实是对 RFC1035 规范的细微违反,比如忽略了长度限制。

第六个压力测试相对较为“较真”:它测试 DNS 解码器是否能基于 RFC1035 中对“prior”这个词的严格解读,拒绝某种虽然看起来合理但不规范的解码。

在某些测试用例中,RFC1035 本身没有明确该怎么处理。在这些情况下,如果能做出两种都算合理的反应,都可以被视为通过(绿色)。

Rust 更不容易产生漏洞:确实如此,没有任何工程师引入了任意代码执行的漏洞;没人感到有必要使用 unsafe Rust。

任何利用尝试都会变成可恢复的错误:所有的实现都具备 panic 安全性,即程序不会异常终止。

Rust 代码经过更彻底的测试:所有工程师都在限定时间内编写了单元测试并进行了模糊测试,其中几位就是通过这些测试发现了关键错误。

使用 Rust 节省了时间和金钱:所有这些实现都开发得很快。我们也尝试让一位有经验的 C 程序员写出等效的 C 版本,即便借助本次实验积累的所有知识,写出一个安全的版本仍然耗费了三倍以上的时间。而且还没算上:二十年后打补丁的维护成本,或者如果这些漏洞真的被利用,可能造成的经济损失和社会影响。

这些发现,对于写过 Rust 的人或研究过软件安全的人来说也许并不意外。但我们希望,这些结果能帮助你从一个新的角度看待 Rust —— 它不仅仅是“那个限制特别多的语言”。

在我们公司内部,我们使用 Rust,不只是因为它能防止我们犯错,更因为它让我们能写出更安全的软件,而且写得更快。

更深入的技术探讨

我们已经听到程序员们的呼声了:“给点代码看看!”我们在这里简单地说明一下问题的本质。

简单来说,RFC1035 在 DNS 消息中,一个域名是由一系列标签(label)构成的,每个标签前面都有一个长度字节。把这些标签拼接起来(中间用点 . 分隔),就构成了人类可读的域名。一个 0 字节表示域名的结束。

比如,域名 google.com 可以表示为:

下面是一个用 C 写的、非常粗略的 DNS 域名解码函数:

uint8_t *unpack_dns(uint8_t *src) { char *buf, *dst; int len; buf = dst = malloc(strlen(src) + 1); while((len = *src++) != 0) { while(len--) *dst++ = *src++; *dst++ = '.'; } dst[-1] = 0; return buf;}

注:这个函数其实是参考了 Nut/OS 中的实现,Nut/OS 是一款嵌入式操作系统,曾经也因为其 TCP/IP 协议栈中类似的实现而曝出一系列漏洞——所以这段代码非常贴近现实!

在你准备好之前,先花点时间看看:这段代码中有哪些地方可能导致写入非法内存?

潜在错误:

攻击者可以在“域名”的某些部分嵌入空字节( bytes),这会让 strlen 报告错误的字符串长度,导致 malloc 分配的内存不足,实际写入数据时就可能发生溢出。

在 while 循环中,没有检查 len 是否超出了 buf 的容量,也就是没有边界检查。

最后一行的 dst[-1] = 0 也有问题:如果 src 正好指向一个空字节(即字符串结束),这个操作就会写入 malloc 分配内存之前的地址,属于典型的越界写入。

你可以试着把这段代码翻译成一个 Rust 函数,并且观察:仅仅通过使用 Rust,就可以大幅提升这段代码的安全性,过程并不复杂。

fn unpack_dns(mut src: &[u8]) -> Option> { todo! }

值得一提的是:Nucleus NET 中的实际代码比这段更复杂一些,因为它还实现了 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 本身也有一些吐槽。虽然它是基础协议文档,但我们认为它有几个明显的设计缺陷:

某些编码方式完全没有实际意义,却依然被协议允许。

举个例子:我们更希望文档明确禁止使用“跳到另一个跳转偏移地址”(double jumping)或者跳到空字节的行为。

在一些压力测试中,我们特意用了这些无用但合法的编码——因为它们能让 Nucleus NET 崩得很精彩。但我们同时也接受另一种结果:如果程序正确解析了它,或者抛出了错误,都是合理的。

甚至连“空的域名是否有效”这种问题,RFC1035 也没讲清楚。

漏洞示例:Nucleus NET 的 C 代码(旧版本)

最后,我们放出原始的 Nucleus NET 漏洞代码(v5.2 之前的版本,漏洞已在后续版本中修复),这段代码摘自 Forescout 报告,我们对类型做了一些简化,并添加了注释以便阅读。

int DNS_Unpack_Domain_Name(uint8_t *dst, uint8_t *src, uint8_t *buf_begin) { int16_t size; int i, retval = 0; uint8_t *savesrc; savesrc = src; while(*src) { size = *src; while((size & 0xC0) == 0xC0) { if(!retval) { retval = src - savesrc + 2; } src++; src = &buf_begin[(size & 0x3F) * 256 + *src]; /* ! */ size = *src; } src++; for(i=0; i *dst++ = *src++; } *dst++ = '.'; } *(--dst) = 0; /* ! */ src++; if(!retval) { retval = src - savesrc; } return retval;}

让我们来列一下这段代码中的几个问题:

该表达式 &buf_begin[(size & 0x3F) * 256 + *src]; 存在多个严重缺陷:

它完全信任输入中提供的偏移量,并直接跳转到那个内存地址。

它可能跳回已经访问过的内存位置,从而导致我们之前提到的“无限循环”问题。

如果这行代码让 src 指向了一个包含空字节的内存地址,这个空字节会被直接跳过,代码还会“很有勇气地”往结果里写一个空的域名部分,然后继续往后解析……

for 循环中也存在两个问题:

没有任何边界检查来确认解析结果是否会超出 dst 指向的缓冲区,也没有检查是否超过了 RFC1035 中规定的最大域名长度(255 字节)。

for 循环条件中的 size & 0x3F 只掩盖了长度字节的高两位,但没有真正检查该长度值是否合法。比如一个无效的长度指示符 65 会被当成 1 来处理,而之后的一切行为就都由输入控制了。

如果 *src 指向的是空字节,那么这段代码和我们前面提到的“快又脏”版本一样,会出错:

在这种情况下,函数末尾的 *(--dst) = 0 很可能会写入内存分配器内部使用的区域,属于经典的越界写入漏洞。

这段代码用 Rust 来实现会是什么样子?

综合我们几位工程师写出的版本,我们整理出一个“示范性”的 Rust 实现,来解决上面提到的这些问题。

pub fn decode_dns_name(mut input: &'a [u8], mut backlog: &'a [u8]) -> Option> { let mut result = Vec::with_capacity(256); loop { match usize::from(*input.first?) { 0 => break, prefix @ ..=0x3F if result.len + prefix { let part; (part, input) = input[1..].split_at_checked(prefix)?; result.extend_from_slice(part); result.push(b'.'); } 0xC0.. => { let (offset_bytes, _) = input.split_first_chunk?; let offset = u16::from_be_bytes(*offset_bytes) & !0xC000; (backlog, input) = backlog.split_at_checked(usize::from(offset))?; } _ => return None, } } result.pop?; Some(result)}

如果有嵌入式程序员看到我们在这里分配了一个向量(Vec),或许会笑我们的话,但其实用 heapless::Vec 替代 Vec 完全没问题。真的,试试看!事实上,用它反而能让代码更简洁,因为这样就不需要 match 表达式中第二个分支的 if 条件判断了。

当然我们承认有些偏向 Rust,但我们也确实认为,这个 Rust 版本的实现,更清晰地表达了它在做什么。

总结

“C 语言存在内存安全问题”、“现实中确实有很多危险的内存不安全代码”、“Rust 可以解决这个问题”——这些说法并不新鲜。甚至连大公司都已经拿出了实打实的证明。

但这次我们接受了一个挑战,自己做了一次实验。即便给工程师的时间和说明都很有限,最终写出来的 Rust 代码,确实避开了那些跟内存安全相关的漏洞。如果你愿意,也完全可以自己试试看。

我们一直说“Rust 是我们打造更安全软件的方式”。希望这篇文章的整体介绍或技术细节分析,能够帮你理解我们为什么这么说,以及它到底是怎么做到的。

来源:CSDN一点号

相关推荐