Meltdown的分析——完整版;-)

SL7發表於2022-04-15

我把攻擊細節放在最前面,因為這最有可能是你點這篇文章的原因。理解這個攻擊需要的一些更加詳細的知識在下面,也歡迎閱讀。(4.15開始寫,20號終於差不多寫完了……)

Meltdown 攻擊細節

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
char buf[8192]
clflush buf[0]
clflush buf[4096]
 
<some expensive instruction like divide>
 
r1 = <a kernel virtual address>;
r2 = *r1;
r2 = r2 & 1;
r2 = r2 * 4096;
r3 = buf[r2];
 
<handle the page fault from r2=*r1>
 
a = rdstc
r0 = buf[0]
b = rdstc
r1 = buf[4096]
c = rdstc
if b-a < c-b low bit probabaly is 0
  1. 首先宣告快取是2個Page大小,後面會把從核心竊取的1bit乘上4096得到的要麼是0要麼是1,這會導致要麼buf[0]在cache裡面,要麼buf[4096]在cache裡面。至於為什麼不乘其他數,這裡要求相乘後的結果離得足夠遠,CPU有預獲取的,因此硬體會載入相記憶體相鄰的資料到cache,離太近不就區分不了這兩種情況了麼?
    然後用CPU clflush指令把原先的buf[0]和buf[4096]填上junk資料
  2. 第5行可能沒有必要,但是後面第8透過CPU預測執行竊取核心資料一定會發生PageFault,retired的時候會取消執行效果。要攻擊成功,需要CPU預測執行(Speculative execution)幾條指令至少要到第11行。而確認retired的時間越靠後,那成功的機率越大,因此需要執行一些更耗時的指令(比如除法啦)為後面爭取時間。
  3. Page Fault的時間可能會被推遲,但是一定會發生。發生過後要讓程式碼繼續執行可以透過註冊Page Fault Handler來繼續重新獲得控制權,是有方法能夠在Page Fault發生後仍然繼續執行15-20步的攻擊步驟。
  4. 接下來就是透過CPU cycle來判斷buf[0]和buf[4096]哪一個更有可能在記憶體裡,進而推出竊取的那一個核心bit位是0還是1

Meltdown的原理

CVE-2017-5754(Meltdown)雖然已經被修復,而距離它的發生也已經過了幾年了。但是研究這個漏洞仍然能夠增加對作業系統記憶體管理的理解,而下一個這樣的漏洞在那裡?仍然令人期待;-)

 

OS為本質上就是代替User操縱底層硬體的一個軟體,它需要做到很重要的一點就是隔離。對很多個使用者的隔離,對底層硬體的隔離,隔離是所有安全的基礎。Metldown漏洞其實就是有人在猜測硬體CPU的執行細節,一般來說硬體的細節被隱藏,是不能夠被上層使用者知道的。

1
2
3
4
5
6
7
//core of Metltdown attack
char buf[8192];
r1 = <a kernel virtual address>;
r2 = *r1;
r2 = r2 & 1;
r2 = r2 * 4096;
r3 = buf[r2];
  • buffer就宣告瞭一個正常的,普通使用者可以使用的記憶體,當然是從0開始一個虛擬記憶體,要被重新對映才能被MMU找到。
  • r1是某個感興趣的虛擬地址
  • 將r1裡的內容取出放到r2內
  • 取下r2的低bit位,這裡只是1位
  • 因為一個bit要麼是0,要麼是1因此這裡的r2要麼是4096,要麼是0
  • 可以取到buffer的0位或1位

問題1:為什麼要進行這樣的操作?
因為使用者能得到記憶體裡的資料卻不能訪問記憶體裡真實的地址。所有指令對應的都是虛擬地址,透過pagetable來查詢對應,而如果是SV39模式下,每個PTE表項會有一個許可權標誌位Valid來表示使用者的許可權,如果這一位沒有被設定,那使用者其實是不能越權訪問的。

 

問題2:為什麼在現在的場景下這樣的攻擊失效?
這個攻擊能成功的最大前提就是核心的地址被直接對映到使用者程式的記憶體空間裡了,所以CPU的執行模式才能被猜中啊,而現在已經不是這樣了。也就是當使用者程式碼在執行時,完整的核心PTE也出現在使用者程式的Page Table中,但是這些PTE的pte_u位元位沒有被設定,所以使用者程式碼在嘗試使用核心虛擬記憶體地址時,會得到Page Fault。但這一個表項仍然存在。

這樣的攻擊為什麼會成功呢?

這其實依賴於CPU的一些特性,一個是Speculative execution(預測執行),另一個是CPU快取。

Speculative execution(預測執行)

(插句嘴,預測執行其實是很經典的CS提升效能的方法啊,如果能夠被預測,那就可以讓大機率事件發生時的響應加快,但就是往往沒法預測準啊……)

1
2
3
4
5
6
7
8
9
//example of speculation execution
r0 = <something>;
r1 = valid;
if(r1 == 1){
    r2 = *r0;
    r3 = r2 + 1;
}else{
    r3 = 0;
}
  • 這裡r0是某個記憶體地址,r1是某個能夠被訪問的變數
  • 接下來需要要做的if其實是一個分支判斷(branch prediction)選擇某一個要執行的岔路口
  • 如果r1的值為1,就把r0暫存器裡面的值取出來放到r2裡,然後+1後再傳給r3;否則把r3設為0

這個邏輯是很簡單的,但是站在CPU的角度上,第3行對應的load指令,可能會使2Ghz的CPU消耗掉數百個CPU cycle,而這每一個cycle都可以執行一個指令的。因此branch prediction就是在得到r1並且在對r1做判斷得出結果之前,提前執行5、6、8步,哪怕它目前沒有得到足夠多的資訊來做判斷,CPU其實也是在賭。

 

