:glibc malloc機制與經(jīng)典利用手法詳解)
1. 從一次內(nèi)存越界說起堆漏洞利用到底在解決什么問題很多人第一次接觸堆漏洞利用腦子里冒出來的第一個念頭是棧溢出還沒玩明白怎么又來個堆我當(dāng)初也是這個反應(yīng)。棧上的緩沖區(qū)溢出你至少能看到函數(shù)返回地址被覆蓋調(diào)試器里一眼就能定位到偏移量。但堆不一樣堆是一大片由分配器動態(tài)管理的內(nèi)存區(qū)域沒有固定的返回地址給你覆蓋也沒有清晰的棧幀結(jié)構(gòu)給你參照。你面對的是malloc、free、chunk、bin這些概念以及一個看起來雜亂無章的內(nèi)存布局。但恰恰是這種“雜亂”讓堆漏洞利用成為二進制安全領(lǐng)域里最有意思、也最能體現(xiàn)攻擊者創(chuàng)造力的一塊內(nèi)容。堆漏洞利用的核心不是簡單地覆蓋某個地址而是通過操縱內(nèi)存分配器的內(nèi)部數(shù)據(jù)結(jié)構(gòu)讓程序在后續(xù)的malloc或free操作中按照攻擊者的意圖去讀寫任意內(nèi)存。換句話說你不是在直接改寫控制流而是在“欺騙”分配器讓它幫你完成任意地址寫。這個實驗的核心價值在于它強迫你理解glibc malloc的底層實現(xiàn)。你平時寫代碼調(diào)用malloc(16)覺得這就是個黑盒給我一塊內(nèi)存就完事了。但當(dāng)你需要利用堆漏洞的時候你必須知道這16字節(jié)背后發(fā)生了什么——分配器從哪里切的塊、chunk header里存了什么、free之后這塊內(nèi)存去了哪個bin、下一次malloc的時候又從哪里取。這些細節(jié)平時寫業(yè)務(wù)代碼根本不會關(guān)心但在漏洞利用中每一個字段都是可以利用的抓手。適合誰來學(xué)這塊內(nèi)容我的判斷是你已經(jīng)能熟練使用gdb進行調(diào)試看得懂匯編理解棧溢出的基本原理和ret2libc、ROP這類基礎(chǔ)利用手法。如果你連棧溢出的利用鏈都還沒親手寫過一遍直接上堆漏洞會非常痛苦因為堆利用的調(diào)試復(fù)雜度比棧高出一個量級。另外你需要對C語言的內(nèi)存模型有基本認(rèn)知知道什么是指針、什么是結(jié)構(gòu)體對齊、什么是內(nèi)存頁。這些不是可選項是前置條件。這篇文章會圍繞堆漏洞利用的完整學(xué)習(xí)路徑展開從glibc malloc的核心機制講起然后逐一拆解幾種經(jīng)典的堆漏洞類型和對應(yīng)的利用手法接著給出調(diào)試技巧和實操建議最后分享一些我在學(xué)習(xí)和實踐過程中踩過的坑。內(nèi)容會盡量貼近實際動手的場景不會只停留在概念層面。2. glibc malloc的chunk管理機制利用的基石2.1 chunk的內(nèi)存布局與size字段的低三位玄機要理解堆漏洞利用第一步必須搞清楚chunk在內(nèi)存里長什么樣。當(dāng)你調(diào)用malloc(24)的時候分配器實際給你的內(nèi)存塊比24字節(jié)要大。一個已分配的chunk包含兩部分chunk header和用戶數(shù)據(jù)區(qū)。在64位系統(tǒng)上chunk header占16字節(jié)分別是prev_size和size字段各8字節(jié)。prev_size只在物理相鄰的前一個chunk空閑時才有意義用來記錄前一個chunk的大小size字段則記錄了當(dāng)前chunk的總大小包括header本身。size字段有個非常關(guān)鍵的設(shè)計它的低三位被復(fù)用為標(biāo)志位。最低位是PREV_INUSE簡稱P位表示物理相鄰的前一個chunk是否正在被使用。第二位是IS_MMAPPEDM位表示這個chunk是否由mmap直接分配。第三位是NON_MAIN_ARENAA位表示這個chunk是否屬于非主分配區(qū)。因為chunk的大小一定是16字節(jié)對齊的64位系統(tǒng)下是16字節(jié)32位系統(tǒng)下是8字節(jié)所以size字段的低三位天然就是0glibc就拿來當(dāng)標(biāo)志位用了。這個設(shè)計直接影響了利用方式。比如在off-by-one漏洞中你只能溢出一個字節(jié)恰好能覆蓋到下一個chunk的size字段的最低位。如果你把這個P位從1改成0分配器就會認(rèn)為前一個chunk是空閑的從而在free當(dāng)前chunk的時候觸發(fā)向前合并操作。這就是經(jīng)典的off-by-one null byte漏洞的利用起點。注意很多初學(xué)者在調(diào)試時看到size字段的值是0x31、0x41這種以為是chunk大小就是49或65字節(jié)實際上去掉低三位的標(biāo)志位之后真實大小是0x30和0x40也就是48和64字節(jié)。這個細節(jié)在計算偏移的時候非常容易搞錯。2.2 fastbin、smallbin、largebin與unsortedbin的分流邏輯free一個chunk之后它會根據(jù)大小被放入不同的bin中。理解這個分流邏輯是利用堆漏洞的前提。fastbin是最小的一類bin專門管理大小在0x20到0x80之間64位系統(tǒng)默認(rèn)情況下的chunk。fastbin的特點是采用單鏈表結(jié)構(gòu)LIFO后進先出順序而且free的時候不會清除下一個chunk的PREV_INUSE位也不會觸發(fā)合并操作。這意味著fastbin里的chunk可以保持一種“假空閑”狀態(tài)為double free和fastbin attack提供了土壤。smallbin管理的是小于0x400字節(jié)的chunk不包括fastbin范圍采用雙向循環(huán)鏈表FIFO順序。unsortedbin是一個特殊的bin當(dāng)你free一個不屬于fastbin范圍的chunk時它首先會被放入unsortedbin。下一次malloc的時候分配器會先檢查unsortedbin如果找到合適大小的chunk就直接返回否則把它歸類到對應(yīng)的smallbin或largebin中。largebin管理的是大于等于0x400字節(jié)的chunk結(jié)構(gòu)更復(fù)雜維護了兩個雙向鏈表。這個分流邏輯的關(guān)鍵在于不同bin的分配和回收策略不同利用方式也不同。fastbin attack利用的是fastbin單鏈表和缺乏完整性檢查的特點unsortedbin attack利用的是unsortedbin在分配時會將chunk從鏈表中摘除并寫入bk指針的機制largebin attack則利用了largebin中復(fù)雜的鏈表操作。2.3 從malloc到free一次完整的內(nèi)存分配生命周期讓我用一個具體的例子把整個流程串起來。假設(shè)程序依次執(zhí)行以下操作void *a malloc(0x30); // 分配chunk A void *b malloc(0x30); // 分配chunk B void *c malloc(0x30); // 分配chunk C free(b); // 釋放chunk B void *d malloc(0x30); // 再次分配第一步malloc(0x30)時分配器從top chunk上切下一塊chunk A的size字段為0x410x40大小加上P位1。第二步malloc(0x30)時從top chunk繼續(xù)切chunk B的size也是0x41。第三步同理。此時chunk A、B、C在內(nèi)存中物理相鄰。free(b)的時候因為0x40大小屬于fastbin范圍chunk B被放入fastbin鏈表中。此時chunk B的fd指針指向fastbin鏈表的頭部可能是NULL而且chunk C的PREV_INUSE位不會被清除因為fastbin不觸發(fā)合并。再次malloc(0x30)時分配器首先檢查fastbin中是否有合適大小的chunk。有就是chunk B于是直接返回chunk B。注意此時返回的指針和之前free的b是同一個地址。這個流程看起來簡單但每一步都藏著可以利用的細節(jié)。比如fastbin的LIFO特性意味著如果你連續(xù)free兩個chunk再malloc拿到的是后free的那個。再比如如果你能控制fastbin中某個chunk的fd指針下一次malloc就能返回任意地址。3. 幾類經(jīng)典堆漏洞的成因與利用路徑3.1 fastbin double free最簡單的入口最深的坑Double free是堆漏洞里最容易理解的一種同一個chunk被free了兩次。但glibc對fastbin的double free有一個基本的檢查如果fastbin鏈表的頭部chunk就是你正要free的chunk就會報錯。所以直接連續(xù)free同一個指針是不行的。繞過方法很經(jīng)典free(a)free(b)再free(a)。這樣fastbin鏈表變成了a - b - a。當(dāng)你第一次malloc的時候拿到a第二次malloc拿到b第三次malloc又拿到a。此時a同時在fastbin鏈表里和用戶手中你可以往a里寫數(shù)據(jù)修改它的fd指針下一次malloc就能分配到任意地址。這個利用路徑的關(guān)鍵在于fastbin的分配檢查只驗證了鏈表頭部chunk的size字段是否合法沒有做更深入的完整性校驗。所以你可以把fd指針指向一個偽造的chunk只要那個地址上的size字段看起來合理就行。實操中常見的坑是偽造的地址必須滿足對齊要求而且size字段的值必須在fastbin范圍內(nèi)。很多人找了一個可寫的目標(biāo)地址但忘了在那個地址附近布置一個合法的size值導(dǎo)致malloc的時候直接crash。3.2 unsortedbin attack改寫bk指針實現(xiàn)任意寫Unsortedbin attack的核心思路是利用unsortedbin在分配時的摘鏈操作。當(dāng)你free一個不屬于fastbin范圍的chunk時它會被放入unsortedbin。unsortedbin是一個雙向鏈表chunk的fd和bk指針分別指向前后節(jié)點。關(guān)鍵點在于當(dāng)分配器從unsortedbin中取出一個chunk時會執(zhí)行類似這樣的操作victim unsortedbin-bk; unsortedbin-bk victim-fd; victim-fd-bk unsortedbin;如果你能控制victim的bk指針就能在unsortedbin-bk victim-fd這一步把一個可控的值寫入目標(biāo)地址。雖然這個寫操作的值是victim-fd不一定完全可控但足以覆蓋某些全局變量比如把某個標(biāo)志位改大或者覆蓋global_max_fast來擴大fastbin的范圍。這個攻擊手法的限制在于寫入的值是fd指針的值通常是一個堆地址不是任意值。所以它更適合用來做“把某個變量改成一個很大的數(shù)”這種操作而不是精確寫入。3.3 off-by-one與chunk overlapping一個字節(jié)引發(fā)的連鎖反應(yīng)Off-by-one是指程序在寫入時多寫了一個字節(jié)通常是因為循環(huán)邊界判斷錯誤或者字符串沒有正確處理結(jié)尾符。在堆場景下這一個字節(jié)可能恰好覆蓋到下一個chunk的size字段的最低字節(jié)。如果這個字節(jié)是0x00那么下一個chunk的size字段的最低位P位被清零分配器會認(rèn)為前一個chunk是空閑的。當(dāng)你free下一個chunk的時候就會觸發(fā)向前合并把“前一個chunk”也合并進來。但前一個chunk實際上還在使用中這就造成了chunk overlapping——兩個指針指向了同一塊內(nèi)存區(qū)域。利用chunk overlapping你可以實現(xiàn)很多操作修改已分配chunk的size字段讓它覆蓋更大的范圍或者構(gòu)造出重疊的chunk使得free一個chunk之后另一個chunk的內(nèi)容被改寫。這個手法的利用鏈通常比較長需要精心布置多個chunk的大小和順序。3.4 tcache poisoning現(xiàn)代glibc中最常見的利用方式從glibc 2.26開始引入了一個新的機制叫tcachethread local caching。每個線程有一個自己的tcache里面維護了64個bin每個bin最多緩存7個chunk。tcache的引入大大加快了小內(nèi)存分配的速度但也帶來了新的利用方式。tcache poisoning的核心是tcache的fd指針沒有做任何完整性檢查。你free一個chunk到tcache之后直接改寫它的fd指針為目標(biāo)地址下一次malloc同樣大小的chunk時就會先返回這個chunk然后再返回你指定的目標(biāo)地址。也就是說你可以在目標(biāo)地址上獲得一個可寫的chunk。這個手法比fastbin attack更簡單因為tcache的檢查更少。但需要注意tcache的count限制每個bin最多7個chunk超過之后才會走fastbin或unsortedbin的邏輯。另外從glibc 2.29開始tcache的fd指針加入了key字段做校驗直接改寫fd會導(dǎo)致檢測失敗。繞過方法通常是先泄露heap地址然后偽造key值。4. 調(diào)試堆漏洞時那些教科書不寫的實操細節(jié)4.1 用gdb插件看清堆的真實布局裸gdb調(diào)試堆非常痛苦因為你只能看到一片內(nèi)存地址分不清哪個是chunk header、哪個是用戶數(shù)據(jù)、哪個是空閑的。我強烈建議在調(diào)試堆漏洞時使用gdb的堆分析插件。這類插件可以以可視化的方式展示chunk的分布、bin鏈表的狀態(tài)、tcache的內(nèi)容。具體來說你需要關(guān)注幾個信息當(dāng)前所有已分配chunk的地址和大小、fastbin/tcache鏈表中每個chunk的fd指針、unsortedbin中chunk的fd和bk指針。這些信息在調(diào)試時能幫你快速定位問題。比如你構(gòu)造了一個double free但malloc的時候crash了通過查看fastbin鏈表就能發(fā)現(xiàn)是不是fd指針被寫壞了。另外一個實用技巧是在關(guān)鍵位置下斷點比如malloc返回之后、free執(zhí)行之前然后手動檢查堆的狀態(tài)。不要等到程序crash了再回頭看那時候現(xiàn)場可能已經(jīng)被破壞了。4.2 堆地址隨機化對利用腳本的影響ASLR地址空間布局隨機化對堆漏洞利用的影響非常大。堆的基地址每次運行都會變化這意味著你無法在腳本里硬編碼地址。解決辦法通常有兩種一是通過信息泄露漏洞獲取堆地址或libc基地址二是在腳本中動態(tài)解析。信息泄露是堆利用中非常關(guān)鍵的一環(huán)。常見的泄露方式包括利用unsortedbin的fd/bk指針泄露libc地址因為unsortedbin的鏈表頭在libc的main_arena結(jié)構(gòu)中、利用tcache的fd指針泄露堆地址。在寫利用腳本時你需要先泄露地址計算出libc基地址和堆基地址然后再構(gòu)造后續(xù)的利用鏈。提示在本地調(diào)試時可以臨時關(guān)閉ASLR來簡化分析。但最終的利用腳本必須能在ASLR開啟的情況下工作否則就沒有實際意義。4.3 利用腳本的編寫節(jié)奏與調(diào)試方法寫堆利用腳本和寫棧溢出腳本的節(jié)奏完全不同。棧溢出的利用鏈通常比較線性填充緩沖區(qū)、覆蓋返回地址、布置ROP鏈。堆利用則更像是在下一盤棋你需要精確控制每一步malloc和free的順序以及每次寫入的內(nèi)容。我的建議是先用Python的pwntools框架搭好腳本骨架把交互函數(shù)封裝好。然后分階段調(diào)試第一階段只做信息泄露確認(rèn)能拿到正確的地址第二階段構(gòu)造double free或tcache poisoning確認(rèn)能分配到目標(biāo)地址第三階段寫入目標(biāo)值并觸發(fā)getshell。每個階段都單獨驗證不要一口氣寫完再跑否則出了問題很難定位。另外堆利用腳本中經(jīng)常需要處理輸入輸出的同步問題。比如程序在malloc之后會打印一個提示信息你需要精確接收這個信息再發(fā)送下一步的數(shù)據(jù)。如果同步?jīng)]做好腳本會卡住或者發(fā)送錯誤的數(shù)據(jù)。5. 從實驗環(huán)境到真實場景堆漏洞利用的邊界與思考5.1 實驗環(huán)境中的簡化與真實軟件的差異實驗環(huán)境里的堆漏洞題目通常是精心構(gòu)造的漏洞點明確、利用路徑清晰、保護機制可能只開啟了一部分。但真實軟件中的堆漏洞要復(fù)雜得多漏洞可能隱藏在復(fù)雜的業(yè)務(wù)邏輯中堆的布局受到大量其他分配操作的影響保護機制可能全部開啟。比如實驗環(huán)境中你可能會遇到一個簡單的菜單程序有add、delete、edit、show四個功能漏洞點就在edit功能中沒有檢查新輸入的長度。但在真實軟件中堆漏洞可能出現(xiàn)在解析器、序列化庫、網(wǎng)絡(luò)協(xié)議處理等場景中你需要先理解整個數(shù)據(jù)流才能定位漏洞。另外實驗環(huán)境通常關(guān)閉了Full RELRO、PIE、FORTIFY_SOURCE等保護機制或者只開啟了一部分。真實軟件中這些保護往往全部開啟你需要考慮如何繞過。比如Full RELRO意味著GOT表不可寫你就不能通過改寫GOT表來劫持控制流需要轉(zhuǎn)向其他目標(biāo)如__malloc_hook、__free_hook或棧上的返回地址。5.2 堆漏洞利用的常見防護機制與繞過思路現(xiàn)代glibc和編譯器引入了多種針對堆漏洞的防護機制。除了前面提到的tcache key校驗還有Safe-Linking從glibc 2.32開始fastbin和tcache的fd指針被加密存儲加密方式是(address 12) | (address 48)。這意味著你不能直接寫入目標(biāo)地址需要先泄露堆地址才能計算出正確的加密值。Chunk size檢查在free和malloc時增加了對size字段的合法性檢查比如檢查size是否對齊、是否在合理范圍內(nèi)。Double free檢測tcache中增加了對double free的檢測如果free的chunk已經(jīng)在tcache中會觸發(fā)報錯。繞過這些防護需要更精細的利用手法。比如Safe-Linking的繞過需要先泄露堆地址然后計算出加密后的指針值。tcache double free的繞過通常需要先清空tcache中的某個bin或者利用其他chunk來間接實現(xiàn)。5.3 學(xué)習(xí)堆漏洞利用的進階路線建議如果你已經(jīng)完成了這個實驗想繼續(xù)深入堆漏洞利用我的建議是第一把glibc的malloc源碼讀一遍。不需要全部讀懂但malloc、free、_int_malloc、_int_free這幾個核心函數(shù)的邏輯要搞清楚。源碼在glibc的malloc/malloc.c文件中配合調(diào)試信息看效果最好。第二刷題??梢哉乙恍┙?jīng)典的堆漏洞題目來練手從最簡單的double free開始逐步過渡到unsortedbin attack、largebin attack、house of系列。每道題都親手寫利用腳本不要只看writeup。第三關(guān)注glibc的版本變化。不同版本的glibc在堆管理上有差異比如tcache是2.26引入的Safe-Linking是2.32引入的。你寫的利用腳本在2.27上能跑通在2.31上可能就失效了。理解版本差異是進階的必經(jīng)之路。第四學(xué)習(xí)真實漏洞的利用分析??梢哉乙恍┕_的CTF題目或者CVE漏洞分析來讀看看別人是怎么在復(fù)雜的保護機制下完成利用的。這能幫你建立更完整的攻擊思維。6. 我在堆漏洞實驗里踩過的幾個典型坑第一個坑是chunk size的計算錯誤。我一開始總是忘記size字段包含chunk header的16字節(jié)而且低三位是標(biāo)志位。比如malloc(0x18)實際分配的chunk大小是0x20用戶數(shù)據(jù)0x18加上header的0x8對齊到0x20size字段的值是0x21。我在計算偏移的時候經(jīng)常多算或少算8字節(jié)導(dǎo)致覆蓋到了錯誤的位置。后來我養(yǎng)成了一個習(xí)慣在gdb里用堆分析插件確認(rèn)每個chunk的實際地址和大小不再靠心算。第二個坑是fastbin的LIFO順序搞反了。我以為free的順序和malloc的順序是一樣的結(jié)果構(gòu)造double free的時候鏈表順序完全不對。后來我在紙上畫了一個簡單的棧式結(jié)構(gòu)圖每次free就壓棧每次malloc就彈棧才把這個邏輯理順。這個看似簡單的順序問題在構(gòu)造復(fù)雜的利用鏈時非常關(guān)鍵。第三個坑是忽略了tcache的存在。我按照fastbin attack的思路構(gòu)造利用鏈結(jié)果發(fā)現(xiàn)malloc返回的地址和我預(yù)期的不一樣。后來才意識到glibc 2.26之后tcache優(yōu)先于fastbinfree的chunk首先進入tcache而不是fastbin。要觸發(fā)fastbin的邏輯必須先把tcache對應(yīng)的bin填滿7個chunk。這個版本差異讓我浪費了不少時間。第四個坑是信息泄露的地址計算錯誤。unsortedbin的fd和bk指針指向的是main_arena結(jié)構(gòu)中的地址而main_arena在libc中的偏移量是固定的。我一開始直接用泄露的地址減去main_arena的偏移但忘了main_arena的地址是鏈表的頭部地址不是chunk本身的地址。正確的計算方式是泄露的fd指針減去main_arena中對應(yīng)bin的偏移得到libc基地址。這個偏移量在不同glibc版本中可能不同需要根據(jù)具體版本查表。第五個坑是腳本的交互同步問題。堆利用腳本通常需要和程序進行多次交互每次malloc、free、edit之后都要接收程序的輸出。我一開始用recvuntil的時候沒有匹配到正確的字符串導(dǎo)致腳本卡住。后來我在每次交互之后都打印一下接收到的數(shù)據(jù)確認(rèn)同步正確再繼續(xù)。這個習(xí)慣幫我節(jié)省了大量調(diào)試時間。這些坑看起來都是小問題但在實際調(diào)試中一個小錯誤就會導(dǎo)致整個利用鏈?zhǔn)《襝rash的位置可能離真正的錯誤點很遠。所以我的建議是每寫一步都驗證一步不要等到最后再調(diào)試。堆漏洞利用的調(diào)試成本很高越早發(fā)現(xiàn)問題越好。