本文探讨了使用Rust构建安全可靠系统的挑战,尽管Rust在内存管理和并发方面有优势,但仍存在内存泄漏、错误处理、线程安全、宏滥用和不安全代码等潜在漏洞。强调了在工程实践中,即使使用最佳实践,也可能出现bug,因此需要谨慎对待潜在的漏洞,并采取措施来最小化风险。
我们从 2014 年就开始使用 Rust 了。我们是 Rust 的忠实粉丝。但这并不意味着 Rust 是解决你所有问题的完美语言。安全漏洞有很多种形式。有些漏洞允许恶意行为者接管系统。另一些漏洞允许他们查看不应该能够看到的信息。还有一些较小的,但也同样关键,允许恶意行为者以相对较低的成本关闭服务。这种攻击被称为 DoS,即拒绝服务。系统关闭对真实的人来说代价高昂。如果系统在没有外部干扰的情况下关闭,情况会更糟。
前几天,以太坊的团队负责人 Péter Szilágyi 说,C 版本的 KZG 库在某些系统上崩溃了:
如果我们想构建安全且健壮的系统,它们需要考虑到崩溃的可能性,无论它们是用 C、Rust、Erlang 还是 Java 编写的。Rust 语言是目前用于构建高性能系统最常用的语言之一。
Rust 引入了一个关于内存管理的出色的新概念,并在编译时阻止了许多类型的错误。它可以防止你访问无效的内存位置、空指针、重复释放内存或使用已释放的内存。
这个概念背后的思想非常棒:当编译器可以完成这项艰苦的工作时,不要信任程序员进行内存管理。 这里需要付出的代价是编码会稍微困难一些。
如果你有长期运行的软件,例如 Web 服务器、区块链节点或类似的东西,那么崩溃意味着你的系统停止服务。
例如,如果你的节点收到来自公共网络的请求,那么当你崩溃时,你的节点就会停止服务。这是你的系统的一个漏洞。
弹性不仅来自处理流量和数据的单个程序,还来自监控它的周围系统以及其中的状态和错误管理。 在这种情况下,关键在于当你遇到意外故障时会发生什么。
内存泄漏是一种难以发现和解决的微妙错误。
当程序以不再需要的内存没有被释放的方式管理内存分配时,就会发生内存泄漏。
在 Rust 中,很难出现引用循环。 你可以使用 Rc<T> 和 RefCell<T> 来做到这一点。 Rust 不保证没有内存泄漏,即使在安全代码中也是如此。 处理引用循环很容易陷入泄漏,因为两个引用都不会被释放。
这种情况很难通过检查源代码来检测。
许多其他情况也可能导致内存泄漏,例如在异步代码中运行的函数(尤其是当你将它们与线程混合使用时)。
在长期运行的程序中,许多内存泄漏可能导致拒绝服务,因为整个系统可能会耗尽内存。
Rust 有一些错误处理的工具,将错误值编码在 Result 枚举中。 没有像其他语言那样的 exceptions 。 另一方面,Rust 有 panicking 的概念。

Panic 会终止正在运行的程序。
Rust 宁愿选择 panic 而不是未定义的行为,因为未定义的行为难以跟踪和调试。
话虽如此,Rust 中的 panic 通常发生在达到绝对不能发生的条件时。
Rust book 有一个关于 panicking 的章节:
https://doc.rust-lang.org/book/ch09-03-to-panic-or-not-to-panic.html#to-panic-or-not-to-panic
获得 panic 最明显(也可能是最常用的)方法是在 Result 为 Err 时解包它。
有时,panic 隐藏在无害的操作中,例如通过 [ ] 运算符按索引访问数组(当索引超出范围时)或执行数学运算(例如除以零)。
值得一提的是,std 有一些函数可以避免在这些操作中出现 panic。 例如, get() 通过索引访问元素,返回一个 Option 值,而不是 panic。
虽然 Rust 提供了“无畏并发”,但该语言并不能保证不会出现由并发引起的错误或安全问题。
并发是指在 CPU(在一个或多个核心中)的线程之间调度指令。 这种调度是任意的,我们将一种情况称为这些不同线程的原子指令的一种可能的执行顺序。
虽然我们有检查来保证每个线程都可以访问我们想要的数据,并且不会发生意外的内存共享,但我们仍然可以编写带有死锁或竞争条件的代码。
Rust 编译器无法(在编译时)检查你的多线程程序是否存在可能的死锁。 因此,Rust 不能保证你的程序不会陷入僵局。 在这种情况下,你的程序不会继续执行。
例如,我们可能有一个通道正在等待永远不会到来的数据,从而阻塞了它的线程。 虽然这很容易在一个线程中发现,但如果我们有多个线程使用多个通道和带有锁的共享数据,这可能会更难发现。 在并发中,这被称为饥饿。
引用 Rustonomicon Rust 不能阻止一般的竞争条件。 当你检查系统条件,然后根据该条件采取行动时,可能会发生典型的竞争条件。 这被称为 time-of-check to time-of-use (TOC/TOU)。 由于线程之间操作的交错,条件的状态可能会随着另一个线程的执行而改变。 因此,第一个线程采取的行动是无效的(换句话说,你使用旧信息进行决策)。
Rust 有一个强大的宏功能。 它们可以在某些功能过于严格的地方扩展语言的可能性。 例如,鉴于 Rust 具有强类型系统,因此函数的参数在数量和类型上都是固定的。 使用宏,我们可以有一个函数式调用,其中包含数量和类型的可变参数。 println! 就是一个完美的例子。
Rust 宏的一个良好特性是它们是卫生的。 这意味着宏的主体在宏本身的上下文中扩展和执行,而不会占用调用宏的代码片段的额外上下文。 此功能可以防止 C 程序中可能发生的危险和意外行为(并且难以调试),这是由于包含了其他变量。
话虽如此,滥用宏是有害的。 首先,宏会使编译时间变慢。 最糟糕的是,关于宏的不良实践可能导致难以理解代码。 实际上,它们向语言引入了新的“关键字”,并重新定义了一些规则。 你可以在同一个宏中接收多种类型这一事实可能会使代码的读者感到困惑。
Rust 中的 Unsafe 是打开非检查内存和变量大门的钥匙。 该语言的优势之一是借用检查器以及对内存使用方式的限制。 Unsafe 赋予了程序员这种能力,但也赋予了程序员责任。
确实在某些情况下别无选择。 在我们有 Rust 代码与 C 代码交互的情况下,鉴于 C 是一种“unsafe”语言(从 Rust 的角度来看),FFI 调用是 unsafe 的。
使用 unsafe 会使我们的代码更容易受到攻击(例如,访问未经检查的内存位置始终是危险的)。
Unsafe 代码块必须经过仔细审核。
工程不是一门科学。 即使采取了最佳实践,仍然可能发生错误。 但是,通过使用像 Rust 这样的语言,并注意潜在的漏洞,如 panic 情况和并发问题,我们可以最大限度地降低这些错误对我们的系统造成危害的风险。
重要的是要记住,我们都是人,并且可能会发生错误。 尽管如此,通过共同努力并交流彼此代码中的任何错误或问题,我们可以为每个人创建更安全、更健壮的系统。 因此,让我们继续合作,努力实现更好、更安全的编程实践。
- 原文链接: blog.lambdaclass.com/ple...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!