【譯】Ringbahn的兩個記憶體Bug

Praying發表於2020-11-03

原文連結: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

相關文章