摘要:幽靈攻擊包括誘使受害者投機性地執行在正確程式執行期間不會發生的操作,並通過側通道將受害者的機密資訊洩露給攻擊者。
本文分享自華為雲社群《幽靈攻擊與編譯器中的消減方法介紹》,作者:畢昇小助手 。
現代處理器使用分支預測和推測執行來最大限度地提高效能。例如,如果分支的目標取決於正在讀取的記憶體值,CPU將嘗試猜測目標並嘗試提前執行。當記憶體值最終到達時,CPU要麼丟棄,要麼提交推測計算。投機邏輯在執行方式上是不可信的,可以存取受害者的記憶體和暫存器,並可以執行具有可觀副作用的操作。幽靈攻擊包括誘使受害者投機性地執行在正確程式執行期間不會發生的操作,並通過側通道將受害者的機密資訊洩露給攻擊者。
注:幽靈攻擊有很多變體,比如 Spectre Variant 1/2/3/3a/4、L1TF, Foreshadow (SGX)、MSBDS, Fallout、TAA, ZombieLoad V2等[1], 這裡只介紹 spectre v1, 其他幾個變體暫不涉及。
亂序執行:又稱無序執行,它允許程式指令流下游的指令與先前指令並行執行,有時甚至在先前指令之前執行,從而提高了處理器元件的利用率。
投機性執行:通常,處理器不知道程式的未來指令流。例如,當無序執行到達條件分支指令時,就會發生這種情況,該指令的方向取決於先前指令執行情況。在這種情況下,處理器可以保留其當前暫存器狀態,預測程式將遵循的路徑,並推測地沿著路徑執行指令。如果預測結果是正確的,則提交(即儲存)推測執行的結果,從而產生比等待期間CPU空轉的效能優勢。否則,當處理器確定它遵循了錯誤的路徑時,它通過恢復其暫存器狀態並沿著正確的路徑繼續,放棄 "推測執行" 的工作。
分支預測:在推測執行期間,處理器猜測分支指令的可能結果。更好的預測通過增加可以成功提交的推測性執行操作的數量來提高效能。
記憶體層次結構:為了彌合較快處理器和較慢記憶體之間的速度差距,處理器使用連續較小但較快的快取的層次結構。快取將記憶體劃分為固定大小的塊,稱為行,典型的行大小為64或128位元組。當處理器需要記憶體中的資料時,它首先檢查層次結構頂部的L1快取是否包含副本。在快取命中的情況下,即在快取中找到資料,從L1快取中檢索並使用資料。否則,在快取未命中的情況下,重複該過程,嘗試從下一個快取級別檢索資料,最後從外部記憶體檢索資料。一旦讀取完成,資料通常儲存在快取中(以前快取的值被驅逐以騰出空間),以防在不久的將來再次需要資料。
微體系結構側通道攻擊:我們上面討論的所有微體系結構元件都通過預測未來的程式行為來提高處理器效能。為此,他們維護依賴於過去程式行為的狀態,並假設未來行為與過去行為相似或相關。當多個程式同時或通過分時在同一硬體上執行時,由一個程式的行為引起的微體系結構狀態的變化可能會影響其他程式。這反過來又可能導致意外資訊從一個程式洩露到另一個程式。初始微體系結構側通道攻擊利用時序可變性和通過 L1 cache 的洩漏從密碼原語中提取金鑰。多年來,通道已經在多個微體系結構元件上得到了演示,包括指令快取、低階快取、BTB 和分支歷史。目標已經擴大到包括共址檢測、打破 ASLR、擊鍵監測、網站指紋識別和基因組處理。最近的結果包括跨核和跨CPU攻擊、基於雲的攻擊、對可信執行環境的攻擊、來自移動程式碼的攻擊以及新的攻擊技術。
思考下面這個程式,可否在不輸入正確密碼情況下通過驗證?(答案有很多,比如 24 個 1)
int main() { int ret = 0; char def_password[8] = "1234567"; char save_password[8] = {0}; char password[8] = {0}; while(true) { printf("please input password: "); scanf("%s",password); memset(save_password,0,sizeof(save_password)); if (strcmp(password, def_password)) { // 比較是否和密碼一致 printf("incorrect password!\n\n"); } else { printf("Congratulation! You have passed the verification!\n"); strcpy(save_password, password); break; } } return ret; }
這裡對陣列的存取沒有檢查下標是否合法,但是更進一步的思考下,加上長度檢查就一定安全了嗎?
實際上,軟體即使沒有漏洞,陣列存取時都加了下標有效性檢查,也是不一定是安全的。考慮一個例子,其中程式的控制流依賴於位於外部實體記憶體中的未快取值。由於此記憶體比CPU慢得多,因此通常需要幾百個時鐘週期才能知道該值。這時候,CPU會通過 投機執行 把這段空閒時間利用起來,從安全的角度來看,投機性執行涉及以可能不正確的方式執行程式。然而,由於CPU的設計了通過將不正確的投機執行的結果恢復到其先前狀態來保持功能正確性,因此這些錯誤以前被認為是安全的。
具體的,比如下面的程式碼片段(完整的在論文[2]最後):
if (x < array1_size) y - array2[array1[x] * 4096]
假設變數 x 包含攻擊者控制的資料,為了確保對 array1 的記憶體存取的有效性,上面的程式碼包含了一個 if 語句,其目的是驗證x的值是否在合法範圍內。接下來我們將介紹一下攻擊者如何繞過此if語句,從而從程序的地址空間讀取潛在的祕密資料。
首先,在初始錯誤訓練階段,攻擊者使用有效輸入呼叫上述程式碼,從而訓練分支預測器期望 if 為真。接下來,在漏洞攻擊階段,攻擊者在 array1 的邊界之外呼叫值 x 的程式碼。CPU不會等待分支結果的確定,而是猜測邊界檢查將為真,並已經推測性地執行使用惡意x存取 array2[array1[x]*4096] 的指令。
請注意,從 array2 讀取的資料使用惡意x將資料載入到依賴於 array1[x] 的地址的快取中,並進行對映,以便存取轉到不同的快取行,並避免硬體預取效應。當邊界檢查的結果最終被確定時,CPU 會發現其錯誤,並將所做的任何更改恢復到其標稱微體系結構狀態(nominal microarchitectural state)。但是,對快取狀態所做的更改不會恢復,因此攻擊者可以分析快取內容,並找到從受害者記憶體讀取的越界中檢索到的潛在祕密位元組的值。
攻擊的消減方法主要有以下幾個:
關於使用編譯器(如畢昇編譯器)進行 spectre v1 的消減在LLVM社群[4] 已有針對函數級別的方案,使用方法如下:
1.為單個函數新增屬性.
void f1() __attribute__((speculative_load_hardening)) {}
2.為程式碼片段新增函數屬性.
#pragma clang attribute push(__attribute__((speculative_load_hardening)), apply_to = function) void f2(){}; #pragma clang attribute pop
3.新增編譯選項, 整體使能幽靈攻擊防護
-mllvm -antisca-spec-mitigations=true
-mllvm -debug-only=aarch64-speculation-hardening # 檢視偵錯資訊
下面簡單介紹一下編譯器(如畢昇編譯器)的消減原理[3]。
原始程式碼:
if (untrusted_value < limit) val = array[untrusted_value]; // Use val to access other memory locations
生成的組合大概是這樣:
CMP untrusted_value, limit B.HS label LDRB val, [array, untrusted_value] label: // Use val to access other memory locations
消減後:
if (untrusted_value < limit) val = array[untrusted_value < limit ? untrusted_value : 0]; // Use val to access other memory locations
生成的組合大概是這樣:
CMP untrusted_value, limit B.HS label CSEL tmp, untrusted_value, WZr, LO CSDB LDRB val, [array, tmp] label: // Use val to access other memory locations
可以看到,我們主要使用 CSEL+CSDB(Consume Speculative Data Barrier) 兩個指令組合進行消減,CSEL 指令引入一個臨時的變數,如果沒有投機執行,這個指令看起來是多餘的,因為它還是會等於 untrusted_value, 在投機執行且推測錯誤的情況下,臨時變數的值就變成了0,且 CSDB 確保 CSEL 的結果不是基於預測的。
附:編譯器防護前後反組合對比
主要程式碼在檔案 AArch64SpeculationHardening.cpp, 雖然 LLVM社群[4] 有很多討論[5],但程式碼一共只有七百行左右,主要有三個步驟:
1.啟用自動插入投機安全值。
// 對於可能讀寫記憶體的指令(不止是load), 加固其相關的暫存器 MachineInstr &MI = *MBB.begin(); if (MI.mayLoad()) BuildMI(MBB, MBBI, MI.getDebugLoc(), TII->get(Is64Bit ? AArch64::SpeculationSafeValueX : AArch64::SpeculationSafeValueW)) .addDef(Reg) .addUse(Reg);
其中: 如果全是load到 GPR(通用暫存器),就對暫存器加固,否則對地址加固,因為 mask load 的值預計會導致更少的效能開銷,因為與 mask load 地址相比,load 仍然可以推測性地執行。但是,mask 只在 GPR暫存器上很容易有效地完成,因此對於load到非GPR暫存器中的負載(例如浮點load),mask load 的地址。
2.將消減程式碼新增到函數入口和出口(初始化)。
for (auto Entry : EntryBlocks) insertSPToRegTaintPropagation( *Entry, Entry->SkipPHIsLabelsAndDebug(Entry->begin())); ... // CMP SP, #0 === SUBS xzr, SP, #0 BuildMI(MBB, MBBI, DebugLoc(), TII->get(AArch64::SUBSXri)) .addDef(AArch64::XZR) .addUse(AArch64::SP) .addImm(0) .addImm(0); // no shift // CSETM x16, NE === CSINV x16, xzr, xzr, EQ BuildMI(MBB, MBBI, DebugLoc(), TII->get(AArch64::CSINVXr)) .addDef(MisspeculatingTaintReg) .addUse(AArch64::XZR) .addUse(AArch64::XZR) .addImm(AArch64CC::EQ);
3.將消減程式碼新增到每個基本塊。
BuildMI(SplitEdgeBB, SplitEdgeBB.begin(), DL, TII->get(AArch64::CSELXr)) .addDef(MisspeculatingTaintReg) .addUse(MisspeculatingTaintReg) .addUse(AArch64::XZR) .addImm(CondCode); SplitEdgeBB.addLiveIn(AArch64::NZCV); ... BuildMI(MBB, MBBI, DL, TII->get(AArch64::HINT)).addImm(0x14);
其他說明,這個方案依賴於 X16/W16 暫存器(CSEL 要用到),如果已經被使用,則只能插入記憶體屏障指令:
BuildMI(MBB, MBBI, DL, TII->get(AArch64::DSB)).addImm(0xf); BuildMI(MBB, MBBI, DL, TII->get(AArch64::ISB)).addImm(0xf);
支援軟體安全技術的一個基本假設是,處理器將忠實地執行程式指令,包括其安全檢查。本文介紹的幽靈攻擊,它利用了投機執行違反這一假設的事實。實際攻擊的範例不需要任何軟體漏洞,並允許攻擊者讀取私有記憶體並從其他程序和安全上下文註冊內容。軟體安全性從根本上取決於硬體和軟體開發人員之間對CPU實現允許(和不允許)從計算中暴露哪些資訊有明確的共識。因此,雖然文中描述的對策可能有助於在短期內限制攻擊,但它們只是權宜之計,因為最好有正式的體系結構保證,以確定任何特定程式碼構建在當今的處理器中是否安全。因此,長期解決方案將需要從根本上改變指令集體系結構。更廣泛地說,安全性和效能之間存在權衡。本文中的漏洞以及許多未介紹的漏洞都來自於技術行業長期以來對最大限度地提高效能的結果。這之後,處理器、編譯器、裝置驅動程式、作業系統和許多其他關鍵元件已經進化出了複雜優化的複合層,從而引入了安全風險。隨著不安全成本的上升,這些設計需要選擇性修改。在許多情況下,需要改為安全性優化的替代實現。[2]