就像生活中许多伟大的事情一样,本文也是源于刁难。Reddit,更确切地说是r/programming,已经让我抓狂了。所以我的目标很简单:我想概要的介绍一下为什么Rust的unsafe关键字有效,而在C/C++中类似的方法却行不通。
本文最初发布于jam1garner的个人博客,由InfoQ中文站翻译并分享。
就像生活中许多伟大的事情一样,本文也是源于刁难。Reddit,更确切地说是r/programming,已经让我抓狂了。所以我的目标很简单:我想概要的介绍一下为什么Rust的unsafe
关键字有效,而在C/C++中类似的方法却行不通。
和其他许多人一样,我在日常工作中也用到C语言。它不是一种糟糕的语言,我喜欢它,就像我喜欢使用汇编或其他深奥的语言编码一样,我将其看成是一个有趣的、具有挑战性的谜题。我不能说我喜欢维护C语言代码,我也不认为任何人会喜欢长时间这样做。作为安全专家,我认为…在安全性方面,C语言有很多问题。每个人都知道,这不是秘密,我这里不是要告诫你,如果你不用Rust重写一切,一切就都会出问题。
让我们来看一个非常简单的C程序:
const char* get_name() {
return "jam";
}
int main() {
printf("name: %s\n", get_name());
return 0;
}
很简单。该函数返回一个字符串。现在,有同事修改了这个函数,它返回在运行时生成的名称。借助编程的魔力,我不需要更改对get_name
的任何调用!函数签名没有变!字符串还是字符串,一切都没问题:
int number = 0;
const char* get_name() {
number += 1;
char* name = malloc(10);
snprintf(name, 10, "jam%i", number);
return name;
}
int main() {
printf("name: %s\n", get_name());
return 0;
}
那些经验丰富的程序员可能会立刻注意到我的程序有问题。少一个free
!我真傻,内存漏了一地。我可真蠢。我还以为它不会对调用点造成破坏呢。
这是 C 语言的内存管理问题:这是一个你必须完成的沉默契约。如果幸运的话,你同事在函数上方留下了一条注释,告诉你该字符串的所有权会被传递给调用函数并由它负责释放。如果你不走运,你要么去读函数定义,要么就说“不管怎样,内存泄漏并不比释放后使用更糟糕”。
“等等,你刚才说的是‘所有权’吗?我还以为那是 Rust 的东西呢!”
不是这样的!所有权是一个非常普遍的概念,但在其他语言中,你必须自己跟踪它。(更糟糕的是,你会发现它不仅仅适用于内存管理……)
跑题了,让我们先回来修复下程序:
int number = 0;
const char* get_name() {
number += 1;
char* name = malloc(10);
snprintf(name, 10, "jam%i", number);
return name;
}
int main() {
char* name = get_name();
name[0] = 'J';
printf("name: %s\n", name);
free(name);
}
我添加了一些额外的功能,但你忽略就行。我们现在有free
了,我做了测试,一切正常。
const char* get_name() {
return "jam";
}
int main() {
char* name = get_name();
name[0] = 'J';
printf("name: %s\n", name);
free(name);
}
哦不。我同事还原了他的这次提交?
Segmentation fault (core dumped)
啊啊啊。我刚刚添加完所有的free
!
好吧,我不再白费口舌了。关键在于:沉默契约很糟糕,它们不可扩展。C 语言无法表示所有权,你甚至没有 RAII 来帮助你。对于那些不特别仔细的人:之前,我隐式地将const char*
转换为char*
,甚至像“嘿,你不能编辑这个数据”这样的观点都是用 C 语言编码的。当然,你会获得一个警告…如果编写函数的人正确地将返回值标记为const
…任何处理过比他们能记住的 API 更大的 C 代码库的人都遇到过这些静默契约。
当然,在这方面,RAII/ 智能指针等对 C++ 帮助很大,但是,当你需要安全有效地共享内存时,它们终究会失败。它有预防措施,但就像 C 语言在支持const
方面的失败一样,任何重要的用例最终都需要艰难地应对。就像 Rust 一样,它们还有…采用问题。几十年前的 C++ 应用程序不会使用智能指针,而转换可不是一项轻松的任务。
现在,为了避免这被认为是对 C 语言的抨击,我可能需要实际地谈谈 Rust!更重要的是,揭穿我最讨厌听到的对立观点:
当然,Rust 是“安全的”,但提供了
unsafe
关键字,而且大多数标准库都是unsafe
的!
我们需要讨论两件事:封装和局部性。
我相信,如果你了解面向对象编程,那么你一定听说过封装。如果没有,也没关系。封装是将数据隐藏在更大的分组中,只通过方法提供访问。从根本上说,在 Rust 中,这既是 OOP 原则的起点,也是终点。
在组织代码和构建抽象方面,它是非常有用的工具。如果你暴露太多,就会导致抽象泄漏,这会妨碍你在以后更改实现。在我们的 C 语言示例中,由于我们的内存管理没有被抽象出来,所以我们不能更改某些实现细节,比如数据存储在哪里。不暴露太多的实现细节,这样我们就可以针对性能或变化的用例改变事情的工作方式。
这和unsafe
有什么关系呢?
Rust 的最终目标并不是完全消除那些危险点,因为在某种程度上,我们需要能够……访问内存和其他资源。实际上,Rust 的目标是将所有的unsafe
元素抽象出来。在考虑安全性时,你需要考虑“攻击面”,或者我们可以与程序的哪些部分进行交互。像解析器这样的东西是一个很大的攻击面,因为:
你可以进一步分解,将传统的攻击面分解成“攻击面”(可以直接影响程序代码的部分)和“安全层”,这部分代码是攻击面依赖的代码,但是无法访问,而且可能存在潜在的 Bug。在 C 语言中,它们是一样的:C 语言中的数组根本不是抽象的,所以如果你读取了可变数量的项,就需要确保所有的不变量都保持不变,因为这是在不安全层中操作,那里可能会发生错误。
现在我们将其与 Rust 做个比较:Vec
由unsafe
代码组成,因此存在潜在的 Bug,它成为不安全层。但是Vec
封装了它的数据,我必须使用方法来访问它。如果 Rust 的 libstdVec
实现没有 Bug,那么我对Vec
所做的任何操作都不会导致内存崩溃。因此,接下来的问题就变成了确保Vec
正确实现(一个相对较小的不安全层),以确保任意大的基于Vec
的攻击面都是可靠的。答案很简单:将不安全层置于尽可能远离攻击面的地方。关于这一点,稍后会有进一步的讨论。
通常,这种推理会引入一些逻辑谬误,我见过很多次,那就是“如果所有安全的 Rust 都以不安全的 Rust 为基础构建,那不就意味着所有的 Rust 都不安全了吗?”我将回答这个问题,并做更深入的讨论,但在这里:
函数可以接受的输入是有限的,这些输入的一个子集(可能为空)将导致内存安全漏洞,然而,如果逻辑上不可能传入任何不可靠的输入,那么逻辑上就不可能导致内存崩溃。对于这个例子,让我们用更容易推断的东西来替代内存安全性:Panic。
fn crash_on_zero(x: u8) {
if x == 0 {
panic!();
}
}
在这个例子中,集合中有一个输入会导致 panic (0),如果我们的目标是不让 Panic 发生,那么,假设输入可以是任何东西,我们就失败了。然而,如果我们在外围再创建一个“安全封装器”来防止无效输入,就永远不会出现 Panic:
fn cannot_crash(x: u8) {
if x != 0 {
crash_on_zero(x);
}
}
用不安全替换 Panic,通过封装不安全的代码,我们消除了程序中所有的不稳固性。无论你如何使用安全包装,都不会导致 Panic。为了内存安全,需要保持的不变量数量比“非零”变量的数量多得多,输入的数量也更多,我认为,你仍然会发现封装将大幅减少不安全,特别是因为它将前面提到的“不安全层”缩小了一个数量级,在获得更大的安全确定性的同时,降低了所需的检查成本。
Rust 最大的特点之一是它在设计时更加关注局部性,也就是说,函数更容易推理,而不需要查看函数之外的内容。在前面的 C 程序示例中,我们的内存管理没有局部性:在一个函数中使用内存后,我是否需要free
内存取决于另一个函数的实现。也就是说,仅查看 C 程序的函数签名不足以推断应该如何处理传入给它的和从它传出的内存。
Rust(其次是 C++ 智能指针)提供的替代方法是将内存行为编码为返回类型。在 Rust 中,Box<T>
类型表示该值是堆分配的,并归属于该Box
的拥有者。如果我把一个Box
传递给你,我就不再拥有它了,所以你有责任释放它。因此,如果一个函数返回一个Box
,它就明确地告诉了每个调用者,它们需要负责释放返回的内存。类似地,String
类型表示堆分配和所有权,而&'static str
表示放入二进制文件中的不可变字符串。
上文的 C 语言函数可以重写如下:
fn get_name() -> &'static str {
"jam"
}
因此,如果我们将函数签名更改为一个堆分配字符串,这将是一个破坏性的更改。因此,如果我的同事将代码从堆分配String
改回硬编码的&'static str'
常量,那么如果我编写了修改字符串的代码,他将得到一个编译器错误,并知道需要更新我的代码。
不管怎样,这不仅能让我们获得内存安全的代码,还使得代码更容易重构,我可以确定地从&'static str
改为String
,如果这导致了破坏性更改,则编译器会立即告诉我,而不是等两个月后用户报告错误,而我已经忘记做了什么更改。
更具体地说,出借机制更进一步,因为生存期规范允许你对使用规则进行编码,从而通过证明局部内存安全和生存期保证来进一步确保全局内存安全。这种能在局部证明一切的能力大大减少了证明一切安全的工作。首先,如前所述,它让你可以证明整个程序是安全的,你对不安全代码的安全抽象意味着要审计的代码少了一个数量级。其次,由于这些抽象的安全性并不依赖于它们的使用方式,所以所需的审计量会随着代码库的规模呈线性增长,而不是呈指数增长,所以你不必检查抽象和使用之间的交互。
Rust 允许你分离出所有需要仔细检查的代码(这得益于unsafe
关键字,在偶尔出现错误时更方便查找)。所有这些的一个很大的好处是,由于标准库已经经过了彻底的测试,所以只有在你做法不规范时才会遇到问题。让我们来看看最近来自 Rust libstd 的一些经常讨论的不稳固性 Bug:
如果你的威胁模型是“在未沙箱化的系统上运行任意用户提供的安全 Rust”,那么这些当然是大问题……但这是一个不存在的威胁模型。让我们再看一个例子。
VecDeque 中的 Segfault ——最后,一个实质一些的问题。让我们来看看最小复制:
let mut deque = VecDeque::with_capacity(32);
deque.push_front(0);
deque.reserve(31);
deque.push_back(0);
这无疑可以在正常代码中触发!它存在有 2 年了吧?
好了,现在我已经提供了我对“Rust 有缺陷”这个问题的想法。在开发过程中,你的目标通常是通过一个非常严格的机会窗口。你必须以正确的顺序满足适当的条件,同时符合给定的约束。你对自己所能做的事情的控制是相当有限的。无疑,在某些合理的程序中也有可能利用上述漏洞,但在任何使用VecDeque
的特定情况下,都不太可能被利用。为什么?因为这需要特定的事件序列,而且在大多数情况下,你没有必要的工具来触发with_capacity
->push_{front, back}
->reserve
->push_{front, back}
。这就是我们为什么要在攻击面和不安全层之间设置尽可能大的隔离,这样可以减少攻击者对不安全代码的控制,减少漏洞被利用的可能性,同时也增加漏洞利用的难度。
这就是我关于为什么基于unsafe
也可以构建出相当安全且难以被利用的代码的观点。如果你想告诉我这是一个多么糟糕的想法,或者向我解释 Rust 如何糟糕且不安全,请在 Twitter 上告诉我!
查看英文原文:
领取专属 10元无门槛券
私享最新 技术干货