JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

旁路攻擊:Spectre、Meltdown 與推測執行

到目前為止每一個攻擊都損毀了記憶體。這最後一篇做的事更奇異也更深:它不寫錯任何一個位元組就偷走了祕密,靠的是讀取 CPU 在搶先猜測時留下的那些微弱足跡。

一種不同的洩漏:那個從不是通道的通道

到目前為止這一級裡的每一個攻擊都是對正確性的攻擊。一個溢位寫了一個它不該寫的位元組;一個釋放後使用讀了一個不再存在的物件;ROP 讓 ret 跳到它不該跳的地方。程式做了的事,而那份錯誤就是立足點。本篇談的是這樣的攻擊:程式做的恰恰是對的事——每一條指令都合法、每一次權限檢查都通過、沒有任何一個記憶體位元組被損毀——而祕密還是洩漏了。那就是一個旁路:資訊逃逸出去,不是透過程式預期的輸出,而是透過「運算如何執行」的某個物理副作用。時間、耗電、一塊快取的餘溫。CPU 從來就不該說出那個祕密;它只是依祕密而行為不同,而行為不同本身就是一則訊息。

從最溫和的例子開始,因為那個原則就是整場戰役。想像一個逐位元組比較密碼、並在兩個位元組一相異時立刻返回的登入檢查。一個猜對了前 3 個位元組的錯誤猜測,會跑得比一個在第 1 個位元組就失敗的稍微久一點,因為那個迴圈多跑了三輪。那個函式從不告訴你祕密——但它花的時間取決於你猜對了多少個開頭的位元組。精確地量出那個時間,你就能一次一個位元組地還原出密碼,把幾千次計時猜測變成一把鑰匙,沒有溢位、也沒有通常意義上的臭蟲。這是一個計時旁路,而它的修正告訴你整個領域是在講什麼:讓執行時間與祕密無關——一個總是碰到每一個位元組的常數時間比較。洩漏從不在答案裡;它在工作的形狀裡。

把快取變成一具竊聽器

上面那個計時通道透過一個迴圈次數洩漏。那個遠更強大的通道——Spectre 與 Meltdown 加以武器化的那一個——透過 CPU 快取洩漏。從微架構那級你知道快取線:當 CPU 讀一個位址時,它把整條 64 位元組的線拉進快速快取,於是下一次存取那條線裡的任何東西都會快得多。一次來自快取的讀取也許花幾個週期;一次未命中、跑去主記憶體的讀取也許花幾百個。那道落差——若已快取則快、若否則慢——是巨大的、可靠的,而且只要對一次記憶體存取計時就能從軟體量出。樞紐就在這裡:一條線是否被快取,是 CPU 碰過了什麼的足跡。如果你能安排讓一個祕密去決定哪一條線被拉進快取,你稍後就能藉著量「現在哪一條線是快的」來還原那個祕密。

那個經典技巧叫做 Flush+Reload(清除+重載),值得走一遍,因為它是每一個快取攻擊所用的那具接收器。攻擊者與受害者共享某塊記憶體——一個被唯讀映射進兩者的共享函式庫就很完美。攻擊者首先把選定的一條線清出快取(x86 甚至有一條指令 clflush 正是做這件事)。接著它讓受害者執行。然後它重載那同一條線、並對那次載入計時。如果載入很快,那條線就是被帶回了快取——意思是受害者在執行時碰過它。如果載入很慢,受害者就放過了它。一條被清除又被計時的線,回答了一個關於受害者記憶體存取的是非題,而那個答案是精確到奈秒的一個數字。

Flush+Reload, one round, measuring one cache line L:

  1. clflush(L)         // evict L: guarantee it is NOT cached
  2. run the victim     // victim may or may not touch L
  3. t0 = rdtsc()       // read cycle counter
     x = *L             // load from L
     t1 = rdtsc()
  4. if (t1 - t0) is SMALL  -> L was cached -> victim TOUCHED L
     if (t1 - t0) is LARGE  -> L was not cached -> victim did NOT

  fast ~  50 cycles  (hit)
  slow ~ 300 cycles  (miss to DRAM)
  the gap is the message.
