Nugine 的个人博客关于

Unsafe 的隐藏坑点

Unsafe Rust 有一个隐藏的坑点:型变(variance)问题。

不了解型变的人容易写出带有漏洞的unsafe代码,这类漏洞通常不易察觉。

本文前置知识 https://doc.rust-lang.org/nomicon/subtyping.html

我们来写一个具有内部可变性的结构。

pub struct MyCell<'a, T> {
    value: &'a T,
}

impl<'a, T> MyCell<'a, T> {
    pub fn new(x: &'a T) -> Self {
        Self { value: x }
    }

    pub fn get(&self) -> &'a T {
        self.value
    }

    pub fn set(&self, x: &'a T) {
        unsafe { std::ptr::write(&self.value as *const &T as *mut &T, &x) }
    }
}

MyCell 包含一个指向 T 的共享引用,提供 get 和 set 两个操作。

get 操作:返回一个指向 T 的共享引用,看起来没问题。

set 操作:将 MyCell 中的共享引用指向另一个 T,看起来也没问题。

但是这里会有健全性漏洞(unsoundness),下面我们来利用它。

fn exp() {
    let x = 123;
    let cell = MyCell::new(&x);

    fn set_cell<'a>(cell: &MyCell<'a, i32>) {
        let val = 42;
        cell.set(&val);
    }

    set_cell(&cell);
    dbg!(cell.get());
}

执行结果每次都不一样,这里有一个释放后使用(use after free)的漏洞。

[src/main.rs:29] cell.get() = 22070
[src/main.rs:29] cell.get() = 21970

在 set_cell 中,记 val 的生存期为 &'1,从定义 val 的这行起,到函数结束,当函数返回时 val 被释放。

cell.set(&val) 相当于 MyCell::set(cell,&val)

cell 为 &MyCell<'a, i32>

&val 为 &'1 i32

由于 MyCell 只包含 &'a T,默认情况下,MyCell 对 'a 协变。这里的 cell 变为 &MyCell<'1, i32>

生存期检查通过,cell 中的引用有效期变为 '1,但函数返回后 cell 仍然是 MyCell<'a, i32>,'a 长于 '1

因此 cell 中的引用被设置成了 &'1 i32,但外部类型定义认为它是 &'a i32,产生了生存期错误,导致漏洞。

实际上,MyCell 这类具有内部可变性的结构应该对 'a 不变。

如果 MyCell 对 'a 协变,它内部的引用有效期就会被意外缩短。

如果 MyCell 对 'a 逆变,它内部的引用有效期并不会加长,同样有漏洞。

修复方案:使用标准库提供的内部可变性来源 UnsafeCell,标注 MyCell 具有类似 UnsafeCell<&'a T> 的行为。

UnsafeCell<T> 对 T 不变,UnsafeCell<&'a T> 对 'a 不变。根据型变推导,MyCell<'a,T> 对 'a 不变,对 T 不变。

use std::cell::UnsafeCell;
use std::marker::PhantomData;

pub struct MyCell<'a, T> {
    value: &'a T,
    _marker: PhantomData<UnsafeCell<&'a T>>,
}

或者直接使用 UnsafeCell

use std::cell::UnsafeCell;

pub struct MyCell<'a, T> {
    value: UnsafeCell<&'a T>,
}

impl<'a, T> MyCell<'a, T> {
    pub fn new(x: &'a T) -> Self {
        Self {
            value: UnsafeCell::new(x),
        }
    }

    pub fn get(&self) -> &'a T {
        unsafe { *self.value.get() }
    }

    pub fn set(&self, x: &'a T) {
        unsafe { std::ptr::write(self.value.get(), &x) }
    }
}

编译器正确地拒绝了利用代码。

error[E0597]: `val` does not live long enough
  --> src/main.rs:32:18
   |
30 |     fn set_cell<'a>(cell: &MyCell<'a, i32>) {
   |                 -- lifetime `'a` defined here
31 |         let val = 42;
32 |         cell.set(&val);
   |         ---------^^^^-
   |         |        |
   |         |        borrowed value does not live long enough
   |         argument requires that `val` is borrowed for `'a`
33 |     }
   |     - `val` dropped here while still borrowed

且慢,你以为这里只有一个漏洞吗?

回到原来的定义

pub struct MyCell<'a, T> {
    value: &'a T,
}

进行线程安全性推理,参考 nomicon send-and-sync.

如果 MyCell 满足 Send,那么需要 &'a T 满足 Send,T: Sync。

如果 MyCell 满足 Sync,那么需要 &'a T 满足 Sync,T: Sync。

但实际上 MyCell 应该只满足 Send,不满足 Sync.

当 MyCell 被多线程共享时,每个线程都可以随意改变同一个 MyCell 中的引用。如果两个线程同时写同一个内存地址,在没有原子操作保证的情况下,就会发生数据竞争,产生难以发现的 bug.

UnsafeCell 只满足 Send,不满足 Sync.

因此,最终的写法就是包含 UnsafeCell,安全简洁。

pub struct MyCell<'a, T> {
    value: UnsafeCell<&'a T>,
}

这也告诉我们,正确地写出 Unsafe 代码通常需要从数据结构定义开始就使用良好的抽象,不能一把梭乱写,不然,轻则应用崩溃,重则安全漏洞。

修正:

根据别名规则,只有 UnsafeCell<T> 才可以通过共享引用更改内容,因此本文中只有最终写法是正确的。

发布于 2020-01-04地址: GitHub, 知乎