同样是段错误,为啥Rust算漏洞而C不算?;别再直接比CVE数量了,Rust和C/C++的标准完全不同
本文通过对比curl(C库)和hyper(Rust库)中一个简单函数调用导致段错误的案例,揭示了Rust与C/C++在内存安全漏洞CVE报告标准上的根本差异。C/C++通常只将“实际发生的误用”视为调用方责任,不报库的CVE;而Rust只要存在通过safe API触发内存bug的可能性,就视为库的健全性漏洞,必须报CVE。因此直接比较两边的CVE数字具有高度误导性。
内存安全漏洞报告方式在Rust和C/C++里的根本区别
大家在网上比来比去,说Rust的CVE漏洞数量怎么跟C/C++差不多,就觉得Rust也没啥了不起。但这里头有个大坑——C/C++里很多导致程序崩溃的写法,大家默认是“你用得不对,活该”,不算库的漏洞;而在Rust里,只要你不写unsafe代码,程序还能崩,那就一定是库的漏洞,必须报CVE。所以两边统计漏洞的标准完全不一样,直接比数字就跟拿苹果和橘子比谁更圆一样,没意义。
漏洞数据库CVE的分类标准差异
CVE这个数据库,说白了就是给软件的安全漏洞贴标签、发身份证的地方。有些漏洞是逻辑写错了,比如你做了一个管理员面板,结果忘了检查访问者是不是真的管理员,那普通用户也能进去瞎搞,这锅在任何语言里都得你背。
但有一类特别恶心人的漏洞,叫内存安全漏洞。比如你让程序去读一块它不该读的内存,或者把数据写到了奇怪的地方,轻则程序崩溃,重则黑客能利用这个漏洞直接控制你的电脑。
C/C++和Rust在处理这类漏洞时,有个特别微妙的区别。我打个比方:C/C++就像一把没有保险的枪,你扣扳机它就响,但说明书上没写“别对着自己脑袋扣扳机”。你不小心扣了,大家会说“你傻啊,这还用教?”而Rust像一把有各种安全机制的枪,如果你正常握枪它还走火,那肯定是枪本身有质量问题,厂家得召回。
这个区别直接导致两边报CVE的标准天差地别。在C/C++的世界里,很多能导致内存崩溃的写法,大家默认是“使用者犯傻”,不算库的漏洞。在Rust里,只要你不写unsafe,库还能让你崩,那就是库的锅。
curl库的一个简单函数测试
curl是一个用C写的网络库,全世界无数程序都在用它下载东西、访问API。它的主要维护者Daniel Stenberg,可以说是开源界的劳模,带着一群人修修补补了30年。最近虽然被一堆AI扫描出来的假漏洞搞得焦头烂额,但curl依然是公认的坚固耐用。
咱们来考验它一下。我打开curl的文档,随便找了一个接收参数的函数,叫curl_getenv。这个函数的作用很简单:跨操作系统地获取环境变量的值。比如你想知道电脑里PATH这个变量是啥,就可以用它。
curl号称安全又稳健,那这个函数应该没问题吧?我写个极度简单的C程序:
c
#include
int main(void) {
curl_getenv(NULL);
}
就5行代码,传了一个空指针进去。编译的时候啥警告都没有。结果一运行:
bash
$ gcc test.c -otest -lcurl -Wall -Wextra
$ ./test
Segmentation fault (core dumped)
程序崩了,段错误,这就是典型的内存安全问题。按照常理,这应该算一个潜在漏洞吧?
当然不算。你我都知道,跑去给curl报这个漏洞,会被人家骂回来。但关键问题是:我们怎么知道这不算?
再改一下程序:curl_getenv("FOO"),就换了个正经参数。如果这个还崩,那curl的维护者肯定急得跳脚,立马当严重漏洞修。这两个程序就差一点点,为啥一个算漏洞一个不算?
原因很现实。在C语言里,这种因为“用错方式”导致的崩溃,大家都认为是调用者的代码有问题,不是库的毛病。为什么这么定?有两个主要原因。
第一,C语言的类型系统太弱了,根本没法精确描述“这个函数不允许传空指针”这种规则。库的作者写文档都懒得写全所有禁忌,因为写不完。你去看curl_getenv的文档,它确实没说“传NULL会段错误”。作者默认你会“正确”使用,如果你不会,出了事你自己扛。
第二,C/C++里一不小心就能触发未定义行为。如果每个可能的错误用法都算一个CVE,那随便一个C库都能爆出几百万个漏洞。每个函数调用可能有五种方式把它搞崩,这CVE编号都不够用的。
所以在C和C++里,我们通常只把“确实有人在真实程序里用错了并且导致了漏洞”的情况报CVE,而不是因为“这个API本身可以被用错”就报。换句话说,我们报的是具体的误用案例,而不是API本身有被误用的风险。
Rust处理同样情况的严格标准
那Rust里有什么区别呢?我拿hyper这个库举例。它在Rust社区的地位,跟curl在C世界的地位差不多,都是最流行的网络库。
假设hyper里也有一个类似的简单函数,接收一个参数。我写个Rust程序:
rust
fn main() {
hyper::foo(None);
}
然后cargo run一跑,程序段错误了。请问,这算hyper的CVE吗?
绝对算。
为啥?因为这个程序里一个unsafe块都没写。在Rust里,你不写unsafe,编译器就给你打包票:你的代码不会有任何内存安全问题。如果还能崩,那只能是hyper库自己内部有毛病——它内部可能用了unsafe,而且写错了。
这就是Rust和C/C++最核心的区别。在Rust里,只要你能用完全不写unsafe的方式触发一个内存bug,那这个bug 100%是库的责任,不是你的责任。我们把这种API叫做“不健全的”(unsound),或者说它有一个“健全性漏洞”。
换句话说,Rust报CVE的标准是:只要存在一种方式,用安全的API就能导致内存bug,哪怕全世界还没人真的这么写过,也要报CVE。
这在C/C++的人看来可能觉得“不公平”——你们Rust也太严格了吧,这也要报?但严格来说,Rust的CVE标准确实比C/C++更“严”。不是Rust漏洞更多,而是Rust把很多C/C++根本不当回事的情况,都算成了漏洞。
安全Rust和unsafe Rust的分界线
现在你可能会问:那我怎么知道一个函数能不能安全地用?
在C/C++里,这个问题很难回答。你得去读文档,读源码,甚至要靠经验猜。很多函数你传某个奇怪参数会不会崩,文档根本不写。
在Rust里,答案极其简单,分两种情况。
第一种情况:这个函数没标记unsafe。那答案就是能。你随便怎么调用它,都不可能造成内存安全问题。编译器替你担保了。这不是库作者拍胸脯保证,而是Rust的类型系统和所有权系统在编译时就检查过了。
第二种情况:这个函数标记了unsafe。那你调用它的时候,也必须写一个unsafe块。代码审查的时候,所有人一眼就能看到这里有个危险区域。在这个unsafe块里面,安全程度就退回到了C/C++的水平——你自己得保证所有条件都满足,否则编译器不帮你检查。
绝大多数Rust代码根本不需要写unsafe。你写一个普通的Web后端、命令行工具、游戏引擎的逻辑部分,完全可以全程不碰unsafe。这时候你拥有的内存安全保证,是C/C++永远给不了的。
打个比方:C/C++就像你开车上了高速,但所有车都没有刹车。老司机说“你控制好车距就没事”,但总有人会追尾。Rust是每辆车都有自动刹车,而且刹车系统是出厂强制检验的。你说Rust的车也会出故障?有可能,但那是个案,修好了所有车又都安全了。
CVE数字对比为什么容易误导人
网上经常有人拿着数据说:你看,Rust项目的CVE数量按代码行数算,跟C/C++差不多嘛,所以Rust也没宣传的那么安全。
这话乍一听有道理,但看完前面几章你就明白了——两边报CVE的尺子不一样。
C/C++那边漏报了很多“潜在但还没真实发生”的风险。一个C函数能被用错导致崩溃,这不叫CVE,叫“你不会用”。同样的标准放到Rust里,那叫“库的健全性漏洞”,必须报。
反过来,Rust那边多报了很多“API设计有缺陷但没人真的写错过”的情况。一个Rust库的safe函数理论上能被用崩,哪怕实际没人这么写,也要报CVE。这在C/C++里根本不算事。
所以直接比数字,就像比两个班的考试成绩,但一个班的卷子满分100分,另一个班的卷子满分只有60分。你光看分数能比出谁学得好?
更合理的方式是看在真实世界里,用这些库写的程序,到底有多少内存安全漏洞被黑客利用了。
在这个维度上,Rust的优势非常明显。因为绝大多数Rust程序根本不写unsafe,整个程序的内存安全由编译器担保。而C/C++程序,每一行都可能藏着未定义行为,只是还没触发而已。
总结:两种哲学的碰撞
curl的开发者在做一件了不起的事:用C语言写出尽可能安全、健壮的库。但问题是,全世界几百万个用了curl的C程序,依然可以轻轻松松地“拿错姿势”——比如传个空指针——就搞出内存漏洞。curl的作者再厉害,也没法阻止你犯这种错。
Rust的思路是:既然你们人类总会犯错,那我从语言层面就不给你犯错的机会。API能设计成安全的就设计成安全的,设计不安全的必须显式标记unsafe,强迫你过脑子。
这不是说Rust没有内存漏洞。编译器也有bug,unsafe代码也可能写错。但区别在于,Rust把“安全”做成了默认状态,把“不安全”做成了需要主动跨过的一道门槛。C/C++正好反过来,安全全靠程序员的人肉责任心。
下次你再看到有人拿Rust和C/C++的CVE数量比来比去,你可以问他一句:你们家报CVE的标准,是把“API可能被用错”也算进去,还是只算“已经有人用错了”?