一輪 Flush+Reload 讀出受害者行為的一個位元:它碰過線 L 了嗎?快取命中與未命中之間那道巨大而可靠的落差就是整具發送器——不需要任何臭蟲,只要一個計時器。

現在把一個是非題變成一整個位元組,因為這正是本篇其餘部分倚賴的訣竅。鋪出一個由 256 條各自獨立的快取線組成的探測陣列,一條對應一個位元組的每一種可能值(0 到 255)。要洩漏一個祕密位元組 `s`,安排受害者去讀 `probe[s * 64]`——那恰好碰到 256 條線中的條,也就是其索引正是那個祕密的那一條。然後攻擊者重載全部 256 條線、對每一條計時。恰好有一條快速地回來;它的索引就是那個祕密值。一個單一的是非題原語,複製成 256 路,就成了一次完整的 8 位元讀取,讀到一個攻擊者從不被允許直接看見的值——而受害者不過是做了一次合法的陣列存取。

推測執行:CPU 跑了一段「從未發生」的程式碼

快取是接收器。發送器是現代 CPU 裡最深的那個想法,也是讓 Spectre 與 Meltdown 成為可能的那一個。從效能那級你遇過亂序執行分支預測。一個現代 CPU 不會等。當它撞上一個分支——`if (x < array_len)`——它無法在那個比較解析完之前空轉等待,於是它分支會往哪走、並搶先執行那條被預測的路徑。這就是推測執行:押注上做的工作,在 CPU 還不知道押注對不對之前就做了。如果猜對了,那份工作已經做完、程式就飛了起來。如果猜錯了,CPU 就把那份推測的工作丟掉——它回捲暫存器、棄置結果、悄悄走上另一條路。從架構上看,就好像那些被誤推測的指令從未跑過。

把最後那句再讀一遍,因為它藏著這十年的那個瑕疵。「就好像從未跑過」在架構層次上是真的——也就是程式被允許看見的暫存器與記憶體那個層次。但它在微架構層次上不是真的。當 CPU 推測性地執行了那些註定要被丟棄的指令時,如果它們之中有任何一個做了一次記憶體讀取,那次讀取就把一條線拉進了快取——而回捲並不會把它逐出。架構狀態被乾淨地復原了;快取卻沒有。那份推測的工作在恰好是 Flush+Reload 能讀的那個地方留下了足跡。推測執行給了你「執行了卻『沒發生』」的指令,而快取記得它們碰過什麼。把快取接收器與推測發送器擺在一起,你就有了整個攻擊。

Meltdown 與 Spectre:濫用同一道縫隙的兩種方式

兩個攻擊都在 2018 年 1 月一同揭露,都用了「推測執行留下快取痕跡」這道縫隙——但它們以不同方式濫用它,而那個不同很要緊。Meltdown 攻擊記憶體保護本身。正常情況下,一個讀取核心位址的使用者模式程式會得到一個錯誤、永遠看不到那個值。但在受影響的 Intel CPU 上,當程式推測性地讀一個被禁的核心位元組時,權限檢查與資料擷取是平行發生的——於是在一段短暫的推測窗口裡,晶片在錯誤被遞送之前就把那個祕密位元組交給了後面的指令。那些後面的指令用那個祕密位元組去索引探測陣列、把一條線拉進快取。然後錯誤抵達、那次架構讀取被作廢、程式看不到任何違法的事發生。但快取痕跡已經鋪好,Flush+Reload 就從中讀出了那個核心位元組。Meltdown 實際上熔穿了使用者與核心記憶體之間的那道牆。

