【译】Ringbahn的两个内存Bug

原文链接:https://without.boats/blog/two-memory-bugs-from-ringbahn/
原文标题:Two Memory Bugs From Ringbahn
公众号:Rust 碎碎念
翻译: Praying

在实现ringbahn[1]的时候,我引入了至少两个 bugs,这些 bugs 引发了内存安全错误,导致段错误,分配器中止以及匪夷所思的未定义行为。我已经修复了我所能找到的 bugs,现在我也无法证明代码库中是否有更多的内存安全问题(当然,这并不意味着没有),我想记录下这两个 bugs,因为它们有一个共同点:它们都是由析构器(destructor)引起的。

Bug #1: 析构器在赋值后运行(二次释放)

这是一个比较经典的 bug,基本上每个正在写 unsafe 代码的人都会知道。有 bug 的代码看起来像下面这样:

let data = self.read_buf.buf.as_mut_ptr();
let len = self.read_buf.buf.len();
self.completion.cancel(Cancellation::buffer(data, len));
self.read_buf = Buffer::new();

这段代码是Ring类型(API 后来改变了)上的 cancellation 的实现的一部分。在 ringbahn 中,如果 IO 当前正在运行,它正在使用一个 buffer,但是程序取消了它对这个 IO 的关注,这个 IO 的完成会被一个 cancellation 对象处理,该对象将被用于在 IO 完成时清理对应的 buffer。这个代码构造了一个 cancellation,并将其传递给 completion,并且使用一个新的 buffer 来替换原有的 buffer。

这听起来很好,但是会出现问题,因为赋值(assignment)的语义。在 Rust 中,当一个字段被重新赋值时,这个字段的前一个值会调用析构器。因此,在这段代码中,当我们将read_buf重新赋值时,我们传递给 cancellation 的 buffer 立即就被释放了。然后,当 IO 完成时,我们再次释放了这个 buffer,导致了二次释放。

相同的 bug 在这个文件中出现了两次:类似的一段代码以相同的方式取消关注的读取事件。

解决方案:ptr::write

解决方法是把最后一行代码使用ptr::write的调用来替换:

let data = self.read_buf.buf.as_mut_ptr();
let len = self.read_buf.buf.len();
self.completion.cancel(Cancellation::buffer(data, len));
ptr::write(&mut self.read_buf, Buffer::new());

函数ptr::write行为很像赋值:它把第二个参数的值写到第一个参数的地址。但是,和赋值不同,它不会运行前一个值的析构器。这是ptr::write和一个赋值操作最大的不同。

这看起来不是很直观,但它是在写 unsafe 代码时一个需要记住的重要技巧:如果你想要要对一个值重新赋值,但是你不想运行前一个值的析构器,你需要使用ptr::write

Bug #2: 析构器引用一个释放过的对象(释放后使用)

第二个 Bug 出现于下面这段代码中:

let mut state = self.state.as_ref().lock();
if matches!(&*state, State::Completed(_)) {
    callback.cancel();
    self.deallocate();
else {
    *state = State::Cancelled(callback);
}

这段代码实现了我们在前面的代码示例中调用的Completion::cancel方法。 一个 completion 的state字段是一个NonNull<Mutex<State>>,它指向一个表示 completion 的状态的枚举。当一个 completion 被取消的时候,我们获取一个在 completion 上的锁,然后检查它是否完成。如果它还没有完成,我们存储一个回调(callback)用于完成时调用。但是如果它已经完成(意味着这个 IO 完成和我们对它的取消并发地进行),我们调用回调(通过它的cancel方法)并且然后析构这个完成,清理和这个 IO 事件相关的所有资源。

问题在于,state变量是一个MutexGuard,包装了(wrapping)了对我们的 completion 的状态的锁定访问。state变量的析构器会到函数完成时才会调用,并且当它调用的时候,它将会修改已经锁定的 Mutex 的状态。但是,当我们调用self.deallocate时我们已经释放了那个 mutex。这意味着,此时出于其他目的而修改任意的已被使用的状态会是一个释放后使用(use after free)。

这个 bug 也发生了两次:在 completion 模块的两一个函数,我们也统一在丢弃 mutexguard 之前释放了这个 completion。

解决方案:mem::drop

解决方案是在释放 completion 之前,插入一个mem::drop的调用,如此一来,析构器就能够被保证在 mutex 释放之前运行。现在代码像下面这样:

let mut state = self.state.as_ref().lock();
if matches!(&*state, State::Completed(_)) {
    callback.cancel();
    drop(state);
    self.deallocate();
else {
    *state = State::Cancelled(callback);
}

这将析构器按照正确的顺序排序,因此对 mutex 状态的写操作会在 mutex 被释放之前发生。

从技术上来讲,我们同样能够mem::forget这个 MutexGuard:因为我们正在释放这个 Mutex,所以我们指定其他试图尝试获取锁的行为都不会发生,并且释放锁也是白费功夫。我是在写这篇博客的时候才有了这个想法。

对此我们还能做什么?

我觉得有趣的是,我在我的代码中发现的两个内存错误都是因为运行了析构器。从某种意义上来说,这并不奇怪:对面前的代码进行推理是一回事,但对编译器隐式插入在你的程序中的代码进行推理又是另一回事。

在 safe 代码中,析构器很好,也是 Rust 的强大能力之一:正如 Yehuda Katz 以前写的那样,能够让(程序员)在大多数情况下可以不担心资源清理是非常棒的。但 unsafe 代码是另一回事,其中关于别名的保证会变得相当混乱。如果能在一些作用域中开启一个 lint,用以警告我是否析构器被插入到我的代码中,那就太好了。

(顺便提一下,对于类型理论的爱好者来说,这个 lint 会有效地把 Rust 的 “仿射(affine)”类型变成 “线性(linear)”类型。我认为 Rust 选择将不可复制的类型变成 “仿射(affine)”类型在总体上是正确的选择,但这表明在某些情况下,额外的线性检查是非常有价值的。)

参考资料

[1]

ringbahn: https://github.com/withoutboats/ringbahn

posted @ 2020-11-03 16:15  Praying  阅读(240)  评论(0编辑  收藏  举报