可能比 C 不是 年经验资深安全从业者 Rust 更不安全 !25 解药 (可能性不是特别大)

可能比 C 不是 年经验资深安全从业者 Rust 更不安全 !25 解药 (可能性不是特别大)

本文作者 John Viega 拥有 25 年安全和开发经验,喜欢研究编译器,是 AES-GCM 加密算法的联合作者,曾是 McAfee 首席安全架构师、SaaS 业务部门的 CTO。并在纽约大学担任兼职教授至今。撰写了多本关于安全的书籍,包括《安全神话》、《构建安全软件》和《使用 OpenSSL 进行网络安全》等。是 IEEE 安全与隐私杂志的前主编。

他的这篇文章围绕行业长期存在的,关于 C/C++ 安全性不足及其应该被 Rust 等语言替代的话题展开了讨论,并发表了一些独特而有价值的观点。原文内容较长,译文删减了一部分不重要的内容以方便阅读。

前不久我又看到了一篇非常轻视内存安全性,觉得这方面没什么必要做改变的文章,然后我又看到一些安全专家对这篇文章回应说,想要保障安全、负起责任就得尽快放弃 C 和 C++ 才行。这篇文章就是我对这个话题的分析,我会尽可能覆盖所有方面,尽量让行业内的读者都能理解我的意思。

太长不看版本

一些安全人员已经怒气冲冲了

我曾看到一位安全人员和业务部门争吵不休,于是我问他:

人们每天都愿意承担风险。我们知道,只要我们外出,就有可能感染某种病毒。我们知道,只要我们上车,就有可能发生事故并丧命。

但众所周知,我们倾向于大大高估或低估自己的风险水平。

一般来说,安全行业可能认为普通人大大低估了风险。过去这个观点可能是正确的,但现在的世界已经不同了。曾几何时,大多数行业都大大低估了风险,网络连接可能被轻易篡改、代码可能被轻易攻破,没有例行补丁,也没有广泛的隔离或其他有效的权限机制。

但由于整个安全行业的辛勤工作,其他科技界从业人员最终承认他们错了。安全行业产生了巨大的影响力,从硬件架构到网络协议,再到编程语言设计都开始重视安全性。这条路走下来并不轻松,因为我们常常无法真正理解来自其他领域的专业观点。

如果我们能牢记这一点,行业就能取得更快的进步,获得更好的信誉。我们需要这样做,因为正如我们所看到的,要做的事情还有很多,而且还有很多重要的变化根本无法快速实现。

内存安全问题有多严重?

内存安全往往被认为是最严重的漏洞类别,因为这个层面的漏洞经常可以获得最高权限。漏洞经常可以被远程利用,有时甚至不需要任何身份验证。

但有观点认为内存安全漏洞数量众多,老练的攻击者很容易找到并利用它们,这种说法是错误的。

世纪初的时候情况的确是那样,但现在不一样了。从安全角度来看,内存不安全代码的影响肯定还是非常大,但还没有高到你要顶着经济压力为它换成内存安全语言的程度。

风险状况已经变了

我坚决承认相比 C/C++,其他语言本质上更安全;我只是在质疑“它们的安全性到底强多少?尤其考虑到我们已有的很多安全措施时。”

世界发生了许多变化,直接影响了风险水平(双向),包括:

考虑上述情况:

为什么对比不同语言的 CVE 数量容易误导人呢?举个例子——Linux 内核最近正式获得了为自己的代码库发布 CVE 的能力。但在他们看来,任何错误都可能带来他们不了解的安全隐患,因此 Linux 内核中发现的每一个错误现在都有自己的 CVE,尽管它们大多不是可利用的内存问题。

漏洞的利用可能性降低了

一方面,找到好的漏洞变得越来越难,这一事实并不重要,因为如果你不再用 C,这类问题就不再是问题了。

另一方面,漏洞研究人员比从前更努力,找到的漏洞却少很多,这表明实际的风险比以前更低了(尤其是在有良好的补偿控制措施的情况下)。如今,我倾向于认为许多 C 程序中存在问题的几率很高,但如果你有正确的设计,并花钱请合适的人来审查你的代码,那么找到下一个错误的经济成本就足够高了。

