行到Flush+Reload:緩存?zhèn)刃诺缹嶒炘砼c工程實踐)
1. 從一條緩存命中說起這個實驗到底在做什么第一次接觸這個實驗的人大多會被標題里的詞嚇一跳。其實把外殼剝掉它要講的事情非常樸素現(xiàn)代處理器為了跑得快會在你還沒明確要求讀某個內(nèi)存地址之前就猜你可能要讀然后提前把數(shù)據(jù)搬進緩存。這個猜的行為本身沒問題問題在于——猜錯了之后處理器雖然會把結果丟掉但緩存里的痕跡不會立刻消失。于是攻擊者可以通過測量哪個地址讀得快反推出剛才處理器偷偷碰過哪個地址進而讀到本不該讀的數(shù)據(jù)。這個實驗就是圍繞這條鏈路設計的先制造一個越權訪問的瞬間再通過緩存?zhèn)刃诺腊涯莻€瞬間泄露的信息撈出來。它屬于體系結構安全里最經(jīng)典的一類動手實驗適合已經(jīng)學過操作系統(tǒng)、計算機組成原理想真正搞明白CPU 到底怎么泄露秘密的同學。你不需要什么特殊設備一臺普通的 x86 機器、一個 Linux 環(huán)境、一段 C 代碼就夠了。我把它拆成三層來理解第一層是原理為什么亂序執(zhí)行會留下可觀測的副作用第二層是手法怎么用 FlushReload 把緩存狀態(tài)變成可讀信號第三層是工程怎么把噪聲壓下去、把成功率提上來。很多人卡在第二層和第三層之間原理看懂了代碼跑出來全是噪聲這篇就把這幾層的細節(jié)都攤開講。注意本文討論的所有內(nèi)容僅用于理解處理器微架構的公開教學原理請在自己的實驗環(huán)境中進行不要用于任何未授權的系統(tǒng)。2. 原理拆解亂序執(zhí)行為什么會說漏嘴2.1 現(xiàn)代處理器的先斬后奏機制要理解這個實驗得先接受一個反直覺的事實CPU 執(zhí)行指令的順序和你寫的代碼順序往往不是一回事。編譯器會重排處理器自己也會重排。這種能力叫亂序執(zhí)行Out-of-Order Execution它的目的是填滿流水線不讓昂貴的執(zhí)行單元閑著。打個比方你去餐廳點餐廚師不會等你把整張菜單念完才開火。他聽到一份炒飯就先把鍋熱上等你念到再加個湯時炒飯已經(jīng)在鍋里了。如果最后你說算了炒飯不要了廚師把炒飯倒掉就行但灶臺已經(jīng)熱過、鍋已經(jīng)動過——這個動過的痕跡就是這個實驗要利用的東西。處理器也一樣。當它遇到一條需要等待的指令比如要讀的內(nèi)存不在緩存里得等幾百個周期它不會干等而是往后看幾條指令挑那些不依賴當前結果的先執(zhí)行。這些搶跑的指令如果最后被證明不該執(zhí)行處理器會回滾它們的架構狀態(tài)寄存器、內(nèi)存但微架構狀態(tài)緩存、分支預測器往往不會完全回滾。2.2 權限檢查與數(shù)據(jù)搬運的時間差關鍵點來了。當一條指令試圖讀取一個它沒有權限訪問的地址時處理器會做權限檢查。但這個檢查發(fā)生在流水線的某個階段而數(shù)據(jù)從內(nèi)存搬到緩存的動作可能已經(jīng)在這個檢查完成之前就啟動了。換句話說權限檢查說不行的時候數(shù)據(jù)可能已經(jīng)在緩存里躺著了。處理器隨后會拋出一個異常程序看起來什么都沒讀到但緩存里那個不該存在的副本成了攻擊者的信標。這里有個容易被忽略的細節(jié)并不是所有處理器、所有版本都受這個時間差影響。不同微架構對權限檢查的位置、對推測執(zhí)行的限制都不一樣。所以做這個實驗時你會發(fā)現(xiàn)同一段代碼在不同機器上表現(xiàn)差異很大這不是你寫錯了而是硬件行為本身就有差異。2.3 緩存為什么能當測謊儀緩存的核心特性是快。從 L1 緩存讀數(shù)據(jù)大概幾個周期從主存讀要一兩百個周期。這個數(shù)量級的差距用高精度計時器一測就出來了。于是攻擊邏輯就變成這樣準備一塊大緩沖區(qū)先把整塊緩沖區(qū)從緩存里踢出去flush然后觸發(fā)一次越權訪問讓處理器猜著去讀某個秘密值并用這個秘密值當索引去訪問緩沖區(qū)里的某個位置。如果秘密值是 42那么緩沖區(qū)偏移 42 的那塊數(shù)據(jù)就被帶進了緩存。最后攻擊者遍歷整個緩沖區(qū)測量每一塊的讀取時間哪一塊特別快哪一塊就是被訪問過的秘密值就是它的索引。整個過程里攻擊者從來沒有合法地讀到那個秘密值他讀到的只是哪塊內(nèi)存變快了。這就是側(cè)信道攻擊的精髓不直接偷內(nèi)容而是偷內(nèi)容的影子。3. 實驗環(huán)境搭建把地基打牢3.1 硬件與系統(tǒng)的選擇這個實驗對硬件有實打?qū)嵉囊蟛皇请S便一臺虛擬機就能跑出漂亮結果的。項目推薦配置說明CPU 架構x86_64該實驗的經(jīng)典版本針對 x86 設計ARM 上行為差異大物理機 vs 虛擬機優(yōu)先物理機虛擬機的緩存行為被宿主干擾噪聲極大操作系統(tǒng)主流 Linux 發(fā)行版需要能加載內(nèi)核模塊、能讀/proc編譯器gcc 或 clang需要能控制優(yōu)化等級權限能執(zhí)行特權操作部分步驟需要加載模塊或調(diào)整配置我踩過的第一個坑就是在云主機上跑結果全是噪聲。原因是云主機是多租戶共享物理核你的緩存隨時被鄰居刷掉。所以能上物理機就上物理機實在不行也要找獨占核的機器。3.2 關閉干擾源在開始之前有幾件事必須先做否則你測出來的時間全是抖動的關閉地址空間隨機化echo 0 /proc/sys/kernel/randomize_va_space。地址每次變你的緩沖區(qū)偏移就對不上實驗沒法復現(xiàn)。固定 CPU 頻率把調(diào)頻策略設成 performance避免 CPU 在測量中途降頻導致時間基準漂移。綁定 CPU 核心用taskset把進程綁到某個核上減少遷移帶來的緩存失效。關閉超線程干擾如果條件允許盡量讓測量線程獨占物理核別和別的線程共享。這些操作看起來瑣碎但每一條都直接影響信噪比。我見過太多人代碼寫得沒問題就是因為沒關隨機化跑十次錯十次。3.3 計時工具的選擇測量緩存命中與否靠的是高精度計時。常用的有幾種rdtsc指令讀時間戳計數(shù)器開銷極低適合細粒度測量但要注意它可能被亂序執(zhí)行影響需要配合lfence做序列化。clock_gettime精度到納秒開銷比 rdtsc 大但更穩(wěn)定。性能計數(shù)器通過perf讀取緩存命中/未命中事件更直接但需要權限且粒度較粗。在這個實驗里主流做法是用rdtsc。原因是它足夠快測量本身的開銷不會淹沒信號。但用rdtsc有個經(jīng)典陷阱它自己可能被亂序執(zhí)行提前讀導致測出來的時間不準。解決辦法是在rdtsc前后加內(nèi)存屏障強制序列化。static inline uint64_t rdtsc_begin() { uint64_t a, d; asm volatile(mfence\n\t rdtsc\n\t mov %%rax, %0\n\t mov %%rdx, %1\n\t : r(a), r(d) : : %rax, %rdx, memory); return (d 32) | a; } static inline uint64_t rdtsc_end() { uint64_t a, d; asm volatile(rdtscp\n\t mov %%rax, %0\n\t mov %%rdx, %1\n\t mfence\n\t : r(a), r(d) : : %rax, %rdx, memory); return (d 32) | a; }mfence和rdtscp的作用就是保證計時指令本身不被重排否則你測的就不是你想測的那段代碼。4. 核心手法實現(xiàn)FlushReload 的完整鏈路4.1 為什么選 FlushReload緩存?zhèn)刃诺烙泻脦追N玩法比如 PrimeProbe、EvictTime、FlushReload。這個實驗用的是FlushReload原因是它最簡單、最直接、對共享內(nèi)存的依賴最小。它的邏輯只有三步Flush用clflush指令把目標地址從緩存里踢出去。觸發(fā)執(zhí)行那段會越權訪問的代碼讓處理器推測性地把秘密值對應的緩存行拉進來。Reload重新讀一遍目標地址測時間??斓木褪潜焕M來的慢的就是沒被碰過的。對比 PrimeProbeFlushReload 不需要去爭搶緩存組也不需要復雜的驅(qū)逐策略代碼量小得多。代價是它要求攻擊者能對目標地址執(zhí)行clflush在真實攻擊場景里這個前提不一定成立但在教學實驗里完全夠用。4.2 緩沖區(qū)設計與索引映射緩沖區(qū)的大小和索引映射方式直接決定實驗能不能成功。核心約束是秘密值的每一個可能取值都要對應緩沖區(qū)里一個獨立的、不共享緩存行的位置。假設秘密值是一個字節(jié)取值范圍 0 到 255。那么緩沖區(qū)至少要能容納 256 個槽位每個槽位占一個緩存行x86 上通常是 64 字節(jié)。所以緩沖區(qū)大小至少是 256 × 64 16384 字節(jié)。#define CACHE_LINE_SIZE 64 #define SLOT_COUNT 256 #define BUFFER_SIZE (SLOT_COUNT * CACHE_LINE_SIZE) char probe_buffer[BUFFER_SIZE]; // 訪問秘密值對應的槽位 void access_slot(uint8_t secret) { volatile char *addr probe_buffer secret * CACHE_LINE_SIZE; (void)*addr; }這里有個細節(jié)為什么要乘CACHE_LINE_SIZE而不是直接probe_buffer[secret]因為如果兩個不同的秘密值落在同一個緩存行里它們就無法區(qū)分了。乘上緩存行大小保證每個秘密值獨占一行測量時才能干凈地分辨。4.3 觸發(fā)越權訪問的代碼結構實驗里通常用一個受害者函數(shù)來模擬越權訪問。它的結構大致是這樣char *secret_data; // 攻擊者不該讀的數(shù)據(jù) char *probe_buffer; // 攻擊者的探測緩沖區(qū) void victim_function(size_t index) { if (index secret_size) { // 邊界檢查 uint8_t value secret_data[index]; // 越權讀取 access_slot(value); // 用秘密值索引緩沖區(qū) } }正常情況下如果index越界if判斷為假后面的代碼不會執(zhí)行。但處理器在推測執(zhí)行時可能已經(jīng)猜if為真提前把secret_data[index]讀了出來并用它去訪問了probe_buffer。等邊界檢查的結果真正出來發(fā)現(xiàn)猜錯了架構狀態(tài)回滾但probe_buffer里那一行已經(jīng)進了緩存。這就是整個實驗的心臟。你要做的就是反復觸發(fā)這個函數(shù)然后測量probe_buffer哪一行變快了。4.4 測量與統(tǒng)計把噪聲壓下去單次測量幾乎必然被噪聲淹沒。緩存狀態(tài)、中斷、調(diào)度、頻率波動任何一個都能讓你的信號消失。所以必須做多次采樣 統(tǒng)計。常見做法是對每個候選索引重復測量幾百到幾千次。記錄每次的讀取時間。用閾值把時間分成命中和未命中兩類。統(tǒng)計每個索引命中的次數(shù)取最高的那個作為猜測值。#define TRIES 1000 #define THRESHOLD 100 // 周期數(shù)需根據(jù)機器實測調(diào)整 int scores[SLOT_COUNT] {0}; for (int t 0; t TRIES; t) { // 1. flush 整個緩沖區(qū) for (int i 0; i SLOT_COUNT; i) { _mm_clflush(probe_buffer i * CACHE_LINE_SIZE); } _mm_mfence(); // 2. 觸發(fā)越權訪問 victim_function(attack_index); // 3. reload 并計時 for (int i 0; i SLOT_COUNT; i) { uint64_t start rdtsc_begin(); volatile char c probe_buffer[i * CACHE_LINE_SIZE]; (void)c; uint64_t end rdtsc_end(); if (end - start THRESHOLD) { scores[i]; } } } // 4. 找最高分 int best 0; for (int i 1; i SLOT_COUNT; i) { if (scores[i] scores[best]) best i; }閾值THRESHOLD是最需要調(diào)的參數(shù)。設太小噪聲全被當成命中設太大真正的命中被漏掉。我的經(jīng)驗是先跑一次純 flush reload 的基線看看未命中時的時間分布再跑一次故意命中的看看命中時的時間分布兩個分布之間的谷底就是合適的閾值。5. 實操中的坑與排查手冊5.1 信號完全出不來怎么辦這是最常見的問題。代碼看起來沒錯但scores數(shù)組里全是均勻分布沒有明顯峰值。按下面的順序排查現(xiàn)象可能原因排查方法所有槽位分數(shù)接近推測執(zhí)行沒發(fā)生檢查編譯器是否把if優(yōu)化掉了用-O0試試分數(shù)隨機波動噪聲太大關隨機化、綁核、提高采樣次數(shù)完全沒有峰值越權訪問沒觸發(fā)確認index確實越界且secret_data可訪問峰值位置漂移緩存行映射問題確認槽位間隔等于緩存行大小時快時慢CPU 降頻固定頻率策略我遇到過一次特別隱蔽的情況編譯器把victim_function里的if判斷整個優(yōu)化掉了因為它證明了index永遠在范圍內(nèi)。解決辦法是把secret_size聲明為volatile或者在編譯時加-O0讓編譯器別自作聰明。5.2 閾值怎么定才靠譜定閾值不要拍腦袋。正確做法是畫時間分布直方圖先只做 flush不做越權訪問測一批 reload 時間這是未命中分布。再故意讓某個槽位進緩存測一批 reload 時間這是命中分布。兩個分布通常會在某個區(qū)間分開取中間值當閾值。如果兩個分布重疊嚴重說明你的測量精度不夠要么提高計時精度要么增加采樣次數(shù)要么換臺機器。5.3 為什么換個機器結果就變了這個實驗對微架構極其敏感。不同代際的處理器對推測執(zhí)行的限制不同權限檢查的位置不同緩存替換策略也不同。有的機器上成功率 90%換一臺可能只有 10%。這不是代碼的問題是硬件的問題。所以做這個實驗時記錄你的硬件型號和微架構代號這樣結果才有可比性。如果你在報告里寫成功率 80%卻不寫機器型號那這個數(shù)字沒有意義。5.4 提高成功率的幾個實用技巧增加采樣次數(shù)從 1000 提到 10000峰值會更明顯代價是時間變長。多次攻擊取眾數(shù)對同一個秘密值攻擊多次取出現(xiàn)次數(shù)最多的結果比單次更穩(wěn)。優(yōu)化 flush 順序先 flush 再觸發(fā)順序不能反否則緩存狀態(tài)不對。減少測量循環(huán)里的分支測量代碼越簡單越好任何多余操作都會引入噪聲。用volatile防止優(yōu)化確保每次 reload 都真的去讀內(nèi)存而不是被編譯器緩存到寄存器里。提示如果你發(fā)現(xiàn)峰值時有時無先別改代碼先看看是不是后臺有進程在搶 CPU。用top看一眼往往問題就出在這。6. 從實驗到認知這件事到底告訴了我們什么6.1 性能與安全的永恒拉扯這個實驗最讓人震撼的地方不是它有多復雜而是它揭示了一個根本矛盾處理器為了快必須猜只要猜就可能猜出不該猜的東西。這不是某個廠商的失誤而是整個高性能計算范式的固有代價。你不可能既要極致的亂序執(zhí)行又要絕對的信息隔離。每一代處理器都在這個天平上找平衡點有的偏向性能有的偏向安全但沒有人能完全消除這個矛盾。理解了這一點你就不會再問為什么會有這種漏洞而會問在什么條件下這個漏洞可被利用代價有多大。這才是安全思維的正確打開方式。6.2 側(cè)信道思維的遷移價值拋開具體的攻擊手法這個實驗真正教給你的是一種思維方式當直接讀取被禁止時去讀它的副作用。這個思路可以遷移到很多地方。比如判斷某個文件是否存在不一定非要打開它看它的訪問時間、大小變化、錯誤碼差異都能推斷出信息。再比如判斷某個分支走了哪條路不一定非要看結果看執(zhí)行時間、功耗、緩存占用都能反推。這種從側(cè)面看的能力是安全分析里最值錢的東西之一。它逼你跳出功能正確的層面去思考實現(xiàn)過程本身泄露了什么。6.3 防御思路的啟發(fā)既然攻擊靠的是緩存痕跡那防御自然就從消除痕跡入手。常見的思路有幾類隔離讓不同安全域的數(shù)據(jù)不共享緩存從物理上切斷側(cè)信道。填充在關鍵操作后主動刷新緩存讓痕跡來不及被觀測。隨機化讓時間測量失去穩(wěn)定的基準增加攻擊者的噪聲。限制推測在權限檢查點插入屏障禁止跨邊界的推測執(zhí)行。這些防御各有代價有的損失性能有的增加復雜度。實際系統(tǒng)里往往是組合使用根據(jù)威脅模型取舍。理解攻擊原理才能理解防御為什么這么設計而不是死記硬背一堆緩解措施的名字。7. 一些個人體會這個實驗我前后做過好幾輪每輪都有新發(fā)現(xiàn)。第一輪卡在環(huán)境上虛擬機里怎么跑都是噪聲第二輪卡在閾值上拍腦袋定的值把信號全濾掉了第三輪才真正看到那個清晰的峰值那一刻確實有點激動——你親眼看到了 CPU 說漏嘴。如果讓我給后來者一句建議那就是別急著追求高成功率先把單次測量做干凈。很多人一上來就想跑出 99% 的準確率結果連基線都沒測過根本不知道自己的噪聲水平在哪。先把 flush reload 的基線時間測出來把命中/未命中的分布畫出來剩下的就是水到渠成的事。另外這個實驗的價值不在于復現(xiàn)一個攻擊而在于它逼你去讀處理器的微架構手冊去理解流水線、緩存、推測執(zhí)行這些平時被抽象掉的東西。做完之后你看代碼的眼光會變——你會開始想這行代碼在硬件層面到底發(fā)生了什么有沒有留下不該留的痕跡。這種視角的轉(zhuǎn)變比實驗本身的結果重要得多。后續(xù)如果想繼續(xù)深入可以往兩個方向走一是換不同的緩存?zhèn)刃诺朗址ū热?PrimeProbe體會它們各自的適用場景二是研究不同微架構上的行為差異把同一段代碼在幾臺不同機器上跑一遍對比結果。這兩個方向都能讓你對硬件到底怎么工作有更立體的認識。