Rust 安全參考 | Rust 編譯到 WebAssembly 可能出現側通道攻擊

語言: CN / TW / HK

這是一篇來自 Trail of Bits [1] 安全公司部落格的文章,介紹了 Rust 利用 LLVM 編譯到 WebAssembly 時可能出現新的側通道攻擊風險。

許多工程師選擇 Rust 作為他們實現加密協議的首選語言,因為它具有強大的安全保證。儘管 Rust 使安全的密碼工程更容易,但仍有一些挑戰需要注意。其中之一是需要保留恆定時間(constant-time)屬性,這確保無論輸入如何,程式碼都將始終花費相同的時間來執行。這些屬性在防止時序攻擊(timing attack)方面很重要,但它們可能會受到編譯器優化的影響。

時序攻擊,是側通道攻擊時序攻擊屬於側通道攻擊/旁路攻擊(Side Channel Attack),側通道攻擊是指利用通道外的資訊,比如加解密的速度/加解密時晶片引腳的電壓/密文傳輸的流量和途徑等進行攻擊的方式,一個詞形容就是“旁敲側擊”。舉一個最簡單的計時攻擊的例子,某個函式負責比較使用者輸入的密碼和存放在系統內密碼是否相同,如果該函式是從第一位開始比較,發現不同就立即返回,那麼通過計算返回的速度就知道了大概是哪一位開始不同的,這樣就實現了電影中經常出現的按位破解密碼的場景。密碼破解複雜度成千上萬倍甚至百萬千萬倍的下降。最簡單的防禦方法是:“發現錯誤的時候並不立即返回,而是設一個標誌位,直到完全比較完兩個字串再返回”。來源知乎: https://www.zhihu.com/question/20156213/answer/43377769 [2]

恆定時間(constant-time)

密碼學很難正確實現。除了擔心整體正確性和可能以意想不到的方式暴露祕密的邊緣情況外,潛在的側通道洩漏和時序攻擊也是令人深感擔憂的問題。

時序攻擊試圖利用應用程式的執行時間可能微妙地依賴於輸入這一事實。如果應用程式根據私密資料(例如隨機數生成器或私鑰的種子)做出與控制流相關的決策,這可能會稍微影響應用程式的執行時間。同樣,如果使用私密資料來確定從記憶體中的哪個位置讀取,這可能會導致快取未命中,進而影響應用程式的執行時間。在這兩種情況下,有關私密資料的資訊都會在程式執行期間通過時間差異洩露。

為了防止這種時間差異,密碼學工程師通常避免根據私密資料實施決策。但是,在程式碼需要根據私密資料做出決策的情況下,有一些巧妙的方法可以在恆定時間內實現它們,也就是說,無論輸入如何,總是在相同的時間內執行。例如,考慮以下Rust函式,它在變數 ab 之間執行條件選擇。

#[inline]
fn conditional_select(a: u32, b: u32, choice: bool) -> u32 {
if choice { a } else { b }
}

根據編譯器工具鏈和目標指令集的不同,編譯器可以選擇使用分支指令來實現條件選擇,比如x86上的 jne 或ARM上的 bne 。這將在函式的執行過程中引入一個時間差,這可能會洩露關於選擇變數的資訊。下面的Rust實現使用了一個巧妙的技巧,在恆定時間內執行相同的條件選擇。

// 設定標誌位,避免馬上返回
#[inline]
fn conditional_select(a: u32, b: u32, choice: u8) -> u32 {
// if choice = 0, mask = (-0) = 0000...0000
// if choice = 1, mask = (-1) = 1111...1111
let mask = -(choice as i32) as u32;
b ^ (mask & (a ^ b))
}

理想情況下,這種方式好像已經解決了問題。但實踐中,也存在固有風險。由於編譯器沒有時間概念,因此它不會將時間差異視為可觀察到的行為。這意味著可以自由地重寫和優化恆定時間程式碼,這可能會在程式中引入新的時間洩漏。像上面這樣精心編寫的恆定時間實現仍然可以由編譯器優化為分支指令,這會洩漏 choice !

如何阻止編譯器優化破壞程式碼的恆定時間呢?有幾種方案:

  • 使用 -C opt-level=0 關閉所有優化。這種方案基本不可行,因為我們需要編譯器的優化。
  • 使用來自 subtle [3] crate 的構造來嘗試阻止 LLVM 優化恆定時間程式碼路徑的嘗試。

  • 語言內建私密型別來支援。之前有一個 Rust RFC [4] 引入了 secret types,但這已被推遲,等待 LLVM 支援。

來自 subtle crate 中的構造,用於遮蔽優化:


#[inline(never)]
fn black_box(input: u8) -> u8 {
// 使用 read_volatile 來告訴編譯器 &input 的記憶體是易失的,編譯器不應該對它做任何假設
// 起到了一個優化屏障的作用
unsafe { core::ptr::read_volatile(&input as *const u8) }
}

// 通過檢查生成的彙編指令,可以確定該函式將始終在恆定時間內執行,是有效的
pub fn test_with_barrier(a: u32, b: u32, choice: bool) -> u32 {
let choice = black_box(choice as u8); // 使用上面定義的優化屏障
conditional_select(a, b, choice)
}

從 Rust 編譯到 WebAssembly 會如何?