我刚开始进入这个领域时,工作往往很简单。如果你能找到一个数组形式的局部变量,那么你很可能可以诱使程序在数组之外写入。而且内存布局很容易预测,因此那时候我们很容易利用这种情况。

具体来说,局部变量经常保存在程序栈中。当你进入一个函数时,数据会被推送到堆栈上,你退出时,数据会从堆栈中弹出。这与函数调用后仍存在的长期内存分配(堆存储)是不一样的。

例如,过去我们经常看到这样的代码:

voidopen_tmp_file(char *filename){char full_path[PATH_MAX] = {0,};strcpy(full_path, base_path);strcat(full_path, filename);// Do something with the file.}
复制代码

对外行来说上述代码可能看起来没什么问题。它创建一个数组,该数组初始化为零,其大小为操作系统支持的路径的最大大小。然后,它将某个 base_path 名复制到该数组中,最后将文件名附加到该数组的末尾。但即使在今天的系统上,如果攻击者可以控制这个函数或 base_path 的输入,也很容易让程序崩溃。

部分原因是 C 不跟踪内容的长度。在 C 中,strcpy 逐字节复制,直到遇到值为 0 的字节(所谓的 NULLbyte)。类似地,strcat 的工作方式是向前扫描到 full_path 中的第一个空字节,并从 filename 中复制,直到找到 NULL。在任何情况下,这两个函数都不会根据 fullpath 的长度来检查它们正在做什么。因此,如果你可以传入超过 PATH_MAX 减去 len(base_path) 个字符,你就能写入超出缓冲区末尾的数据。

过去,程序栈将自己的运行时数据与用户的数据混合在一起,这就是传统的栈溢出如此容易实现的原因所在。

每次调用一个新函数时,堆栈都会获取程序应该返回的位置的内存地址。因此一旦你发现了其中一个条件,你要做的就是制作一个利用漏洞的有效载荷(你的恶意数据),让它覆盖这个返回地址,用指向你自己的有效载荷的指针替换它……载荷里一般还包括可以执行任何操作的可执行指令。有效载荷的可执行部分通常称为 shell 代码,尽管获得交互式登录权限(即 shell)不一定是你的目标。无论用哪种方式,当漏洞成功被利用时,攻击者往往能够从这里运行他们想要的任何代码。

从技术角度来看,当时最复杂的东西是 shell 代码本身,因为它一般至少需要汇编级知识。但你用不着自己编写 shell 代码,有很多现成代码可用。

为什么不总是执行边界检查?

好问题。有人可能会认为,我们只需让编程语言每时每刻都生成代码来检查所有访问边界,就能解决问题。

但如果到处都做代码检查,绝对会对性能产生显著影响,而且肯定会有些领域是无法忽视这种影响的。

例如,如果你是 CDN,并试图以经济高效的方式处理大量连接,那么代码检查的额外硬件成本很可能是无法承受的。

而且,用 Python 编写的单个应用程序经常“足够快”,但如果 Python 运行的每一段代码都经过完全的边界检查,它还会那么快吗?

我们使用的大多数软件都大量使用 C 或 C++ 编写的底层系统代码。不仅操作系统是用这样的语言编写,常见的编程语言在运行时利用的许多库也是如此。

当然,你可以“用 Rust 重写它”。但即使我们应该这样做,显然这也是一段漫长而艰巨的旅程。

请注意,Rust 能够接近 C 的速度,部分原因是编译器可以在编译时“证明”它可以跳过大多数边界检查。

实际上,基于 C 设计 API 并不难,严格使用的话同样可以避免内存错误,同时最大限度地减少生成的运行时代码。例如,在上面的例子中,很容易为字符串提供一个不一样的 C API,始终跟踪长度并进行全面检查。我们可以为数组和其他数据类型提供类似的 API。我们甚至可以为指针提供这样的严格性。

这实际上是 C++ 做的很成功的事情。从 API 文档来看,它确实很容易做到永远不出现内存问题。

但实际上,经济因素通常是软件领域最重要的考虑因素,经济上的投资更关心的是软件使用者的体验(比如说有些情况下使用者最关心的是性能)。

因此,如果你正在编写底层系统代码,那么它必须有广泛的适用性,并且在大规模使用的场景中成本不能很高。所以我们需要权衡成本和风险,经济因素无法被忽略。

越界内存错误:缓解措施的发展历史

我们应该愿意接受多大的风险这个问题,引出了下一个问题“我们目前接受多大的风险?”

因为如果答案是“不多”,那么我们就需要考虑是否值得付出代价来增加边界检查。

实际的风险水平很难准确量化。共识是,世纪初的 C/C++ 程序绝对满身筛子。但漏洞缓解措施经过了四分之一世纪的发展,世界已经完全不同了。证明一个漏洞可以被利用变得非常困难,标准确实越来越高了。

拿前面提到的内存漏洞代码示例来说,虽然它 的确 是内存错误,但这并不意味着它很容易被利用,连有可能被利用都不一定。尽管上面的代码很糟糕,但 StackGuard 早在 1998 年就很好地解决了这个问题。其基本思想是,当程序启动时,选择一个至少 64 位的随机数。每次调用函数时将其推送到堆栈上。然后,每次从函数返回时,检查随机金丝雀值是否完好无损。如果不是,则崩溃而不是返回。除非程序以某种方式泄露了它的金丝雀,否则漏洞就不会被轻松利用了。

软件开发社区(包括“坏人”和漏洞研究社区)不得不更加努力地研究绕过技术,但如果情况合适,他们至少会找到一些可以绕过某些缓解措施的情况。例如,如果内存是动态分配的,上述缓解措施就不起作用,因为这块内存是单独保存在堆中的。

当然,程序不会将函数返回地址保存在堆中。但许多真实程序,尤其是使用 C++ 动态调度的程序,会将指向函数的指针保存在堆中,并使用它们动态选择要调用的函数。

地址空间布局随机化(ALSR)是一种更有效和广为人知的防御措施,它是在操作系统级别实现的。基本上,每次程序启动时,操作系统都会尽可能随机化数据所在的位置。有了 ASLR,如果注入了足够的随机性,攻击成功的概率将非常低,可能需要像宇宙中的原子一样多的尝试次数才能成功。

你可能会问两个重要的问题:

对于问题 1,每个线程一个堆栈不仅更容易实现,而且通常速度更快,因为硬件通常会直接支持程序栈。最后,虽然进程有虚拟地址空间可以保护其免受其他进程的影响,但在一个进程中,进程中的任何代码都可以寻址进程中的任何内存单元。

不过,注入随机性还是很有价值的。例如,在堆溢出中,函数指针是诱人的目标。将所有函数指针存储在静态分配的表或单独的内存堆中,绝对比将函数指针随意散布在任何地方的典型方法要好。

至于第二个问题,在系统级别上绝对可以防止代码在堆栈或堆外执行,这是一种非常值得采取的缓解措施。但是,某些环境(包括某些完整的编程语言)需要使用其中一种区域来实现自己的动态功能(例如 lambda 函数,它们是闭包)。不过对于大多数程序而言,这种缓解措施基本上是免费的,并且进一步提高了安全性。

如今,当出现内存问题时,攻击者通常不能指望自己可以直接运行代码。但想象一下你正在攻击用 Python 编写的程序,你能以某种方式利用 Python 底层 C 实现中的内存错误来写入内存中的任意位置。在底层,Python 实现了一个虚拟机。堆或堆栈中可以存在一些“指令”,这些指令由 Python 的内置代码检查,并且该代码根据指令执行不同的操作。

事实证明,当我们谈论内存“不可执行”时,实际上指的只是直接在底层系统处理器上执行的内容,而不是在应用程序级虚拟机中发生的事情。因此,即使你攻击的程序的可执行代码段不可写,并且你可以写入的所有数据都不可执行,你仍可以更改控制可执行代码执行操作的数据。

作为攻击者,如果没有虚拟机,你可以使用一种称为“返回导向编程”(又名 ROP)的技术自己创建一个。基本上,你可以利用内存错误尝试整理程序的数据,让它在程序的内存中跳转,执行你希望它执行的操作(通常目的是让它生成登录 shell,然后你可以合法地再次运行任何你想要的操作)。

ROP 不简单,因为它通常要求攻击者在堆栈和堆上整理数据,这本身就很难。添加地址布局随机化后,你会发现大多数涉及越界写入的内存错误实际上都难以利用,通常需要将多个错误链接在一起,并且往往还需要应用 ROP 才能用上漏洞。

最近,英特尔推出了控制流完整性(CFI)这个选项来显式阻止 ROP。我们说过将返回地址移出堆栈通常是没有意义的。英特尔认为让大家在实践中不再这样做太难了,但相反,如果将返回地址 复制 到一个影子堆栈上;然后,当函数返回时,它会确保返回位置是一致的——这显然可以有效防止堆栈溢出。

如果攻击者不去直接写入堆栈怎么办?例如,使用 ROP 时,人们通常只会以一种导致跳转失败的方式操纵数据,这将运行他们喜欢的代码。当该代码遇到“返回”语句时,它可能会返回到 CFI 期望的位置上。但 CFI 还可以验证调用站点。ROP 经常会跳转到函数中间,而 CFI 可以阻止这种情况。而且,它可以确保函数只从应该被调用的地方被调用。

CFI 不会阻止我们对 Python 虚拟机的攻击。但对于没有嵌入虚拟机的程序,使用 CFI 很可能会让 ROP 式攻击变得更加困难。

总之,虽然我们可能无法很好地量化现代缓解措施阻止了多少百分比的内存漏洞,但如果程序应用了所有容易获得的系统缓解措施,很可能很多这样的错误根本就无法利用了(大多数应用程序中的许多缓解措施都是默认启用的,但 CFI 足够新,并且没有广泛使用,有些地方没有应用这些缓解措施)。

的确也有水平很高、拥有创新精神的专家可以绕开缓解措施,但他们中的大多数要么会向政府提供漏洞信息,要么会负责任地披露漏洞,这通常意味着如果你能及时打补丁,风险就可以得到很好的缓解。

我预计随着时间的推移,硬件平台将继续提高标准。如果我们足够幸运,再过十年,这些缓解措施的效果甚至可能达到与完整边界检查一样好的水平,但成本却要低得多。而在此之前,人们有理由相信,尽管这些问题确实令人担忧,但补偿控制方面的投资已经非常充足了。

其他内存错误

在 C 和 C++ 中,越界访问并不是唯一符合“内存错误”定义的情况。

这些语言需要用户手动承担分配和释放内存的责任。它们确实带有用于帮助用户管理内存的库,但与其他许多语言不同,你仍然需要决定何时以及如何释放堆内存。

除了纯粹的数组边界错误之外,还有许多问题,包括:

此类问题在代码中相当常见,因为我们通常很难推断何时释放内存,而手动执行这种操作的 C 程序员经常会出错。自动内存管理(例如垃圾收集)通常有助于解决大多数此类问题……除非你可以利用这种内存管理机制来发起攻击。确实,垃圾收集语言中的内存管理器往往非常复杂,并且有多个垃圾收集器出过几次严重漏洞,其中包括了大多数浏览器 JavaScript 引擎。

现代缓解措施

如上所述,C++ 已经做了很多工作来帮助程序员避开上述问题:

许多 C++ 程序员都从上述措施中受益。相比之下,C 程序员往往没有这些好处可以享用。虽然 C 标准与 C++ 非常像,但 C 在更改方面更加保守,并且不会像 C++ 那样添加很多细节。

C 程序员也有能用的:

但 C 更多将其定位为一种可移植的汇编语言。我们稍后会回到这个问题。

为什么你的观点如此偏颇?

对于局外人来说,本文目前的结论可能与你听到的“证据”相悖。例如,我听到很多人声称“大多数 CVE 都是由内存安全问题引起的”。我最近又读到一篇文章给出了一个有趣的统计数据:当时(2024 年 3 月),C/C++ 代码中有 61 个 CVE,但 Rust 代码中只有 6 个 CVE。

当然,所有这些数据都是真的。但它们并不能准确反映风险水平:

玩这个游戏的漏洞研究人员通常只用一个漏洞就能赚到普通技术工人一年以上的薪水。而且绝对有人每年能卖出不止一个这样的漏洞。

一般来说,如果我是购买漏洞的一方,对我来说最重要的考虑因素有:

有一些错误类型(比如命令注入攻击漏洞)会影响大多数编程语言,它们在上述许多方面得分都很高。但一个真正好的内存错误通常会得分更高。由于内存错误的固有价值,以及实际查找和利用此类错误的技术挑战,内存错误是漏洞研究界最负盛名的错误类型。因此,它们比更平凡的问题受到更多的关注。这意味着在其他语言编写的代码中很容易出现大量相当容易实现的漏洞,你更有可能看到这种漏洞出现在你正在使用的代码中,让你面临更大的风险。

人们为什么不换成其他选项呢?

到目前为止,我们已经看到 C 确实比任何高级语言都更容易受到内存错误的影响。虽然缓解措施相当有效,但它们还不足以成为人们不换语言的唯一原因。那么阻止人们切换语言的关键因素是什么?

为了讨论,我们会像经济学家一样假设人们是理性的。

首先,即使每个人都同意 C 应该消亡,这也需要很长时间。连 COBOL 应用程序都还在继续使用,很多公司会觉得替换 COBOL 程序要付出大量成本、面临很大风险,经济因素让这种语言继续存在下去。而 C/C++ 比 COBOL 更普及,近 50 年来它们一直是最重要的软件技术基石。

每个主流操作系统都是主要用 C 语言编写的。我们每天使用的大多数网络服务主要用 C 或 C++ 实现。在嵌入式系统中,使用 C 或 C++ 以外的语言几乎闻所未闻。即使在 Rust 领域,你也会发现一些 C。

其中一些原因包括:

如果你是谷歌或微软,你会抛弃几十年来一直表现得很强大的代码,并且相信替代方案可以涵盖过去几十年的所有极端情况吗?尤其是你还知道新方案并没有很好的知识库积累?我们今天运行的软件已经建立了一些信任度。当然,在糟糕的过去,Sendmail 很受欢迎,但漏洞百出,因为它不是防御性编写的。在 Unix 方面,Postfix 已经积极开发了 25 年,并且是用 C 编写的。虽然它的安全记录并不完美,但它一直很好用,并且从一开始就充分利用了最小特权原则。

尽管 Postfix 从一开始就更加注重安全,而且更容易配置和使用,但它花了至少十年(可能更长)的时间才基本取代 Sendmail。它不仅花了很长时间重新发现那些可能破坏业务的重要异常,还用了很久向担心业务中断的人们证明它足够成熟,不会造成这种风险。

因此,为了“用 Rust 重写它”,并成功取代 Postfix(更不用说 Exchange),我们必须:

在最好的情况下,这个过程也需要很久,结果要十年后才能看到。人们很难对一个长达十年的项目感到兴奋,因为它取代的东西并没有那么大的风险。上述论点也同样适用于大量用 C 编写的传统基础设施。

C 的长期优势

在我看来,Rust 走进内核是一项令人印象深刻的成就。多年来,许多人一直在游说,希望大家允许 C++ 进入内核,但这个目标从未实现。反对它的论点很简单——C++ 有太多的抽象。内核基本上位于软件栈的底部,不应产生任何不必要的成本,因此内核团队需要能够推理性能问题,这意味着他们需要能够看到 C 代码如何映射到生成的汇编代码上。

从经验上讲,Rust 在这方面确实更接近 C。然而,Rust 在这方面肯定 不比 C 好 ,而且在某些任务上 Rust 非常碍事。

许多任务足够底层,是真正的系统级任务,例如内存管理、设备驱动程序、公开新硬件功能等。是的,你可以在 Rust 中做这些事情,但与 C 相比这很费力,而且经常需要利用 Rust 的“不安全”功能,于是会承担同样的风险。那么为什么不用 C 来编写呢?

此外,就像 Linux 内核不包含标准 C API(因为它们在这种情况下没有意义;它们在需要时提供自己的内部 API)一样,Rust 不能使用自己的 API;它必须使用内核的。

我们使用的硬件架构在指令级别提供的内置安全性非常少。当然,即使是 C 的可怕的基本类型系统也比直接向架构写入代码要好得多。在任何现实软件系统中,如果你深入到最底层,总会有代码需要针对这些不安全的平台来编写。

此外,绝大多数嵌入式系统都只用 C。资源有限的不仅是 CPU:

C 在这个世界中蓬勃发展,这是 C 标准积极瘦身常用 API 的众多原因之一,它在这方面的努力和成果远超其他语言。

不幸的是,嵌入式世界往往不支持许多让 C 变得安全的常见缓解措施。从安全角度来看,它们还有其他缺点,例如没有足够的资源来做基础的加密工作,升级打补丁也没那么容易。然而,这些限制更多是商业权衡。大部分嵌入式软件出于某种原因都运行在低端硬件上,如果它们承担得太多,产品就没竞争力了。而且更强大的硬件需要更多的电量,对于可穿戴设备或其他可能需要长时间使用电池供电的设备来说,能耗比安全性可能更重要。而 C 语言实际上是唯一一种愿意正确服务此类环境的非汇编语言(而 C++ 是唯一一种在嵌入式领域真正有吸引力的替代选项)。

人们确实喜欢称 C 为“便携式汇编程序”。但在我看来,C 比汇编高级得多。只有无法用 C 正确完成(或无法轻松正确完成)时,我才会使用汇编语言,但这时一般也就涉及几条指令。当我不得不使用汇编语言时,我也会浪费很多时间,因为这种情况很少见(而且每个现代架构都非常复杂),我不得不在文档上花费更多时间。

C 比汇编语言更高级,但比其他系统编程语言(C++ 和 Rust)都更底层。它基本上介于两者之间,抽象出了许多平台可移植性问题,但仍然足够基础。如果你了解架构和编译器,就可以通过查看 C 源代码来可靠地预测它将生成什么代码。

有很多任务最好在这个级别完成。用 Rust 完成此类任务不会像汇编语言那样困难,但无论如何,换掉“不安全”的代码块仍需要很多额外的工作。对此类“不安全”块的替代需求越多,越有可能亏本。

我认为我们最好使用有意义的轴来对语言进行分类,尽可能定义它们。通常,使用 Rust 或 C 的人们非常关心性能。在我看来,轴的一端是性能,另一端是用户体验。按照这个方法来分:

C 和汇编语言在内存安全性方面肯定处于绝对劣势,但正如我们一直在讨论的那样,安全性差距并不像人们想象的那么大,因为:

也就是说,我们很难想象在未来几年内,你选择的操作系统和浏览器会完全用 Rust 重写,同时完全解决安全隐患。但可以肯定的是,人们会付出大量努力慢慢转向这一方向。我们已经看到,不仅 Linux 接受了 Rust,微软也非常重视它。

语言的过早优化

我一直在说,C 在生态系统中的地位很有说服力,而且它不会很快消失,因为没有合适的东西可以取代它。我认为更令人担忧的是, 许多 程序员系统性地高估了性能的重要性。我的观察:

我认为任何诚实的系统程序员都会说过早优化是一个巨大的陷阱,而且他们已经多次陷入其中。人们在估计性能(和风险)水平方面表现非常差,对于那些会高估性能需求的人来说,他们最终会选择系统语言(或 C,尤其在他们不怎么在乎风险的时候)。事实上,在很多情况下,即使是 Python 也很好用。例如,Dropbox 的大多数关键任务都用 Python,但表现也非常出色。我认为作为一个行业,我们应该更关心人们在性能(而非安全性)方面做出的错误选择,因为:

这并不是说系统语言甚至预汇编语言不是正确的选择。我只是认为我们应该经常思考这个问题,并让人们客观地思考影响他们决策的方方面面:

我们依旧需要学习 C 语言的人才

在人们选择系统语言时,大多数情况下我们应该推荐他们选择更高级的语言,而不是在没有数据的情况下过早为性能做优化。然而,整个行业确实需要继续以某种方式培养 C 程序员,因为:

我最后一点的意思是,50 年后,我们仍然需要能够成为底层架构专家的人才,帮助软件开发人员利用各种硬件改进。

在过去 30 多年里,大量新人涌入编程领域,其中一些人已经走上了这条道路。但是:

C 语言虽然很可怕,但相对其他语言而言,它绝对是从编程角度理解底层架构的更好的基石。如果没有这个垫脚石,那么愿意一路向下推进来促进软件利用硬件改进的专家会快速减少,因为学习基础知识和完成简单任务所需的努力程度,最终会高到让更多感兴趣的人们要么认为他们没有能力,要么不想经历那些痛苦,然后放弃。人类是以目标为导向的生物,为了我们自己的心理健康,我们倾向于不追求那些自己认为太难实现的目标。

如果有一种预汇编语言可以纠正 C 语言的一些最严重的错误(在我看来最大的错误是 C 语言对数组的处理),并且在开发过程中始终执行尽可能多的分析(不仅是通过 Clang 项目提供的清理程序,还包括 Valgrind 等运行时安全工具),我会感到更安心。

Rust 目前是最接近这种替代品的语言,但在我看来,过分强调函数式范式会影响它更好地阐明底层的冯·诺依曼架构。

人们不选择 Rust 的理由

总体而言,人们谈论的理由有:

我意识到 Rust 在很短的时间内就变得非常受极客欢迎,这是有充分理由的。我非常欣赏 Rust 的一些成就,尽管如此,我已经亲身感受了上面列出的一些因素。三年前,我读了一篇写得很差的学术论文,想看看是否有人已经实现了它。结果我发现了同一篇论文的两种不同实现,但只有两种,而且它们恰好都是用 Rust 编写的。这两种实现都非常简洁,很难理解。如果我不知道它们实现了相同的算法,我永远也不会猜到它们做的是一样的事情,因为它们使用了截然不同的习语,而且看起来一点也不像。

我已经编写了足够多的 Rust 代码,并与许多 Rust 开发人员交流过,我可以自信地说,相当多的人会发现它很难采用,而且需要很长时间才能感觉到它与他们目前选择的语言相比一样高效。

谷歌的一篇博客文章试图反驳 Rust“难以学习”的论点。我读过这篇文章,但除了他们没有分享任何真实数据之外,这篇文章还存在一些问题:

从我所看到的情况来看,Rust 主要只在极客圈子流行。我觉得 Rust 明确针对极客,也就是那些对数学函数和递归有直观理解的人们打造,这一事实让我无法接受它。

我希望编程更加平等。我认为世界上有很多聪明、能干的人不在我们的专业圈子里,如果他们能更轻松地将想法转化为计算机代码,就能为世界做出惊人的贡献。我非常希望降低编程的入门门槛(在我看来,Python 在这方面为世界做出了最大的贡献)。从根本上讲,Rust 是一种很棒的语言,熟悉它的人应该在有意义的地方使用它。然而,我认为人们应该尽量更诚实地对待经济学:

目前,Zig 及其生态系统在满足许多系统编程需求方面远远落后 Rust,但前者对问题采取了更加平等的态度。相比 Rust,Zig 将是一种更容易被大多数使用编译甚至脚本语言编写程序的人们接受的语言。

部分原因是 Rust 的根基牢牢扎根于函数式编程世界,其基础原则是围绕数学的纯函数构建的。有些人可以凭直觉理解这些东西,但他们往往是有着深厚的数学背景。

相比之下,Zig 依旧是一种常规的命令式语言。它的基础原则本质上是“给某人详细的指示”,小孩都能理解。事实上,我见过的每一个成功的预编程项目(例如 Scratch)都是命令式的。

函数式编程被普遍认为是晦涩难懂的,并且在过去 65 年中它都没能流行起来的事实表明,每类编程语言都应该有一种强大的过程语言。

但是,我对函数式范式和函数式语言大多持积极态度,因为它们确实有自己的巨大优势,尤其是它们可以更好地鼓励程序员编写更可靠、更易分析的代码。函数式范式的价值非常大,我相信在每个抽象级别(直到预汇编语言)上都应该有一个好的、流行的函数式语言。

另一方面,我认为面向对象编程范式的实用性要小得多,它最好完全消失,或者最多成为一个不那么突出的特性。

Rust(目前)可能存在

比 C 更大的安全风险

当知名的安全思想领袖发表“用非内存安全语言构建关键应用程序是不负责任的”这样的言论时,我感到很失望。这不仅仅是因为他们忽略了经济复杂性,将复杂的决策简单化,还因为即使我们只考虑安全性也能发现,C 中的内存安全问题虽然很严重,但风险并不一定比其他语言更严重。

具体来说,C 程序一般只有少量外部依赖项,而这些依赖项往往是最常用的软件(例如 C 标准库)。其他多数语言中,程序员更容易利用其他人的工作成果。从商业角度来看这是一件好事。但从安全角度来看,更多的依赖项不仅会增加我们的攻击面,还会让我们更容易受到供应链攻击。xz 事件是此类供应链攻击的最新知名案例之一,但它绝非孤例。

Rust 很容易引入外部依赖,就像在 JavaScript 生态系统中一样,它似乎鼓励人们做出来大量很小的依赖项。这使问题监控和管理起来更困难了。Rust 的情况还比大多数语言更糟糕,因为核心 Rust 库(Rust 项目正式维护的主要库)大量使用第三方依赖。这个项目需要承担起该负的责任,监督他们使用的库。对我来说,这一直是软件中最大的风险之一。我可以编写颇具防御性的 C 代码,但我很难信任自己使用的任何依赖项,更不用说大规模使用了。

正确保护依赖项供应链比编写安全的 C 代码要困难得多。在这方面,C 比 Rust 好很多,但并不是特别好。部分原因是 C 标准库用得没那么多。编写大量 C 代码的程序员总会自己构建一些代码,维护上几十年。

我个人一直更关心尽量减少依赖项的主题,而非缓冲区溢出。有一些简单的方法可以尽可能减少内存安全问题,而且在大多数应用程序中它们并不难使用。但深入研究每一个依赖项?即使是供应链安全领域的从业者迄今为止做出的最大努力,也无法很好地帮助我们应对最近的 xz 事件等攻击。一旦开发人员建立了对某些下游依赖项的信任,那么以一种可能被视为意外错误的方式引入后门并不难。比如 xz 事件中,后门并没有直接进入源代码树,因此它更像是一个后门。但我们早就知道,隐秘的后门与错误是无法区分的。尽管我们比以前更加注重同行评审文化,但很多“经过评审”的代码并没有得到那些严谨的人们的充分审查。

此外,代码审查比编写要难得多,这也是我不指望在不久的将来使用基于 LLM 的代码生成工具的原因之一——它将程序员变成了编写需求的“产品经理”,以及代码审查员。目前,我觉得“只”做一名工程师更容易。

无论如何,依赖关系越多,隐含信任圈就越大,攻击面就越大,你承担的供应链风险就越大。这使得 Rust 在供应链安全方面的风险特别大,而 C 在这方面得分相当高。考虑到所有经济因素,选择 Rust 可能还是更明智的,但我认为安全性的理由还不够有说服力。我认为 Rust(以及几乎所有编程语言)如果能有自己的标准库,那就再好不过了。它们应该引入所有依赖项,并愿意承担责任。

此外,我一般会主张语言将更多功能纳入其标准库,尽管最近的趋势恰恰相反。是的,从安全角度来看,这在技术上增加了你的攻击面。但事实并非如此:

Rust 这样的语言应该对人们可能需要的功能负责,尤其是当安全性被视为人们使用它的主要动机之一时。Go 和 Python 这样的语言有丰富的标准库,由语言维护者负责,这实际上是最好的情况。

的确,Python 已经变得如此流行,以至于很多人都使用外部依赖项,而且有几种流行的包管理器。但从供应链的角度来看,它在这方面还是比 JavaScript 强。

建 议

虽然我认为如今供应链问题可能让 C 比 Rust 更胜一筹,但 Rust 很容易让这种优势消失。当这种情况发生时,C 将面临内存管理方面的担忧。我的目的不是要说 C 比 Rust 更好,而是要表明围绕语言选择的决策远比人们情绪化的结论要复杂许多。

对于团队

请注意,许多 C 开发人员选择使用不安全原语的主要原因不是他们缺乏安全风险方面的教育,而是他们通常有一些大型依赖项(例如 OpenSSL 或其他加密库),并且不清楚如何以简单、可移植的方式将 Boehm 垃圾收集器应用于这些第三方库。

对于安全行业

对于其他行业

一般来说,软件行业的其他行业应该与安全行业合作应对风险。具体来说:

反 馈

我很乐意讨论这个话题或接受任何反馈。正如我所说,我很乐意继续学习和重新评估我的观点。所以请联系我,但我不会很快回复。

原文链接:

声明:本文来自用户分享和网络收集,仅供学习与参考,测试请备份。