CPU的賭有一些很有趣的地方,它在提前執行的5、6步的時候儲存在臨時暫存器上,只有當它確實賭對了之後才會讓這幾步操作真正生效。而r0是一個有效的地址還好說,但在提前預測執行的情況下,即使r0無效或者許可權pte_u沒有被設定也會被取出執行,Metldown attack在ARM上並不能攻擊成功,猜測可能就是在預測執行的時候也是判斷了許可權.他也不能產生Page Fault,萬一if要執行的是第8行呢?hhhhhhh~~

 

CPU判斷自己有沒有賭對,也就是執行是不是正確的時刻被叫做retired,如果retired執行正確先前的每一步都會生效,但如果賭錯,先前的執行會被拋棄。而且就在這個簡單的例子裡面,CPU也賭了兩件事,if要選一條執行,如果執行5\6,r0一定要有效。

 

Intel的AMD沒有披露太多的細節,同時CPU所做的這一切其實都是透明的。當第4行的retired返回的時候,如果預測失敗,所有的執行都會被回滾,你不應該看到你不該看到的東西。Mirco-Architecture的細節保密,但是很多人都對它有興趣,因為這關乎效能,當然也關乎安全。不過寫編譯器的人應該知道的多一點,因為很多編譯器的最佳化不就基於CPU的特性麼?

CPU快取

CPU如果是多核,很可能有不止一級快取,有一種模式是每個CPU都有自己的L1(Level 1第1級快取)、L2快取,然後公用L3。其中L1是虛擬記憶體地址,但是L2是記憶體實體地址。L1是最快最小的快取,L1命中可能只要幾個CPU cycle,如果查不到,L2快取命中需要幾十個CPU cycle,還找不到那只有去RAM了那就需要幾百個CPU cycle了。
這有個問題:TLB和MMU是在哪一級上的?我認為它是與L1 cache並列的。如果你miss了L1 cache,你會檢視TLB並獲取實體記憶體地址。MMU並不是一個位於某個位置的單元,它是分佈在整個CPU上的。
不過在L1、L2裡面核心空間和用換空間切換的時候(先前)是沒有更換Page Table的,L1Cache甚至沒有被清空,kernel的PTE表項存在只是不能被使用者訪問,它對使用者透明但是你不能訪問。這一點就成了攻擊的關鍵。之所以這樣做,又處於對效能的考慮,同時包含核心和使用者態的頁表能讓系統呼叫速度變快。

Meltdown為什麼和CPU cache相關?

要更清晰地回答這個問題需要知道CPU cache使用的一些細節。要實際進行攻擊還需要對CPU進行Flush and Reload。說到底,計算機底層硬體能夠理解的只有0和1。那麼對於任何資訊(比如密碼),只要一次能猜中它的某一位是0或1,算上中間某些失敗,進行個幾百萬次的猜測就有可能徹底破解。這背後的原理就是要找到特定的程式碼是否使用了特定的記憶體地址,而某一位是0或1就決定了怎麼使用記憶體,這樣反推了某一位究竟是什麼。

CPU Flush and Reload

要想知道某個函式是不是使用了特定的記憶體地址,這個原理其實也不復雜

1
2
3
4
5
6
cflush address:x
f()
a = rdstc
junk = xxxx
b = rdstc
b-a
  • 我們對地址x感興趣,我們希望確保先前地址x肯定不在cache裡面。Intel CPU有一條指令clflush,它接受一個記憶體地址並且確保這個記憶體地址不在快取裡面。但是即使沒有這麼精簡的指令仍然可以做到這一點,如果知道cache是64KB,那你直接load 64KB的隨機資料就好,先前cache中的資料就被沖走了。
  • 接下來想知道f()函式有沒有使用x地址,那先呼叫它再說
  • 接下來利用的其實就是資料的就近原則,CPU從記憶體地址載入資料一般是會把它周圍的資料也載入進去。rdstc是用來統計時間的,這裡的量級都在ns級別,需要硬體級別的指令才能統計時間。
  • 第五行是載入先前扔進去flush的垃圾地址,如果f()使用了地址x,情況下x在cache裡面,這個載入速度就會很快,b-a也就是幾個、幾十個CPU cycle,當然也有可能地址x被載入到cache裡面後面又被刪除或者轉移,但是簡單情況下如果b-a比較小,就可以認定f()使用了地址x。

Meltdown Fixed

其實透過猜測CPU等硬體的執行模式來進行攻擊早就存在,而且過去那麼多年為CPU執行的很多炫酷指令也未必能保證安全。但是Meltdown讓人發現這種攻擊手段能夠破壞作業系統的隔離性,這種漏洞的利用確實非常精彩。

 

透過先前的分析修復手段至少有2種。
一種是KAISER,現在是Linux中被稱為KPTI的技術(Kernel page-table isolation)。這個想法很簡單,也就是不將核心記憶體對映到使用者的Page Table中,在系統呼叫時切換Page Table。那透過先前的方式根本不可能猜到核心資料,因為切換頁表肯定會重新整理Cache和記憶體。剛才程式碼裡代表核心虛擬記憶體地址的r1暫存器不僅是沒有許可權使用了,簡直沒有意義了。現在核心虛擬記憶體地址不會存在於cache中,甚至都不會出現在TLB中。

 

還有一種是修復硬體,就是CPU進行Speculative execution的時候也要核對許可權,那也沒法載入核心資料了。

 

參考資料:https://pdos.csail.mit.edu/6.828/2020/readings/meltdown.pdf

相關文章