案發現場:一個本該無害的側通道
上一篇導覽留給我們一個令人不安的事實:快取會透過時序洩漏資訊,因為命中快、未命中慢,而能量出那段差距的攻擊者就能得知哪些位址是別人最近碰過的。光看這點似乎還挺得住——處理器總不會去碰它被禁止讀取的記憶體吧。Spectre 與 Meltdown 這兩個 2018 年 1 月一起揭露的漏洞,正是這個假設為假的殘酷示範。它們拿你爬這座階梯時認識的兩個加速把戲——那些讓現代 CPU 變快的把戲——把它們變成一台幫浦,把被禁止的資料推進快取時序側通道裡。
回想前面幾級的關鍵把戲。為了不閒著,CPU 不會乖乖等每條指令做完;它會跑在它官方上確定該到的地方之前的工作。靠亂序執行,它會先開動那些輸入已經就緒的運算,像一間廚房先做任何食材已經送到的訂單——但它仍然按點餐的順序出菜,因為結果只有透過按序提交才正式生效。而靠推測執行,它走得更遠:在一個分支處,它用分支預測器押注路會走哪一岔,並在條件還沒判定之前就衝下那條路。若押錯了,它就把推測的工作丟掉。它一向承諾,這些被丟掉的工作彷彿從未發生過。這些攻擊證明了這個承諾是破的——被丟掉的工作會留下腳印。
Meltdown:推測衝過了權限的圍欄
Meltdown 是兩者中比較簡單、也比較嚇人的一個,因為它熔穿了使用者程式與作業系統核心之間的那道牆。回想保護那一篇:CPU 以不同的權限等級運行:你的應用程式坐在使用者模式,不能讀取核心記憶體,任何嘗試都該引發一個錯誤。在受影響的處理器上,核心的分頁為了速度被對映進每一個行程的位址空間,只標上「使用者模式不得存取」。麻煩在於:權限檢查與真正的資料擷取是平行進行的,而一條載入可以推測性地讀到那個位元組、並在錯誤被送達使其退場之前的幾個週期,就把它往下遞了出去。
把那個位元組在回捲抹掉它之前偷運出來的把戲在這裡。攻擊者準備一個有 256 條快取列的探測陣列,每個可能的位元組值一條,並把它們全部從快取沖出去。接著,在那個注定被丟棄的推測窗口裡,程式碼讀到祕密位元組,並把它的值當成那個探測陣列的索引——去碰 probe[secret]。那一碰恰好把探測陣列的一條列拉進快取。然後錯誤觸發,架構結果消失,暫存器看起來毫髮無傷。但 probe[secret] 仍然是熱的。攻擊者去量讀取全部 256 條列的時間:255 條是慢的未命中,一條是快的命中,而哪一條快就拼出了那個祕密位元組。祕密就這樣從攻擊者讀不到的暫存器,被洗進了攻擊者量得到的一個快取位址。
Meltdown core, conceptually (the asterisk * is a C dereference):
flush(probe[0..255]) // start with a cold probe array
secret = *kernel_pointer // FORBIDDEN: raises a fault...
junk = probe[secret * 256] // ...but speculation does this FIRST,
// pulling probe[secret] into cache
// fault now delivered -> architectural state rolled back
// but probe[secret] is still cached
for v in 0..255: // the reload / timing step
t = time( read probe[v*256] )
if t is FAST: secret == v // the one hit reveals the byte
Repeat one byte at a time to dump a whole region of kernel memory.Spectre:誘騙一個程式攻擊它自己
Spectre 比較幽微、比較難修,會這樣命名是因為——它的作者警告——它會纏著我們很久。Meltdown 是在一個程式內跳過權限的圍欄,Spectre 則待在受害者自己的權限之內,濫用分支預測器讓受害者去跑一條它真實情況下絕不會跑的推測指令。經典的變體針對邊界檢查。想像一個做對了事的受害者函式:`if (i < array_len) y = array2[array1[i]];`。這個檢查是對的。但分支預測器是對過去的押注,於是攻擊者先用合法的 `i` 呼叫這段程式碼很多次,讓預測器學到「這個分支會被走」。
現在攻擊者用一個故意越界的 `i` 去呼叫它——指向遠在 array1 之外、一個祕密所在的地方。邊界檢查 `i < array_len` 為假,所以那條載入本該被跳過。但被訓練成預期「會走」的預測器,仍然推測性地跑了那個本體:它讀了 array1[i]——那個祕密位元組——並用它去索引 array2,把 array2[secret] 拉進快取,這一切都發生在比較判定、工作被丟棄之前的那個推測窗口裡。架構結果是對的(本體官方上從未跑過),但祕密已經像在 Meltdown 裡那樣被編碼進了快取。攻擊者接著去量 array2 的時間把它撈回來。受害者用它自己合法的程式碼,替攻擊者攻擊了它自己。
這就是為什麼 Spectre 難殺得多。Meltdown 大致上是一個 CPU 臭蟲——一條在權限檢查之前就洩漏資料的載入——一個硬體修補加上把核心從使用者空間對映出去(KPTI)就大致堵住它。Spectre 不是單一臭蟲,而是一類:任何處理器在一個與安全相關的判斷上做推測的程式路徑,原則上都能被操縱。沒有任何核心邊界被違反,受害者的原始碼裡也沒有任何明顯「錯」的地方。這個洩漏住在 ISA 所承諾的、與微架構實際所做的之間那道縫隙裡。
緩解措施,與它們索取的代價
沒有一個讓這一切消失的單一開關,因為病因是一個我們真心想要的效能功能。緩解是一種分層防禦,而幾乎每一層都要花速度。軟體可以在一個敏感的邊界檢查之後種下一個推測屏障(像 `lfence` 這樣的指令),逼 CPU 在跑那條載入之前真的等那個檢查跑完——這正是亂序設計當初存在就是為了避開的停頓。編譯器可以把分支改寫成依賴資料的算術(這樣就沒有東西能讓預測器預測錯),或用 'retpoline' 之類的把戲,不讓間接分支推測進攻擊者選定的目標。作業系統把核心從使用者空間對映出去,並在環境切換時清掉或分隔預測器的狀態。
較新的處理器把修補往下推進硬體本身——補上那個缺掉的權限檢查,讓一條被禁止的載入永遠不會推測性地把它的位元組往前遞,並給軟體指令去清掉或隔離預測器的狀態。那些有幫助,但深層的張力依舊:你插入的每一道屏障、你禁止的每一次推測、你在邊界上清掉的每一個快取或預測器,丟掉的正是讓晶片變快的那些工作。各種緩解措施量到的減速,從計算密集型程式碼裡幾乎察覺不到,到系統呼叫繁重或環境切換繁重的工作負載裡的兩位數百分比——而那些正是最常跨越保護邊界的程式,也正是危險所住的地方。
它真正的意思,以及它不是什麼意思
退一步看看這堂課的形狀。數十年來,處理器架構師把推測執行當成隱形的:只要架構結果是對的、錯誤終究會觸發,手段似乎就無關緊要。Spectre 與 Meltdown 粉碎了這個看法,因為它們顯示出手段會洩漏。一個正確的、按序提交的結果,不再是沿途什麼都沒被洩漏的證明,因為快取——以及分支預測器、TLB 和其他共享的微架構狀態——悄悄記錄了這趟旅程。真正重要的那道邊界從來就不只是 ISA 契約;它是實作這份契約的那個硬體。
也要誠實面對它的侷限,因為誇大本身就是一種錯誤資訊。這些是側通道的讀取,不是任意的寫入:攻擊者學到祕密,但並不直接破壞記憶體或奪取程式計數器。這些攻擊是統計性的,需要細心的計時、反覆的試驗,以及一條在受害者附近跑程式碼的途徑(一個惡意行程,或一個與你共用一個核心的沙箱裡的腳本)。它們並不會魔法般地隔著網際網路讀資料,而現代硬體上一個剛打過修補的系統已經強固許多。但更深的那一點仍然成立:那些讓 CPU 變快的最佳化,如今被理解為一個貨真價實的安全面,而「架構說這個沒法被觀察到」不再是一個安全的假設——微架構也有一票。