使用 Rust 真的能让软件更安全吗?
我们一直在说,Rust 是我们让软件更安全的方式。在本博客中,我们将解决一个现实世界中的漏洞,“用 Rust 重写它”,并向您展示我们的实证研究成果,既有高层次的概述,也有技术深度的挖掘。
现实世界中的关键漏洞
2021 年,西门子销售的 Nucleus 实时操作系统发现了一个漏洞。据安全研究人员称:
(……)超过 30 亿台设备使用这一实时操作系统,如超声波机、存储系统、航空电子设备的关键系统等。
换句话说,这段代码有很多应用,包括那些不能发生坏事的应用。那么,是什么出了问题?
在 Nucleus 上运行的联网设备需要通过与 DNS 服务器交互来处理 tweedegolf.nl
等域名。Nucleus 中读取 DNS 服务器发送的响应的易受攻击部分在快乐路径场景下工作正常:包含正确信息的真实响应被正确处理。
但是,可以伪造包含故意 “错误 ”的 “DNS 响应”。恶意黑客可以利用这一点,诱使 Nucleus 向内存位置写入本不该写入的内容。
后果不堪设想:攻击者只需覆盖几个敏感位,就能让设备崩溃。请记住,任何程序代码本身也存储在内存中的某个位置,因此更聪明的攻击者甚至可以尝试对运行 “纽核力 ”的设备重新编程,让其为所欲为。
现在不用担心了 Nucleus 已经打上了补丁,我们又可以安然入睡了。
你为什么要关心
不过,闪电并没有只击中 “纽核力”。我们还发现其他四个网络库也存在类似问题。这些漏洞被统称为 NAME:WRECK。这表明,过去编写此类代码的方式普遍存在问题。
我们从安全咨询公司 Midnight Blue 的朋友那里了解到这个案例。他们向我们提出的问题是:你能说明 Rust 如何防止这种情况发生吗?
本博客将回答这个问题。在前半部分,我们将提供一个不涉及太多技术细节的高层次答案。后半部分面向 C、C++ 和/或 Rust 程序员,将深入探讨 Nucleus 的实际代码,以及如何用现代 Rust 编写。
我们的主张的确是,使用 Rust 就能避免这种情况的发生。这不仅仅是说 “Rust 是内存安全的 ”那么简单(虽然它确实是)。我们要做的远不止于此!我们做了一个小小的工程实验,结果让我们确信,如果使用了现代 Rust:
- 程序员就不会引入这些漏洞。
- 任何利用漏洞的尝试都会导致可恢复的错误。
- 代码会得到更彻底的测试。
- 节省时间和金钱。
根本原因
为什么会发生这样的错误?作为程序员,我们经常会关注细节,但从概念上讲,答案很简单:
- 编写软件所使用的工具无助于防止错误的发生,事实上,即使已经发生了错误,也很难发现。
- 外部输入是隐含的信任,而不是明确的验证。
现在,我们很容易指责说:“哈!那些愚蠢的 C 程序员和他们的缓冲区溢出!”。不过,我们也不要过于苛责:其中一些代码是在安全意识还未深入人心的绿色时代编写的。DNS 服务器究竟为什么要发送错误信息?在 1993 年首次构建 Nucleus 时,在编写实时操作系统时,除了 C 语言之外,还有其他现实的选择吗?
Rust 在实践中有什么帮助?
Rust 是一种内存安全语言。这意味着在大多数情况下,可以保证用 Rust 编写的程序不会读取或修改不该访问的内存。
但对于解码 RFC1035 编码的域名,我们假设除了自动赋予内存安全性外,Rust 还会有两个额外的优势:
- 它是一种表现力更强的算法语言,这意味着一个惯用的 Rust 解决方案会比一个惯用的 C 解决方案包含更少需要特别注意的问题。
- 编写单元测试和模糊测试非常容易,这将鼓励程序员更严格地检查他们的代码。
实验
我们决定把自己当作小白鼠来验证我们的假设。首先,我们编写了一份关于 RFC1035 风格 DNS 消息编码的说明,并将其作为编程练习提交给几位同事,要求他们花三到四个小时完成。我们把这个练习交给了两名实习生和两名工作人员。
与此同时,我们分析了 DNS_Unpack_Domain_Name
例程(如 [Forescout 报告] 中所列),建立了一套压力测试,对其存在的每个问题进行测试。我们还编写了一个模糊器,发现了 DNS 实现中的一些其他常见问题。我们对所有参与者保密。
问题陈述是故意不明确的:它包含了 RFC1035 的链接,但并没有明确指示要对其进行研究。目的是模拟 “让我们在周五下午编写代码 ”的情况,信息不全,时间紧迫。这是错误滋生的条件。
(为了好玩,我们还把这个练习反馈给了 ChatGPT,看看会发生什么。不过这是后话了。)
implementation | happy path | stress tests |
---|---|---|
Nucleus NET | 🟩🟩🟩🟩🟩🟩 | X🟧X🟧X🟥X🟧X🟥X🟧X🟥X🟥X🟧X🟥X🟥X🟥 |
Engineer 1 | 🟩🟩X🟧🟩🟩🟩 | X🟧X🟧🟩X🟧🟩X🟧🟩🟩🟩🟩X🟧🟩 |
Engineer 2 | 🟩🟩🟩🟩🟩🟩 | 🟩🟩🟩🟩🟩X🟧🟩🟩🟩🟩🟩🟩 |
Engineer 3 | 🟩🟩🟩🟩🟩🟩 | 🟩🟩🟩🟩🟩X🟧🟩🟩🟩🟩🟩🟩 |
Engineer 4 | 🟩🟩🟩🟩🟩🟩 | 🟩🟩🟩🟩🟩X🟧🟩X🟧🟩🟩X🟧🟩 |
测试结果
我们的压力测试包含 6 个快乐路径测试(Nucleus NET 可以通过)和 12 个负面测试用例,这些测试用例会导致崩溃、错误结果或触发原始 Nucleus NET 中的可利用条件。下表列出了所有测试的结果,并与 Nucleus NET 的代码进行了比较,不再赘述。
绿色标记(ό)表示测试通过:给定输入,程序产生了有效结果。对于快乐路径测试,这意味着输入已成功解码;对于压力测试,这意味着输入被拒绝。
橙色(X🟧)表示 “正常 ”测试失败:代码要么拒绝了有效输入,要么解码了本应拒绝的内容。这些错误很简单,但不足以导致漏洞利用。
红色(X🟥)表示代码以更不祥的方式失败:例如,遇到运行时中止(如 Rust 中的 panic!),陷入无限循环,或写入不应写入的内存位置。简而言之,红色意味着 “可被利用”。
一些观察结果
- 所有工程师都使用了模糊测试(fuzzing)来测试恐慌安全性,因此没有一个 Rust 实现出现红色标记。
- 第七次压力测试让 Nucleus NET 进入了一个无限循环,其本身就足以导致拒绝服务。在没有人告知的情况下,每个人都发现了这一点。三位工程师通过模糊测试发现了这个问题。
- 观察到的其余 “简单错误 ”大多是对 RFC1035 规范的细微违反,例如忽略长度限制。
- 第六项压力测试相当迂腐:它检查 DNS 解码器是否根据对 RFC1035 中 “prior ”一词的严格解释,拒绝接受原本合理的解码。
- 在某些测试案例中,RFC1035 并没有明确说明具体该怎么做。在这些情况下,有两种合理的结果可以获得绿色标记。
评估
让我们重温一下开头提出的四点主张:
- Rust 存在漏洞的可能性较小: 确实没有工程师引入任意代码执行漏洞:没有人觉得有必要使用不安全的 Rust。
- 任何漏洞利用尝试都会导致可恢复的错误: 所有的解决方案都是安全的,即不会导致软件异常终止。
- Rust 代码的测试更加彻底: 所有工程师都在规定时间内编写了单元测试并对代码进行了模糊测试。几位工程师通过这种方式发现了一个关键错误。
- 使用 Rust 可以节省时间和金钱 所有这些解决方案都是快速开发出来的。我们还尝试过由一名经验丰富的 C 语言程序员进行适当的 C 语言实现,即使掌握了这次实验所获得的知识,我们仍然花费了至少三倍的时间来获得一个安全的版本。当然,这还不包括在 20 年后修补漏洞所浪费的金钱,以及如果这些漏洞被积极利用可能造成的潜在经济和/或社会成本。
对于任何编写过 Rust 或研究过软件安全的人来说,这些发现都不会令人惊讶。但我们希望这些结果能帮助您思考 Rust,而不仅仅是 “那种有更多限制的编程语言”。
在 Tweede golf,我们使用 Rust 不只是因为它能防止我们犯错,我们使用它还因为它能让我们编写更安全的软件,同时又能快速。
深度挖掘
我们可以听到程序员的呼喊:给我看一些代码!要了解 Nucleus NET 例程,我们当然可以推荐阅读 [Forescout 报告],但我们也将在这里简要说明这个问题。
简化一下,RFC1035 规定 DNS 信息中的域名编码为一系列标签,每个标签前面都有一个长度字节。这些标签连接在一起(中间穿插一个.),就构成了域名的人读版本。零字节表示域名的结束。
例如,域名 google.com
可以表示为
x06 | g | o | o | g | l | e | x03 | c | o | m | x00 |
解码这种格式的快捷 C 语言函数可编写如下:
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 的 DNS 解码例程,该例程也用于嵌入式设备,是 TCP/IP 协议栈中另一个漏洞集合的一部分,因此这是真实的代码!)。
花点时间找出可能导致该例程写入无效内存位置的错误。准备就绪后…
单击以显示错误:
- 1. 攻击者可以在 “域名 “中嵌入空字节,使 strlen 报告错误的大小,并让 malloc 分配比实际需要少的内存。
- 2. 在 while 循环中,没有检查 len 是否超过 buf 的容量。
- 3. 结尾处的 dst[-1] = 0 也有问题:如果 src 指向空字节,这一行将写入位于 malloc() 分配的内存之前的内存位置。
我们把这个问题留给读者,让他们把它翻译成 Rust 函数,并观察到它可以相对直接地大大提高安全性:
fn unpack_dns(mut src: &[u8]) -> Option<Vec<u8>> { todo!() }
Nucleus NET 代码比上述代码稍微复杂一些,因为它还试图处理 RFC1035 中描述的压缩方案:如果一个长度字节的两个高位被设置(即 0xC0 或更高),这意味着它和它后面的字节组合成一个 14 位偏移量,进入包含域名剩余部分的 DNS 消息。这样就可以进行反向引用。例如,假设在 DNS 响应的偏移 14Ah 处,我们可以找到
# | 14A | 14B | 14C | 14D | 14E | 14F | 150 | 151 | 152 | 153 | 154 |
---|---|---|---|---|---|---|---|---|---|---|---|
.. | x01 | a | x03 | n | e | t | x00 | x01 | b | xC1 | x4D |
那么偏移量 14Ah 对 a.net
进行编码,偏移量 152h 对 b.net
进行编码。
显而易见,盲目地接受输入所包含的任何偏移量,很容易让解码器访问本应越界的内存。
我们很想深入研究 DNS 实现中可能出现的问题,但这样做只会浪费时间: 2022 年发布的 RFC9267 正是如此!它的可读性很高,包含了在实际应用中出现的各种错误示例。
我们对 RFC1035 本身也有一些批评意见。例如,编码中存在明显无用的编码。例如,我们希望 RFC1035 能明确规定,14 位偏移不允许直接跳转到另一个偏移(即 “双跳转”)或空字节。在一些压力测试中,我们使用了这些无用的编码(因为它们会使 Nucleus NET 以有趣的方式崩溃),但我们既接受了正确解码的域名,也接受了作为正确答案的错误条件。我们甚至不清楚完全为空的域名是否是有效的编码。
易受攻击的 C 代码
以下是 Forescout 报告中打印的 Nucleus NET 代码(在 5.2 版之前的代码,该版本已对其进行了修补)的所有细节。为了清晰起见,我们对代码类型进行了编辑,并添加了一些注释:
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 < (size & 0x3F); 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 中的表现
综合我们工程师的所有解决方案,我们提出了以下 “示例 “解决方案
pub fn decode_dns_name<'a>(mut input: &'a [u8], mut backlog: &'a [u8]) -> Option<Vec<u8>> {
let mut result = Vec::with_capacity(256);
loop {
match usize::from(*input.first()?) {
0 => break,
prefix @ ..=0x3F if result.len() + prefix <= 255 => {
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)
}
如果有嵌入式程序员现在正口吐白沫,嘲笑我们分配了一个向量,那么用 heapless::Vec<u8, 256>
来替换 Vec
是非常容易的。试试看吧!事实上,这个解决方案变得更简单了,因为它不再需要match
表达式第二部分中的 if
guard!
当然,我们也有偏见,但我们也认为这个例程更清楚地表达了所要做的事情。
(关于实际使用的 Rust 实现,你也可以看看 smoltcp 的源代码。函数签名略有不同,风格上也有一些差异,但逻辑结构非常相似)。
结论
关于 C 代码内存不安全、存在有害的内存不安全代码以及 Rust 可以解决这一问题的说法并不新鲜。甚至还有直接来自大型科技公司的证据。但是,我们接受了挑战,做了自己的实验,尽管我们的工程师得到的时间和指令非常有限,但最终产生的 Rust 代码确实避免了与内存安全相关的漏洞。如果你愿意,甚至可以亲自尝试一下。
我们一直在说,Rust 是我们让软件更安全的方式。希望我们的概述或技术深入研究能让你了解我们为什么一直这么说,以及如何做到这一点。
本文文字及图片出自 Does using Rust really make your software safer?