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

劫持控制流與返回導向程式設計

緩衝區溢位只是那道傷口。本篇追蹤接下來的那一步:攻擊者如何把一個被破壞的指標變成對 CPU 的奪取,以及返回導向程式設計如何用受害者早已信任的程式碼拼出一整支程式。

從一個被破壞的位元組到一顆被奪走的 CPU

第一篇讓你握著一道傷口:一個堆疊緩衝區溢位會越過一個區域陣列的尾端去寫入,踐踏堆疊上方坐著的任何東西。那是損害。本篇談的是攻擊——把那份損害變成攻擊者執行他自己選定的程式碼。從前者轉到後者的樞紐,是一個精確而單一的概念,叫做控制流劫持:讓程式跳到一個它從來不該去的地方。一次當機既吵又對攻擊者無用;一次劫持則安靜而徹底,因為一旦你選定了 CPU 下一步去哪,你就選定了程式要做什麼。

要看見那個目標,回想一下函式如何返回。當程式碼呼叫一個函式時,CPU 會把一個返回位址推到堆疊上——也就是被呼叫者完成時要恢復執行的那條指令的位址。那個函式在一個坐落於那個被儲存位址下方堆疊框裡做它的工作,而它的區域陣列就活在那個框裡。尾聲以單一一條指令 ret 結束,它只做一件事:把那個被儲存的位址從堆疊上彈出、放進程式計數器(也就是 rip 暫存器),然後 CPU 開始從那裡擷取指令。那個 ret 就是整場遊戲。它完全信任堆疊上的一個值,而一次溢位剛好能寫到堆疊。

於是那個經典攻擊自己就寫好了。溢位持續往區域陣列之外寫、向上穿過那個框,直到它落在那個被儲存的返回位址之上、用攻擊者挑選的一個位址把它覆蓋掉。函式結束、ret 把攻擊者的值彈進 rip,而 CPU 乖乖地從攻擊者所說的任何地方擷取下一條指令。ret 沒有錯;ret 完美地完成了它的工作。錯誤在上游——一次未經檢查的複製,讓不受信任的位元組抵達了一個硬體會毫不質疑地信任的地方。這就是每一次控制流劫持的核心:去破壞一個 CPU 稍後會載入 rip 的值。

舊的答案,以及擋下它的那道牆

一旦你能重導 rip,顯而易見的下一個問題是:*導去哪裡?*1990 年代原本的答案漂亮地直白:把你自己的機器碼放進你正在溢位的那個緩衝區裡,然後讓返回位址指向它。攻擊者的位元組身兼兩職——先當作一段長長的填料,填滿陣列並覆蓋掉那個被儲存的位址;同時又當作一支小小的程式(一段常被稱為 shellcode 的酬載,因為它經典地會啟動一個外殼)。ret 把 rip 載入一個指回堆疊、指向攻擊者剛剛提供的那些位元組的位址,而 CPU 就執行它們。一筆勾銷的程式碼注入。

接著一道牆立了起來,乾淨俐落地把這招破掉。現代 CPU 與作業系統會為記憶體的每一個分頁標上權限,並強制一條叫做資料執行防護(DEP,或稱 NX,意為「不可執行」)的規則:一個分頁可以是可寫的可執行的,但不能同時兩者皆是。堆疊與堆積——攻擊者資料落腳之處——是可寫的、因此不可執行。程式碼分頁——程式真正的指令——是可執行的、卻不可寫入。所以被倒進堆疊的 shellcode 不過是 CPU 拒絕執行的資料;rip 一指向它,硬體就拋出一個錯誤、行程隨即死去。那個直截了當的注入攻擊,對一大類程式而言,結束了。

重用早已在那裡的程式碼

這就是 NX 留下的那道裂縫。它禁止把攻擊者的資料當程式碼來執行——但受害程式滿是早已可執行、也早已被信任的程式碼:它自己的函式,以及關鍵的、被映射進每一個行程的整個 C 函式庫 libc。那些程式碼位於可執行的分頁上,CPU 執行它們完全合法。於是攻擊者不再試圖提供程式碼,而開始試圖重用它。這個想法的第一個版本是 返回 libc:不把那個被覆蓋的返回位址指向被注入的位元組,而是指向一個真實 libc 函式的開頭——比如 system()——並安排好堆疊,讓那個函式找到它預期的引數。ret 跳進了如假包換、可執行的函式庫程式碼,而攻擊者已經把程式自己的積木轉過來對付它。

返回導向程式設計(ROP)是被推到極限的返回 libc,也是攻擊性資安中最優雅的想法之一。攻擊者不去呼叫整個函式,而是去獵捕既有程式碼的微小碎片,稱為小工具:一小撮有用的指令,而它們碰巧以一個 ret 結尾。舉例來說,libc 裡某處也許有 pop rdi ; ret 這個兩條的序列——一條把某個值載入 rdi 暫存器的指令,緊接著一個返回。它自己幾乎什麼都不做。但它尾隨的那個 ret 正是魔法所在:它讓這個小工具可被串接

現在看著整部機器翻轉過來。攻擊者不是用一個值去覆蓋返回位址——他們用一份仔細排序的小工具位址清單,去覆蓋它上方的整個區域,並穿插著那些小工具將會消耗的資料。第一個 ret 跳到第一號小工具。第一號小工具執行它的指令、撞上它自己的 ret,而那個 ret 把下一個位址從堆疊上彈出、跳到第二號小工具。第二號小工具執行、再 ret 進第三號小工具。堆疊不再是一疊框;它成了一支程式,而 ret 成了它擷取指令的那一步。攻擊者用一種語言寫起了軟體,那語言唯一的詞彙就是受害者自己二進位檔的碎片。

