无畏安全
去年,Mozilla 在 Firefox 中发布了 Quantum CSS,这是对 Rust(一种内存安全的系统编程语言)进行 8 年投资以及一年多时间用 Rust 重写主要浏览器组件的成果。到目前为止,所有主要浏览器引擎都是用 C++ 编写的,这主要是因为性能原因。然而,高性能意味着巨大的(内存)责任:C++ 程序员必须手动管理内存,这会导致各种漏洞。Rust 不仅可以防止这些类型的错误,而且它采用的技术还可以防止 数据竞争,让程序员可以更有效地分析并行代码。
在接下来的几周内,这个由三部分组成的系列文章将探讨内存安全和线程安全,并以一个关于重写 Firefox 的 CSS 引擎用 Rust 带来的潜在安全优势的案例研究作为结尾。
什么是内存安全
当我们谈论构建安全的应用程序时,我们通常会关注内存安全。非正式地说,这意味着在程序的所有可能执行过程中,都不会访问无效的内存。违规行为包括
- 使用后释放
- 空指针解引用
- 使用未初始化的内存
- 双重释放
- 缓冲区溢出
有关更正式的定义,请参阅 Michael Hicks 的 什么是内存安全文章 和 内存安全的含义,一篇形式化内存安全性的论文。
这些内存违规行为会导致程序意外崩溃,并且可以被利用来更改预期行为。与内存相关的错误可能导致信息泄漏、任意代码执行和远程代码执行。
内存管理
内存管理对于应用程序的性能和安全至关重要。本节将讨论基本的内存模型。一个关键的概念是指针。指针是一个存储内存地址的变量。如果我们访问该内存地址,那里将有一些数据,因此我们说指针是对该数据的引用(或指向该数据)。就像家庭地址告诉人们在哪里找到你一样,内存地址告诉程序在哪里找到数据。
程序中的所有内容都位于特定的内存地址,包括代码指令。指针使用不当会导致严重的安全性漏洞,包括信息泄漏和任意代码执行。
分配/释放
当我们创建一个变量时,程序需要在内存中分配足够的空间来存储该变量的数据。由于每个进程拥有的内存是有限的,我们还需要一种方法来回收资源(或释放它们)。当内存被释放时,它将变得可用以存储新的数据,但旧的数据仍然存在,直到被覆盖。
缓冲区
缓冲区是内存中一个连续的区域,用于存储相同数据类型的多个实例。例如,短语“我的猫是蝙蝠侠”将存储在一个 16 字节的缓冲区中。缓冲区由起始内存地址和长度定义;因为存储在缓冲区旁边内存中的数据可能无关,所以务必确保我们不会读取或写入缓冲区边界之外的数据。
控制流
程序由子例程组成,这些子例程按特定顺序执行。在子例程结束时,计算机跳到一个存储的指针(称为返回地址)指向应该执行的代码的下一部分。当我们跳到返回地址时,会发生以下三种情况之一
- 进程按预期继续(返回地址未被破坏)。
- 进程崩溃(返回地址已更改为指向不可执行内存)。
- 进程继续,但结果与预期不同(返回地址已更改,控制流已更改)。
语言如何实现内存安全
我们经常将编程语言放在一个 频谱 上。在一端,像 C/C++ 这样的语言效率很高,但需要手动内存管理;在另一端,解释型语言使用自动内存管理(如引用计数或垃圾回收 [GC]),但性能会受到影响。即使是具有高度优化的垃圾收集器的语言也无法与 非 GC 语言的性能 相比。
手动
一些语言(如 C)要求程序员手动管理内存,指定何时分配资源、分配多少资源以及何时释放资源。这使程序员能够非常细致地控制他们的实现如何使用资源,从而实现快速高效的代码。但是,这种方法容易出错,尤其是在复杂的代码库中。
容易犯的错误包括
- 忘记已释放资源并尝试使用它们
- 分配不足以存储数据的空间
- 读取缓冲区边界之外的数据
智能指针
智能指针 是一个指针,它包含额外的信息,有助于防止内存管理不当。这些可以用于自动内存管理和边界检查。与原始指针不同,智能指针能够自行销毁,而不是等待程序员手动销毁它。
没有唯一的智能指针类型——智能指针是任何将原始指针包装在一些实用抽象中的类型。一些智能指针使用引用计数来计算有多少变量正在使用变量拥有的数据,而另一些智能指针则实现范围策略来将指针的生命周期限制在特定范围内。
在引用计数中,当对对象的最后一个引用被销毁时,对象的资源就会被回收。基本的引用计数实现可能会遇到性能和空间开销问题,并且在多线程环境中难以使用。在对象相互引用(循环引用)的情况下,任何一个对象的引用计数都无法达到零,这需要更复杂的方法。
垃圾回收
一些语言(如 Java、Go、Python)是 垃圾回收的。运行时环境的一部分,称为垃圾收集器 (GC),会跟踪变量以确定哪些资源在表示对象之间引用的图中是可访问的。一旦对象不再可访问,它的资源就不再需要,GC 会回收底层内存,以便在将来重复使用。所有分配和释放都在没有显式程序员指令的情况下进行。
虽然 GC 可以确保内存始终有效使用,但它并非以最有效的方式回收内存。对象最后一次使用的时间可能比 GC 释放它的时间早得多。垃圾回收会带来性能开销,这对于性能关键型应用程序来说可能是不可接受的;它需要高达 5 倍的内存 来避免运行时性能损失。
所有权
为了同时实现性能和内存安全,Rust 使用了名为所有权的概念。更正式地说,所有权模型是 仿射类型系统 的一个示例。所有 Rust 代码都遵循某些所有权规则,这些规则使编译器能够管理内存,而不会产生运行时成本
- 每个值都有一个变量,称为所有者。
- 一次只能有一个所有者。
- 当所有者超出范围时,该值将被删除。
值可以在变量之间 移动 或 借用。这些规则由编译器的一部分(称为借用检查器)强制执行。
当变量超出范围时,Rust 会释放该内存。在以下示例中,当 s1
和 s2
超出范围时,它们都会尝试释放相同的内存,从而导致双重释放错误。为了防止这种情况,当值从一个变量中移出时,以前的所有者将变为无效。如果程序员随后尝试使用无效的变量,编译器将拒绝该代码。这可以通过创建数据的深层副本或使用引用来避免。
示例 1:移动所有权
let s1 = String::from("hello");
let s2 = s1;
//won't compile because s1 is now invalid
println!("{}, world!", s1);
借用检查器验证的另一组规则与变量生命周期有关。Rust 禁止使用未初始化的变量和悬空指针,这会导致程序引用意外数据。如果以下示例中的代码编译成功,r
将引用当 x
超出范围时被释放的内存——一个悬空指针。编译器会跟踪范围以确保所有借用都是有效的,有时要求程序员显式地注释变量生命周期。
示例 2:悬空指针
let r;
{
let x = 5;
r = &x;
}
println!("r: {}", r);
所有权模型为确保适当访问内存提供了一个坚实的基础,从而防止了未定义的行为。
内存漏洞
内存漏洞的主要后果包括
- 崩溃:访问无效的内存会导致应用程序意外终止
- 信息泄漏:意外公开非公开数据,包括敏感信息,如密码
- 任意代码执行 (ACE): 允许攻击者在目标机器上执行任意命令;如果这种攻击可以通过网络进行,我们称之为远程代码执行 (RCE)。
另一种可能出现的问题是 内存泄漏,它发生在内存被分配但程序使用完后没有释放。这种方式会导致所有可用内存被耗尽。如果没有剩余内存,合法的资源请求将被阻止,从而导致拒绝服务攻击。这是一个与内存相关的故障,但无法通过编程语言来解决。
大多数内存错误的最佳情况是应用程序会无害地崩溃,但这并不是一个好的最佳情况。然而,最糟糕的情况是攻击者可以通过漏洞获得对程序的控制权(这可能导致进一步的攻击)。
错误使用 Free(使用后释放,双重释放)
这种漏洞子类发生在某些资源被释放后,但其内存位置仍被引用。它是一种 强大的利用方法,会导致越界访问、信息泄露、代码执行以及 更多问题。
垃圾收集和引用计数语言通过仅销毁不可达对象来防止使用无效指针(这可能会导致性能下降),而手动管理的语言特别容易出现无效指针使用(尤其是在复杂的代码库中)。Rust 的借用检查器不允许在存在对对象的引用时销毁对象,这意味着此类错误在编译时会被阻止。
未初始化的变量
如果在初始化之前使用变量,它包含的数据可能是任何东西——包括随机垃圾或以前丢弃的数据,从而导致信息泄露(这些有时被称为野指针)。通常,内存管理语言在分配后使用默认初始化例程来防止这些问题。
与 C 语言一样,Rust 中的大多数变量在赋值之前都是未初始化的——与 C 语言不同,在初始化之前不能读取它们。以下代码将无法编译
示例 3: 使用未初始化的变量
fn main() {
let x: i32;
println!("{}", x);
}
空指针
当应用程序对一个事实为空的指针进行解引用时,通常意味着它只是访问了会导致崩溃的垃圾数据。在某些情况下,这些漏洞会导致任意代码执行 1 2 3。Rust 具有两种类型的指针,引用 和 原始指针。引用可以安全地访问,而原始指针可能会出现问题。
Rust 通过两种方式防止空指针解引用
- 避免可空指针
- 避免原始指针解引用
Rust 通过用特殊的 Option
类型 替换可空指针来避免它们。为了操作 Option
中可能为空的值,语言要求程序员明确处理为空的情况,否则程序将无法编译。
当我们无法避免可空指针(例如,在与非 Rust 代码交互时)时,我们可以做什么?尝试隔离损坏。任何对原始指针的解引用必须在不安全块中进行。这个关键字 放宽了 Rust 的保证,以允许某些可能导致未定义行为的操作(例如对原始指针进行解引用)。
缓冲区溢出
虽然此处讨论的其他漏洞通过限制对未定义内存的访问的方法来防止,但缓冲区溢出可能会访问合法分配的内存。问题在于缓冲区溢出不适当地访问了合法分配的内存。与使用后释放错误类似,越界访问也可能存在问题,因为它访问了尚未重新分配的已释放内存,因此仍然包含应该不再存在的敏感信息。
缓冲区溢出仅仅意味着越界访问。由于缓冲区在内存中的存储方式,它们通常会导致信息泄露,其中可能包括敏感数据,例如密码。更严重的情况可以通过覆盖指令指针来允许 ACE/RCE 漏洞。
示例 4: 缓冲区溢出(C 代码)
int main() {
int buf[] = {0, 1, 2, 3, 4};
// print out of bounds
printf("Out of bounds: %d\n", buf[10]);
// write out of bounds
buf[10] = 10;
printf("Out of bounds: %d\n", buf[10]);
return 0;
}
防御缓冲区溢出的最简单方法是始终在访问元素时要求边界检查,但这会增加 运行时性能开销。
Rust 如何处理这个问题?Rust 标准库中的内置缓冲区类型要求对任何随机访问进行边界检查,但也提供迭代器 API,可以减少对多个连续访问的这些边界检查的影响。这些选择确保对这些类型的越界读取和写入是不可能的。Rust 推广了会导致边界检查仅发生在程序员几乎肯定必须在 C/C++ 中手动放置它们的地方的模式。
内存安全只是成功的一半
内存安全违规会使程序容易受到安全漏洞的攻击,例如意外数据泄露和远程代码执行。有各种方法可以确保内存安全,包括智能指针和垃圾收集。你甚至可以 正式证明内存安全。虽然某些语言已经接受了较慢的性能作为换取内存安全的代价,但 Rust 的所有权系统同时实现了内存安全并最大程度地降低了性能成本。
不幸的是,当我们讨论编写安全代码时,内存错误只是故事的一部分。本系列的下一篇文章将讨论并发攻击和线程安全。
利用内存:深入资源
堆内存和利用
为乐趣和利润而粉碎堆栈
信息安全的类比
介绍使用后释放漏洞
6 条评论