百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术资源 > 正文

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

moboyou 2025-06-12 13:14 43 浏览

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:WRECKhttps://www.forescout.com/research-labs/namewreck/),说明这类代码的编写方式本身就存在普遍问题。

我们从安全咨询公司 Midnight Blue 那里知晓了这个案例。他们向我们提出一个问题:Rust 能避免这种问题吗?

这篇博客就是我们的回答。前半部分是一个不涉及太多技术细节的高层次说明;后半部分面向 C、C++ 或 Rust 程序员,会深入分析 Nucleus 的实际代码,并演示如何用现代 Rust 编写等效代码。

我们的观点是:Rust 的确可以防止这种问题。但我们不会仅仅停留在“Rust 是内存安全的”这一表面(虽然它确实如此)。我们将更进一步!我们做了一次小型的工程实验,结果让我们确信,如果当初使用了现代 Rust:

  1. 程序员根本不会引入这些漏洞;

  2. 即使有人尝试利用漏洞,也只会触发可恢复的错误;

  3. 代码会被更彻底地测试;

  4. 节省时间,也节省了成本。

根本原因

为什么会出现这样的错误?作为程序员,我们往往容易关于细节,但从概念上来说,答案其实很简单:

  • 现有的编程工具并不会主动帮你避免错误,反而在你犯错之后还很难发现问题;

  • 程序在处理外部输入时,默认是“信任”的,而不是明确地进行验证。

我们当然可以轻松地指出:“哈哈!又是那些写 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 本身没有明确该怎么处理。在这些情况下,如果能做出两种都算合理的反应,都可以被视为通过(绿色)。

评估总结

让我们回顾一下最开始提出的四个论点:

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

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

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

  4. 使用 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<Vec<u8>> { 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 < (size & 0x3F); i++) { /* ! */ *dst++ = *src++;  }  *dst++ = '.';  } *(--dst) = 0; /* ! */ src++; if(!retval) { retval = src - savesrc; } return retval;}

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

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

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

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

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

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

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

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

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

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


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

综合我们几位工程师写出的版本,我们整理出一个“示范性”的 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)}

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

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


总结

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

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

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

相关推荐

Excel技巧:SHEETSNA函数一键提取所有工作表名称批量生产目录

首先介绍一下此函数:SHEETSNAME函数用于获取工作表的名称,有三个可选参数。语法:=SHEETSNAME([参照区域],[结果方向],[工作表范围])(参照区域,可选。给出参照,只返回参照单元格...

Excel HOUR函数:“小时”提取器_excel+hour函数提取器怎么用

一、函数概述HOUR函数是Excel中用于提取时间值小时部分的日期时间函数,返回0(12:00AM)到23(11:00PM)之间的整数。该函数在时间数据分析、考勤统计、日程安排等场景中应用广泛。语...

Filter+Search信息管理不再难|多条件|模糊查找|Excel函数应用

原创版权所有介绍一个信息管理系统,要求可以实现:多条件、模糊查找,手动输入的内容能去空格。先看效果,如下图动画演示这样的一个效果要怎样实现呢?本文所用函数有Filter和Search。先用filter...

FILTER函数介绍及经典用法12:FILTER+切片器的应用

EXCEL函数技巧:FILTER经典用法12。FILTER+切片器制作筛选按钮。FILTER的函数的经典用法12是用FILTER的函数和切片器制作一个筛选按钮。像左边的原始数据,右边想要制作一...

office办公应用网站推荐_office办公软件大全

以下是针对Office办公应用(Word/Excel/PPT等)的免费学习网站推荐,涵盖官方教程、综合平台及垂直领域资源,适合不同学习需求:一、官方权威资源1.微软Office官方培训...

WPS/Excel职场办公最常用的60个函数大全(含卡片),效率翻倍!

办公最常用的60个函数大全:从入门到精通,效率翻倍!在职场中,WPS/Excel几乎是每个人都离不开的工具,而函数则是其灵魂。掌握常用的函数,不仅能大幅提升工作效率,还能让你在数据处理、报表分析、自动...

收藏|查找神器Xlookup全集|一篇就够|Excel函数|图解教程

原创版权所有全程图解,方便阅读,内容比较多,请先收藏!Xlookup是Vlookup的升级函数,解决了Vlookup的所有缺点,可以完全取代Vlookup,学完本文后你将可以应对所有的查找难题,内容...

批量查询快递总耗时?用Excel这个公式,自动计算揽收到签收天数

批量查询快递总耗时?用Excel这个公式,自动计算揽收到签收天数在电商运营、物流对账等工作中,经常需要统计快递“揽收到签收”的耗时——比如判断某快递公司是否符合“3天内送达”的服务承...

Excel函数公式教程(490个实例详解)

Excel函数公式教程(490个实例详解)管理层的财务人员为什么那么厉害?就是因为他们精通excel技能!财务人员在日常工作中,经常会用到Excel财务函数公式,比如财务报表分析、工资核算、库存管理等...

Excel(WPS表格)Tocol函数应用技巧案例解读,建议收藏备用!

工作中,经常需要从多个单元格区域中提取唯一值,如体育赛事报名信息中提取唯一的参赛者信息等,此时如果复制粘贴然后去重,效率就会很低。如果能合理利用Tocol函数,将会极大地提高工作效率。一、功能及语法结...

Excel中的SCAN函数公式,把计算过程理清,你就会了

Excel新版本里面,除了出现非常好用的xlookup,Filter公式之外,还更新一批自定义函数,可以像写代码一样写公式其中SCAN函数公式,也非常强大,它是一个循环函数,今天来了解这个函数公式的计...

Excel(WPS表格)中多列去重就用Tocol+Unique组合函数,简单高效

在数据的分析和处理中,“去重”一直是绕不开的话题,如果单列去重,可以使用Unique函数完成,如果多列去重,如下图:从数据信息中可以看到,每位参赛者参加了多项运动,如果想知道去重后的参赛者有多少人,该...

Excel(WPS表格)函数Groupby,聚合统计,快速提高效率!

在前期的内容中,我们讲了很多的统计函数,如Sum系列、Average系列、Count系列、Rank系列等等……但如果用一个函数实现类似数据透视表的功能,就必须用Groupby函数,按指定字段进行聚合汇...

Excel新版本,IFS函数公式,太强大了!

我们举一个工作实例,现在需要计算业务员的奖励数据,右边是公司的奖励标准:在新版本的函数公式出来之前,我们需要使用IF函数公式来解决1、IF函数公式IF函数公式由三个参数组成,IF(判断条件,对的时候返...

Excel不用函数公式数据透视表,1秒完成多列项目汇总统计

如何将这里的多组数据进行汇总统计?每组数据当中一列是不同菜品,另一列就是该菜品的销售数量。如何进行汇总统计得到所有的菜品销售数量的求和、技术、平均、最大、最小值等数据?不用函数公式和数据透视表,一秒就...