the stack, AFTER an overflow, read top-to-bottom as ret pops it:

  [ &gadget1 ]   <- ret jumps here first:  pop rdi ; ret
  [ 0x... ptr ]  <- popped into rdi by gadget1 (e.g. "/bin/sh")
  [ &gadget2 ]   <- gadget1's ret jumps here: pop rsi ; ret
  [ 0          ]  <- popped into rsi by gadget2
  [ &system   ]  <- gadget2's ret jumps here: call system(rdi)
  ...

each ret = "fetch next address from the stack and go";
the stack has become the attacker's program counter.
一條微型 ROP 鏈:堆疊不再是框,而是一串小工具位址與資料;每一個尾隨的 ret 都推進到下一個小工具,於是攻擊者根本不必注入任何一個可執行的位元組就能進行運算。

為什麼這很難擋,以及它真正需要什麼

有兩個事實讓 ROP 真正強大,而對兩者都誠實是值得的。第一,一個大型二進位檔含有多得驚人的小工具——研究者已證明,一支夠大的程式在 ROP 之下實際上是圖靈完備的,意思是一條鏈能表達任意運算,而不只是「呼叫 system()」。更糟的是,在像 x86 這樣變長的指令集上,小工具可以被從指令中段找到:把 rip 跳進一段較長編碼的中間,那些位元組會解碼成另一條不同的、非預期的、以 ret 結尾的指令。防禦者沒寫過這樣的小工具;CPU 仍在指令與指令之間的縫隙裡找到了它。第二,而這是那個深刻的重點,ROP 從不違反 NX——它執行的每一個位元組都活在一個合法可執行的分頁上。那道殺死了程式碼注入的緩解措施在這裡毫無作用。

但對 ROP*要求什麼也要同等誠實,因為這正是下一篇的防禦咬下去的地方。要寫下真實小工具的位址,攻擊者必須知道那些位址——也就是 libc 與程式實際坐落在記憶體何處。在一台舊系統上,配置是固定的,於是一個位於 0x4005a0 的小工具在每一次執行都在 0x4005a0,攻擊可以把它寫死。最重要的單一反制就是把那份知識拿走*:位址空間配置隨機化(ASLR)在每一次執行時打亂堆疊、堆積與 libc 被載入的位置,於是那條鏈所依賴的位址每次都不同——而且未知。一條瞄準上一次執行位址的 ROP 鏈,如今會落在垃圾上而當機。

一步步建一條出來

為了讓這層抽象變得具體,這裡是針對一支有弱點的程式、建出一條返回 libc 或 ROP 攻擊的實際工作流程——不是要武裝任何人,而是因為看見這些步驟,是真正理解第三篇裡每一道防禦在攻擊哪一個假設的最快途徑。每一步都是一個前提條件;移除其中任何一個,整條鏈就垮了。

  1. 找到溢位。找出一個你能控制、會越過某個緩衝區尾端的寫入——也就是第一篇裡那次未經檢查的複製。沒有一個記憶體破壞的原語,就根本沒有攻擊;這是其他一切賴以建立的傷口。
  2. 量出偏移。算出你溢位的那個緩衝區開頭、到它上方那個被儲存的返回位址之間,究竟隔著多少位元組。哪怕只差一個位元組,你就會覆蓋到錯的東西——這正是一個差一錯誤變成一發失準射擊的地方。
  3. 洩漏一個位址(若 ASLR 開著)。用第二個錯誤——一次越界讀取、一個格式字串讀取——去得知一個真實的執行期位址,再減去它已知的偏移,以還原這一次執行 libc 與你的小工具實際坐落何處。這一步之所以存在,只是因為 ASLR 把那些位址藏了起來。
  4. 蒐集小工具。掃描如今已定位的可執行分頁,找出你需要的碎片——pop rdi ; ret 用來設定一個引數、一個單獨的 ret 用來對齊堆疊——以及你最終想呼叫的那個函式,例如 system()。這些都來自二進位檔裡早已存在的程式碼,所以 NX 從不反對。
  5. 排出酬載。建出那串位元組:先是抵達返回位址的填料,接著是那份排序好的小工具位址清單、穿插著每個小工具所消耗的資料,正如上面那張小小的堆疊草圖所示。這份清單就是你即將執行的那支程式。
  6. 投遞並觸發。把酬載餵進當初造成溢位的那同一個輸入,然後讓那個有弱點的函式返回。它的 ret 把你的第一個小工具載入 rip,那條鏈自己跑了起來,控制權歸你所有。

退一步,整道弧線就驟然對焦。一個記憶體破壞的錯誤給了你一次寫入;那個被儲存的返回位址給了你一個目標;ret 給了你一個擷取步驟;NX 逼你離開注入、走上重用;小工具給了你一套從受害者自己程式碼裡刻出來的指令集;ASLR 藉著藏起位址反擊;而一次資訊洩漏又反擊那一手。這條鏈的每一行,都是一條精確的規則撞上它精確的漏洞。握住這個心智模型,因為第三篇會走過防禦者的回應——金絲雀控制流完整性,以及影子堆疊——而每一個,最好都被理解為對你剛剛看見的某一個步驟的攻擊。