四道牆,以及如何讀一道牆
你抵達本篇時,已經懂得那場攻擊。第一篇展示了傷口——一個緩衝區溢位或一次釋放後使用,遞給你一次受控的寫入。第二篇展示了致命一擊——破壞那個被儲存的返回位址,把返回導向程式設計變成一支由小工具拼出來的完整程式,CPU 就歸你了。本篇是防禦者的回答。在大約二十年裡,他們立起了四道主要的牆,而你能養成的最有用的習慣,就是像一個好的攻擊者那樣去讀每一道牆:不是讀成「這擋住了漏洞利用」,而是讀成一條禁止某一件特定事情的精確規則。 一道緩解措施真正所說的,與人們假設它所說的,兩者之間的縫隙,正是下一次攻擊棲身之處。
這四道牆自然地分成兩對,而在細節之前先把這個形狀放在心裡是有幫助的。第一對攻擊攻擊者的原語——也就是一次寫入為你買到的基本能力。NX(不可執行)禁止把資料當程式碼來執行;ASLR 藏起攻擊者瞄準所需要的位址。第二對則直接守護目標——也就是 CPU 稍後會信任的那些值。堆疊金絲雀在 ret 用到那個被踩爛的返回位址之前就偵測到它;控制流完整性拒絕跳到任何程式並非被設計要跳去的地方。這四者沒有一個是解藥。每一個都抬高一個代價,而整個領域之所以是一場軍備競賽,正是因為每一道牆都留下了一道裂縫,而每一道裂縫都長出了它自己的攻擊。
NX 與 ASLR:餓死那些原語
你在第二篇見過 NX/DEP,所以這裡只給出精確的形狀。虛擬記憶體的每一個分頁都帶著權限位元,而硬體強制一條「可寫互斥於可執行」的規則:攻擊者資料落腳的堆疊與堆積是可寫的、因此不可執行。rip 一指向某個可寫分頁上的位元組,記憶體保護機制就拋出一個錯誤、行程隨即死去。這很便宜——分頁表本來就存在——而且它乾淨地終結了原本「把 shellcode 倒到堆疊上」的攻擊。但重讀那條規則:它從不說你無法控制 rip,只說你無法執行你以資料身分寫下的位元組。那正是 ROP 行軍穿過的漏洞,它重用早已位於可執行分頁上的程式碼。NX 沒有輸;它只是結果上禁止的,比人們所盼望的少。
位址空間配置隨機化(ASLR)攻擊 NX 留下的那道漏洞。一條 ROP 鏈是一串位址——小工具的、像 system() 這樣的 libc 函式的——所以它只在攻擊者知道那些東西坐落何處時才有效。在一台舊系統上配置是固定的:一個位於 0x4005a0 的小工具每次執行都在 0x4005a0,漏洞利用可以把它寫死。ASLR 於 2000 年代中期登上 Linux 與 Windows,它在每一次執行時隨機化堆疊、堆積、共享函式庫、以及(搭配 PIE)主程式本身的載入基底。昨天還管用的那條鏈,如今指進一個不同的、未知的配置,落在垃圾上。攻擊者最基本的那個假設——我知道東西在哪——消失了。
two runs of the SAME program, with ASLR on:
run A: libc base = 0x7f3a2c000000 gadget = base + 0x4a3e0
run B: libc base = 0x7f91d8000000 gadget = base + 0x4a3e0
^^^^^^^^^^^^^^^^ different each run, unknown to attacker
the OFFSET (0x4a3e0) is fixed and known from the libc binary;
the BASE is the secret. leak ONE real address at run time,
subtract its known offset -> recover base -> recompute every gadget.堆疊金絲雀:給返回位址的一條絆索
NX 與 ASLR 餓死攻擊者的原語;接下來的兩道牆則直接守護目標。堆疊金絲雀——名字來自煤礦坑裡的金絲雀,牠的死亡警示了毒氣——用一條絆索來防衛那個被儲存的返回位址。這個想法簡單得令人卸下心防。一個想抵達返回位址的線性溢位,必須越過區域緩衝區與那個位址之間的一切。於是編譯器在進入時,就在那裡——區域變數與被儲存的返回位址之間——種下一個祕密的守衛值。在離開時、就在 ret 之前,它檢查那個守衛值是否依然完好。一個經典的堆疊溢位向上流動、必然踐踏那隻金絲雀才能抵達返回位址——所以一隻被破壞的金絲雀,幾乎可以確定地表示返回位址也被命中了。
把那個放好守衛的框想像出來。區域緩衝區(比如 char buf[16])坐在最低處;溢位從那裡開始、向上寫過其他區域變數,接著是金絲雀字組(像 0x...a3 這樣的值)、接著是被儲存的 rbp,最後才是頂端的返回位址。編譯器的序幕在進入時存入金絲雀;尾聲在 ret 之前測試它:若這個框的金絲雀不再等於那個祕密,它就呼叫 __stack_chk_fail() 並中止;否則就返回。一個線性溢位若不在途中越過——並因而毀掉——金絲雀,就根本無法抵達返回位址。
在 Linux 上,這就是編譯器的堆疊保護器——用 gcc -fstack-protector-strong 把它打開。那個祕密是一個在啟動時取得的、每行程獨有的隨機字組(裡面含一個零位元組,好讓 strcpy() 這類字串函式提早停下、無法輕易把金絲雀往前複製)。若尾聲發現守衛被改了,它不返回——它呼叫 __stack_chk_fail() 並立即中止行程。最後那個細節很重要:金絲雀不修復那份破壞,它把一次安靜的劫持轉換成一次喧鬧而受控的當機。一次當機是一種服務阻斷、那很糟——但它遠勝於攻擊者安靜地執行程式碼,所以這筆交易幾乎總是值得做。
現在把金絲雀讀成一條精確的規則,它的裂縫就現身了。它假設溢位是線性而連續的——但一個越過金絲雀的寫入根本不碰它就擊敗了它。一次釋放後使用,或一次直接瞄準返回位址的任意寫入,完全跳過了守衛。而若攻擊者能先讀到金絲雀——透過一次資訊洩漏——他們只要把同一個值寫回去蓋在它上面,尾聲的檢查就通過了。金絲雀對教科書式的連續堆疊溢位是一道強而便宜的防禦,對現代漏洞利用偏愛的非線性寫入與「先讀後寫」洩漏卻幾乎無用。這就是整場軍備競賽的模式:一道為上個十年的攻擊而建的牆,被這個十年的攻擊繞了過去。
CFI 與影子堆疊:守護跳轉本身
金絲雀守護一個目標,也就是返回位址,而且只防一種形狀的寫入。最具雄心的那道牆,控制流完整性(CFI),守護的是跳轉本身。它的洞見是結構性的:一支正確程式的控制流並非任意。每一次穿過函式指標或 C++ vtable 的間接呼叫,理應落在某個真實函式的開頭,而每一次返回理應回到正是呼叫目前這個函式的那個位置。一次劫持,依定義,違反了其中之一。於是 CFI 在編譯期算出每一次間接轉移的合法目的地集合,並插入一個執行期檢查:在進行一次間接呼叫之前,驗證目標是否在那個被允許的集合裡;若否,就中止。攻擊者仍能破壞那個指標——但那個被破壞的值如今還必須同時是一個合法的目的地,而大多數小工具位址並不是。
這裡有一個自然的劃分。前向邊 CFI 守護間接呼叫與跳轉——也就是函式指標與 vtable 的轉移——做法是拿目標去對照那個編譯期的集合。後向邊 CFI 守護返回,而做這件事最乾淨的機制就是影子堆疊。這個想法很直接:另外維護一個分離、受保護的第二堆疊,它只儲存返回位址。每一次呼叫,都把返回位址同時推到正常堆疊與影子堆疊。每一次返回,都比較兩者;若正常堆疊上的位址與影子副本不符,就是有東西覆蓋了它——中止。因為影子堆疊裡裝著溢位碰不到的東西(它活在記憶體的別處、且受硬體保護),一個被踩爛的返回位址會被以確定性、而非機率逮住——不像金絲雀,沒有祕密可洩漏。
這些不只是論文——它們真的出貨。Intel CET 提供一個硬體影子堆疊,以及一條 endbranch 指令,用來標記每一個合法的間接呼叫落腳點,於是 CPU 自己就會在一次脫軌的跳轉上發生錯誤。ARM 提供指標認證(PAC),它用一把祕密金鑰為指標備用的高位元簽章、並在使用前驗證簽章,於是一個被偽造的返回位址通不過它的檢查。Clang 與 GCC 出貨你今天就能啟用的軟體 CFI。誠實的但書是:前向邊 CFI 的緊密程度,僅止於它那個被允許的目標集合,而那個集合常常很粗——若許多函式共用同一個簽章,攻擊者仍有一份合法目標的菜單可挑。CFI 大幅縮小了攻擊面;它很少把攻擊面縮小到零。
讀懂整盤棋
退一步,這四道牆便排成了對第二篇攻擊四個步驟的回答。走一遍那條鏈,你就能為每一道防禦,說出它移除了哪一個確切的前提條件——以及攻擊者必須恢復哪一件確切的事才能取勝。
- NX 移除了「我注入的位元組會執行」。攻擊者藉著不注入來恢復它——透過 ROP 重用既有的可執行程式碼,而 NX 從不反對,因為每一個小工具都活在一個合法的可執行分頁上。
- ASLR 移除了「我知道小工具在哪」。攻擊者用一次資訊洩漏來恢復它——讀出一個真實的執行期位址、減去它固定的偏移、還原那個隨機的基底、重算整個配置。
- 金絲雀移除了「我能安靜地覆蓋返回位址」。攻擊者藉著洩漏金絲雀、再把它原封不動寫回去來恢復它,或藉著一次完全越過守衛的非線性寫入。
- CFI 與影子堆疊移除了「我能把一次轉移重導到任何地方」。攻擊者藉著只瞄準合法的目的地來恢復它——利用一個粗糙的允許集合,或尋找那些破壞資料、而非控制流的錯誤。
兩個誠實的結論收攏這個迴圈。第一,縱深防禦才是重點:每一道牆單獨來看都可被擊敗,但把 NX、ASLR、金絲雀與 CFI 疊起來,就意味著單單一個錯誤很少夠用——攻擊者如今需要一串錯誤(典型是一個用來擊敗 ASLR 的洩漏,加上一個同時也滿足 CFI 的破壞),而每多一個要求,就是多一件在某支特定程式裡可能根本不存在的事。這些緩解措施賣的是漏洞利用的代價,而非它的不可能。第二,這就是為什麼防禦的前線正在往上游移動,移向那些根本拒絕產生傷口的語言——這正是基礎篇對 Rust 所畫下的那個對比,也是第四、五篇接下來要去的方向:走進沙箱化,以及那些完全繞過記憶體安全的旁路。