在node中使用 WASM,意味著需要經過兩次優化:

  • 由 Rust 經過 LLVM 編譯為 WebAssembly

  • 再由 V8 的 Turbofan JIT 編譯器再次對 WAMS 進行編譯優化

上面的優化屏障程式碼對 LLVM 有用,但是對 Turbofan 是否有用?

實踐證明,Turbofan 沒有對 black_box 函式進行優化,由於 Wasm 中沒有任何內容與 Rust 中的 volatile 讀取相對應,理論上, Turbofan 沒有理由繼續保留   black_box ,但實際上 black_box 有寫記憶體行為,不保證一定沒有副作用,所以 Turbofan 沒有優化掉它。這同時也證明了, black_box 中的私密值 &input 被洩露到了wasm記憶體中,這不是好現象。

如果使用 llvm_asm! 重新實現 black_box 函式:

#[inline(never)]
fn black_box(input: u8) -> u8 {
unsafe { llvm_asm!("" : "+r"(input) : : : "volatile"); }

input
}

此函式不會將私密值input洩露到記憶體中。

總結

很明顯,通過插入優化屏障來對抗 LLVM 並不是提供恆定時間保證的好方法。正在努力在語言層面解決這個問題。私密型別 RFC和 CT-Wasm 專案 [5] 分別為 Rust 和 Wasm 引入了祕密型別,是這種努力的兩個很好的例子。缺少的是一種將機密型別和相應語義匯入 LLVM 的方法。這很可能是 Rust 實現向前發展的先決條件。(Rust RFC 目前被推遲,等待 LLVM 的相應 RFC。)如果沒有 LLVM 支援,很難看出依賴 LLVM 的高階語言如何提供任何絕對恆定時間的保證。在那之前,我們都在和編譯器後端玩捉迷藏。

後續探索

該團隊後續探索出一種方法:在 Rust 編譯器 (rustc) 中實現一個功能,讓使用者可以更好地控制生成的程式碼。相關程式碼見 https://github.com/trailofbits/rust-optnone [6] ,等測試完成後會向 Rust 官方提交 PR。

出發點在於,我們是否可以影響編譯器不優化conditional_select函式,而不是全域性禁止優化?Cargo 和 rustc 接受全域性禁用優化的引數,但在整個系統上這樣做通常是不可能的。一種可能的解決方案是阻止對特定功能的優化。

該團隊利用 LLVM 的 `optnone` [7] 屬性來禁用函式級別優化。

此函式屬性表示大多數優化傳遞將跳過此函式,但過程間優化傳遞除外。程式碼生成預設為“快速”指令選擇器。該屬性不能與 alwaysinline 屬性一起使用;此屬性也與 minsize 屬性和 optsize 屬性不相容。此屬性還需要在函式上指定 noinline 屬性,因此該函式永遠不會內聯到任何呼叫者中。只有具有 alwaysinline 屬性的函式才是內聯到該函式主體的有效候選者。

根據文件,該功能還需要該noinline屬性。碰巧的是,我們已經用該屬性 #[inline(never)] 標記了函式。現在,只需在 Rust 中實現另一個屬性,該屬性在編譯時將為函式生成 optnonenoinline 屬性。

基於已經實現的 `optimize` [8] 屬性改造。

// 為 optimize 屬性新增 never 選項
#[optimize(never)]
fn conditional_select(a: u32, b: u32, choice: bool) -> u32 {
let mask = -(choice as i32) as u32;
b ^ (mask & (a ^ b))
}

在 part2 文章裡介紹了這個實現過程,可以看原文了解詳情。

但這樣是徹底解決問題了嗎?非也。機器程式碼生成過程中依賴特定平臺依然會有一些優化。

未來通過引入私密型別可能會解決問題,但是目前,只能依賴於我們已經掌握的資訊,依賴於 #[optimize(never)] 來向前邁出一小步了。

  • Part I: https://blog.trailofbits.com/2022/01/26/part-1-the-life-of-an-optimization-barrier/ [9]

  • Part II: https://blog.trailofbits.com/2022/02/01/part-2-rusty-crypto/ [10]

參考資料

[1]

Trail of Bits: https://www.trailofbits.com/

[2]

https://www.zhihu.com/question/20156213/answer/43377769: https://www.zhihu.com/question/20156213/answer/43377769

[3]

subtle: https://github.com/dalek-cryptography/subtle

[4]

Rust RFC: https://github.com/rust-lang/rfcs/pull/2859

[5]

CT-Wasm 專案: https://github.com/PLSysSec/ct-wasm

[6]

https://github.com/trailofbits/rust-optnone: https://github.com/trailofbits/rust-optnone

[7]

optnone : https://llvm.org/docs/LangRef.html#function-attributes

[8]

optimize : https://github.com/rust-lang/rust/issues/54882

[9]

Part I: https://blog.trailofbits.com/2022/01/26/part-1-the-life-of-an-optimization-barrier/: https://blog.trailofbits.com/2022/01/26/part-1-the-life-of-an-optimization-barrier/

[10]

Part II: https://blog.trailofbits.com/2022/02/01/part-2-rusty-crypto/: https://blog.trailofbits.com/2022/02/01/part-2-rusty-crypto/

推薦閱讀

覺得不錯,點個贊吧

掃碼關注「 Rust程式設計指北