Spectre 更為隱微、更為普遍、也更難根除。它不破壞權限檢查;它訓練分支預測器去背叛它自己的程式。攻擊者反覆用界內的輸入去呼叫一個受害函式,好讓預測器學到「這個邊界檢查總是通過」,然後用一個越界的索引呼叫它一次。那個檢查 `if (x < array_len)` 終究會失敗、那次存取會被回捲——但剛被訓練好的預測器猜這個檢查會通過、並用那個惡意的 `x` 推測性地跑了函式主體。在那段推測窗口裡,程式碼讀了遠遠越界的 `array[x]`——一個它從不該觸及的祕密——並立刻用那個祕密去索引探測陣列、給快取播了種。邊界檢查做了它的工作、擋下了架構讀取;推測執行還是跑了主體,趕在檢查解析之前。Spectre 把受害者自己正確的程式碼變成了那個洩漏的小工具。

  1. 找到一個小工具。在受害者裡找出形如 `if (x < len) { y = array2[ array1[x] * 64 ]; }` 的程式碼——一個邊界檢查守著一次載入,而那次載入的結果又去索引第二個陣列。真實的二進位檔滿是這個形狀。
  2. 訓練預測器。用有效的、界內的 x 呼叫那個小工具很多次,好讓分支預測器強烈地學到「這個檢查會通過」。這是純粹、合法的暖身——還沒有任何規則被打破。
  3. 清除接收器。把 array2(那個 256 格的探測陣列)的每一條線都 clflush 出快取,好讓計時通道乾淨開始,於是之後任何被快取的線都是一個訊號。
  4. 誘發誤推測。用一個指向祕密的越界 x 呼叫那個小工具。檢查會失敗,但被訓練過的預測器推測性地跑了主體、讀了那個祕密位元組、並用它去索引 array2。
  5. 重載並讀取。對那 256 條探測線各做一次載入並計時。恰好有一條是快的;它的索引就是那個祕密位元組。在不同位址上重複,就能傾印出你想要的那麼多記憶體。

防守那道縫隙——並對代價誠實

因為洩漏騎在硬體做它的本分上,防禦是笨拙的、分層的、而且從不真正免費——把這點直說出來正是理解這個主題的一部分。Meltdown 既是一個特定的硬體瑕疵,就得到了較乾淨的修正:一個叫做核心分頁表隔離的作業系統變更,它乾脆把核心的記憶體從一個使用者行程所見的分頁表取消映射,於是根本沒有核心位址在場可供推測讀取。它有效,但它在每一次系統呼叫上加了一次分頁表切換,而在系統呼叫密集的工作負載上那個代價是量得出來的——一份用安全換來的真實減速。較新的 CPU 在矽晶層次修掉了底層的競爭,於是那邊的軟體繞道可以關掉。Meltdown 至少有一個終點。

Spectre 是那個難的,而誠實要求承認它沒有單一乾淨的解藥。因為它濫用的是推測執行本身——一個每一顆快 CPU 都倚賴的功能——你不能就這麼把它關掉、卻不丟掉那個讓晶片有存在理由的效能。防禦是一個雜物袋,每一樣堵住一個變種。編譯器能在一個敏感的邊界檢查之後插入一個推測屏障(在 x86 上是 lfence 指令),逼 CPU 在跑主體之前等待那個檢查解析——正確,但若到處灑就慢。它們也能把那個危險的模式改寫成一種遮罩形式,即使在被誤推測的路徑上也把索引箝制進範圍,使一次越界讀取變得無害。硬體加了新指令,用來跨權限邊界清除或分割分支預測器。沒有一樣是普遍的;每一樣都是蓋在「那個小工具能形成的某一種方式」之上的補丁。

拉遠來看,讓這最後一篇誠實地為這一級收尾。最深的防禦根本不是一個補丁,而是一個架構姿態:假設任何與不受信任的程式碼共享一塊快取、一顆核心、或一個預測器的祕密都可能洩漏,並把祕密留在那個爆炸半徑之外。這就是為什麼瀏覽器把每一個網站放進它自己的行程——好讓惡意 JavaScript 裡的一個 Spectre 小工具能讀它自己行程的記憶體、卻讀不到另一個網站的。這也是為什麼高保證系統傾向把敏感運算隔離到分開的核心上、或放進一個可信執行環境裡。旁路是這一級最後的、也是最謙卑的一課:即使沒有溢位、沒有劫持、沒有被損毀的位元組——即使每一條指令都正確——一次運算如何執行,都可能背叛它在什麼之上運算。安全不只是關於防止錯的事;它也是關於確保對的事守住它的祕密。