與Linux進(jìn)程內(nèi)存模型全面拆解)
CTF Pwn模塊系列分享二匯編基礎(chǔ)Linux內(nèi)存模型拆解聊到Pwn很多人第一反應(yīng)是“棧溢出、ROP、shellcode”這些聽(tīng)起來(lái)很酷的名字但真到做題或者看別人的利用腳本時(shí)很快就卡住了??ㄗ〉脑蚴邪司懦鲈诨A(chǔ)上看反匯編代碼像看天書(shū)不知道棧是怎么漲的、參數(shù)是怎么傳的、一個(gè)ret指令背后到底發(fā)生了什么。做Pwn如果不懂匯編和內(nèi)存布局就像想當(dāng)外科醫(yī)生卻不了解解剖學(xué)不是說(shuō)完全沒(méi)法動(dòng)刀但每步都容易出大問(wèn)題。這個(gè)系列第一篇主要講了環(huán)境準(zhǔn)備、工具鏈和Pwn題的基本運(yùn)行邏輯。這一篇我們把兩塊最核心的地基拆開(kāi)揉碎講清楚匯編基礎(chǔ)以x86/x64為主和Linux進(jìn)程內(nèi)存模型。這兩塊是后面所有利用技巧棧遷移、堆利用、格式化字符串、ROP鏈構(gòu)造的基礎(chǔ)無(wú)論你將來(lái)主攻哪個(gè)方向這幾板斧都必須爛熟于心。這篇文章適合剛?cè)肟覲wn、被反匯編和gdb輸出勸退的新手也適合已經(jīng)會(huì)寫(xiě)一點(diǎn)exp但對(duì)“為什么是這樣”還一知半解的同學(xué)。我會(huì)把每個(gè)關(guān)鍵概念都拆開(kāi)盡量用大白話(huà)和實(shí)際例子說(shuō)明白“底層到底發(fā)生了什么”每個(gè)結(jié)論盡量交代它的來(lái)龍去脈方便你邊看邊在腦子里建立畫(huà)面。1. 匯編基礎(chǔ)Pwn人必須啃下的底層語(yǔ)法1.1 為什么要學(xué)匯編學(xué)哪些指令就夠用先解決一個(gè)學(xué)習(xí)心態(tài)問(wèn)題Pwn方向要學(xué)的匯編遠(yuǎn)遠(yuǎn)沒(méi)有寫(xiě)編譯器的人那么多不需要背幾百條指令真正高頻出現(xiàn)的核心指令幾十條就夠用。我的經(jīng)驗(yàn)是先掌握“棧操作”和“跳轉(zhuǎn)”兩大塊優(yōu)先級(jí)最高因?yàn)檫@些指令直接對(duì)應(yīng)利用思路里的流程控制和棧幀操作。像mov、push、pop、call、ret、leave、add、sub、lea它們是出現(xiàn)頻率最高的。再者是各類(lèi)jmp和條件跳轉(zhuǎn)指令je、jne、jz、jnz等在判斷邏輯和ROP中要頻繁用到。剩下像syscall、int 0x80、test、cmp、xor這些也常見(jiàn)但邏輯相對(duì)簡(jiǎn)單邊用邊記就行。有的人喜歡先從Intel手冊(cè)啃起我不是很推薦。真正高效的方式是開(kāi)著反匯編窗口和gdb直接把C語(yǔ)言寫(xiě)的小程序反匯編來(lái)看遇到不懂的指令馬上查這樣大腦會(huì)把指令和具體行為綁定起來(lái)而不是死記硬背。你完全不需要做到看任何匯編都能無(wú)腦翻譯但看到一個(gè)函數(shù)級(jí)匯編塊至少能說(shuō)出“它在干什么、參數(shù)在哪、棧上有什么、是否存在明顯危險(xiǎn)點(diǎn)比如邊界檢查缺失”這在Pwn中已經(jīng)足夠。1.2 寄存器體系x86與x64的差異對(duì)照寄存器是CPU用來(lái)存儲(chǔ)數(shù)據(jù)和地址的高速“便簽紙”。x86有eax、ebx、ecx、edx、esi、edi、ebp、esp這組32位寄存器x64則擴(kuò)展到rax、rbx、rcx、rdx、rsi、rdi、rbp、rsp并新增了r8到r15八個(gè)通用寄存器。Pwn實(shí)操中最常打交道的是這幾類(lèi)寄存器x86x64主要用途累加/通用eaxrax返回值、臨時(shí)數(shù)據(jù)基址ebprbp棧幀基址但現(xiàn)在常被當(dāng)通用寄存器優(yōu)化掉棧指針esprsp棧頂指針指向當(dāng)前棧位置參數(shù)1棧上見(jiàn)1.4rdi第一個(gè)參數(shù)參數(shù)2棧上rsi第二個(gè)參數(shù)參數(shù)3棧上rdx第三個(gè)參數(shù)參數(shù)4棧上rcx第四個(gè)參數(shù)參數(shù)5/6棧上r8 / r9第五、六個(gè)參數(shù)段基址fs/gsfs/gs線程局部存儲(chǔ)、canary等特殊用途需要注意的是x86下函數(shù)參數(shù)默認(rèn)全部壓棧傳遞而x64下如果參數(shù)不超過(guò)6個(gè)會(huì)優(yōu)先使用寄存器傳遞超過(guò)6個(gè)才壓棧。這是一個(gè)極為常見(jiàn)的差異點(diǎn)寫(xiě)利用時(shí)尤其要注意因?yàn)槟憧刂频臄?shù)據(jù)在棧上的偏移會(huì)因此完全不同。還有一個(gè)新手容易忽略的點(diǎn)rbp在現(xiàn)代編譯器中經(jīng)常被優(yōu)化成普通通用寄存器尤其在編譯選項(xiàng)開(kāi)啟-O2以上時(shí)你看到的函數(shù)開(kāi)頭可能根本沒(méi)有push rbp; mov rbp, rsp這一套“棧幀建立”代碼。所以學(xué)匯編時(shí)有“幀指針”的概念很好但在真實(shí)題目中不要默認(rèn)一定有rbp棧幀。1.3 必備指令速覽與操作數(shù)語(yǔ)法先從語(yǔ)法風(fēng)格說(shuō)起。Linux下用gcc/objdump默認(rèn)是ATT風(fēng)格寫(xiě)成mov $0x1, %eax意思是將立即數(shù)1存入eax而Intel風(fēng)格寫(xiě)作mov eax, 1。兩種風(fēng)格差別主要在源操作數(shù)和目的操作數(shù)的順序相反。我強(qiáng)烈建議你把gdb和objdump統(tǒng)一設(shè)置為Intel風(fēng)格因?yàn)榻^大多數(shù)CTF Writeup用的都是Intel風(fēng)格看起來(lái)也直觀很多。具體操作gdb內(nèi)執(zhí)行set disassembly-flavor intel或者直接寫(xiě)入~/.gdbinit。objdump用-M intel參數(shù)。常用指令我按功能分組照著敲幾遍就熟了數(shù)據(jù)搬運(yùn)mov dst, srclea dst, [addr]不訪問(wèn)內(nèi)存只計(jì)算地址棧操作push src棧頂指針先減再寫(xiě)入pop dst先讀出再增棧頂指針?biāo)阈g(shù)/邏輯add dst, src、sub dst, src、xor dst, src清零常用、and/or、shl/shr比較與跳轉(zhuǎn)cmp a, b本質(zhì)是減法只修改標(biāo)志位、test a, b按位與只修改標(biāo)志位、jmp addr、je/jne/jg/jle等過(guò)程調(diào)用call func把下一條指令地址壓棧然后跳轉(zhuǎn)到func、ret從棧彈出地址并且跳轉(zhuǎn)等價(jià)于pop rip經(jīng)典組合拳leave等于mov rsp, rbp; pop rbp一般在函數(shù)結(jié)尾出現(xiàn)關(guān)于call和ret這是Pwn中最重要的兩條指令沒(méi)有之一。call會(huì)把當(dāng)前指令的下一條地址返回地址壓到棧上然后跳轉(zhuǎn)ret則是從棧頂彈出這個(gè)地址跳回去。緩沖區(qū)溢出經(jīng)典利用手法就是覆蓋棧上的返回地址讓ret跳到你指定的位置。理解了這兩條指令你就理解了幾乎所有棧溢出利用的核心原理。1.4 函數(shù)調(diào)用約定參數(shù)怎么傳棧幀怎么搭函數(shù)調(diào)用約定通俗講就是“函數(shù)之間怎么交接數(shù)據(jù)和??臻g”的規(guī)矩。Linux下主要掌握兩種x86的cdecl和x64的System V AMD64 ABI以下簡(jiǎn)稱(chēng)SysV前者32位后者64位目前做題絕大部分遇到的是64位SysV。32位cdecl簡(jiǎn)單粗暴所有參數(shù)按從右往左的順序壓入棧中返回前由調(diào)用者清理?xiàng)I系膮?shù)。這意味著在調(diào)用函數(shù)之前你會(huì)看到連續(xù)幾個(gè)push調(diào)用結(jié)束后再add esp, X來(lái)恢復(fù)棧指針。64位SysV的規(guī)則我再?gòu)?qiáng)調(diào)一遍前6個(gè)整數(shù)/指針參數(shù)依次放入rdi、rsi、rdx、rcx、r8、r9第7個(gè)及以后才壓棧返回值放在rax被調(diào)用者負(fù)責(zé)維護(hù)棧上紅區(qū)red zone和局部變量區(qū)。調(diào)用結(jié)束后通常由調(diào)用者來(lái)恢復(fù)棧頂也就是add rsp, X。為什么這些細(xì)節(jié)特別重要因?yàn)樵跇?gòu)造ROP鏈時(shí)你要使用pop rdi; ret這類(lèi)gadget來(lái)設(shè)置參數(shù)?!斑@條gadget為什么能設(shè)置第一個(gè)參數(shù)”以及“函數(shù)的第幾個(gè)參數(shù)應(yīng)該出現(xiàn)在哪個(gè)寄存器/棧位置”完全是基于調(diào)用約定來(lái)確定的。如果搞錯(cuò)了payload寫(xiě)得再長(zhǎng)函數(shù)拿到的參數(shù)也是錯(cuò)的。棧幀的建立和銷(xiāo)毀典型軌跡如下函數(shù)入口如果定義了局部變量會(huì)先push rbp; mov rbp, rsp; sub rsp, 0x20開(kāi)辟局部變量空間函數(shù)結(jié)尾通常leave; ret。但正如上面說(shuō)的編譯優(yōu)化之后這條鏈路不一定完整需要靈活理解。2. Linux內(nèi)存模型從地址空間到段與區(qū)的邊界2.1 一個(gè)進(jìn)程的虛擬內(nèi)存長(zhǎng)什么樣學(xué)Pwn你必須對(duì)進(jìn)程的虛擬內(nèi)存布局建立一個(gè)整體畫(huà)面?,F(xiàn)代操作系統(tǒng)給每個(gè)進(jìn)程一個(gè)獨(dú)立的虛擬地址空間虛擬地址空間被劃分為若干不同功能的區(qū)域。在x64 Linux下內(nèi)存布局從低地址到高地址大致是低地址0x0000000000000000只讀代碼段.text程序指令所在通常不可寫(xiě)避免被篡改數(shù)據(jù)段.data存放已初始化且初值非零的全局變量/靜態(tài)變量BSS段.bss存放未初始化或初值為零的全局變量/靜態(tài)變量堆區(qū)heap程序運(yùn)行時(shí)動(dòng)態(tài)分配的內(nèi)存區(qū)向高地址增長(zhǎng)由malloc/free管理內(nèi)存映射區(qū)mmap區(qū)用于文件映射、動(dòng)態(tài)庫(kù)、共享內(nèi)存等通常會(huì)映射libc和ld.so向低地址增長(zhǎng)棧區(qū)stack函數(shù)調(diào)用、局部變量、返回地址所在向低地址增長(zhǎng)內(nèi)核地址空間用戶(hù)態(tài)無(wú)法訪問(wèn)我們平時(shí)看到的0x7fffffffffff以下區(qū)域才是用戶(hù)可用的高地址0x7fffffffffff這個(gè)布局決定了你從哪里可以控制什么數(shù)據(jù)全局變量偏移固定棧地址相對(duì)靠上堆地址處于中間libc會(huì)被映射到靠近棧的地方。做題時(shí)經(jīng)??吹絭mmap輸出的libc起始地址落在0x7f...開(kāi)頭那就對(duì)應(yīng)mmap映射區(qū)。2.2 為什么是“虛擬”內(nèi)存頁(yè)、PIE和ASLR虛擬內(nèi)存概念好多人聽(tīng)了很多遍但沒(méi)和Pwn對(duì)應(yīng)起來(lái)。簡(jiǎn)單說(shuō)每個(gè)進(jìn)程看到的是連續(xù)、獨(dú)立的內(nèi)存地址但實(shí)際上物理內(nèi)存是分頁(yè)管理的虛擬頁(yè)到物理頁(yè)的映射由內(nèi)核維護(hù)的頁(yè)表決定。程序本身不知道物理地址CPU通過(guò)MMU查頁(yè)表來(lái)完成映射。這里有一個(gè)對(duì)漏洞利用極其關(guān)鍵的推論即使兩個(gè)進(jìn)程運(yùn)行同一個(gè)程序它們的虛擬地址分布也可能完全不同因?yàn)楝F(xiàn)代Linux開(kāi)啟了ASLR地址空間隨機(jī)化。ASLR會(huì)隨機(jī)化棧、堆、共享庫(kù)、mmap基底等區(qū)域的起始地址讓攻擊者難以確定目標(biāo)地址。Pwn里常說(shuō)的“泄露地址”之所以必須做就是繞開(kāi)ASLR的前提。另一個(gè)相關(guān)概念是PIEPosition Independent Executable。如果程序編譯時(shí)開(kāi)了PIE默認(rèn)開(kāi)啟代碼段、數(shù)據(jù)段也會(huì)被隨機(jī)化如果關(guān)閉PIE程序加載地址固定通常默認(rèn)為0x400000x64。CTF題目為了降低難度或故意鍛煉某種能力常見(jiàn)組合是“不開(kāi)PIE但開(kāi)NX、開(kāi)canary”或者“開(kāi)PIE但不全開(kāi)”做題第一步看checksec輸出就是明確這些防護(hù)的開(kāi)關(guān)狀態(tài)。內(nèi)存布局的理解直接決定了checksec結(jié)果對(duì)應(yīng)的攻擊思路。2.3 段權(quán)限與NX、Canary等防護(hù)的底層邏輯每個(gè)內(nèi)存區(qū)域都有權(quán)限屬性讀R、寫(xiě)W、執(zhí)行X對(duì)應(yīng)頁(yè)表項(xiàng)的控制位。NXNo-eXecute就是限制某些頁(yè)不可執(zhí)行在Linux上通過(guò)PT_GNU_STACK和頁(yè)屬性實(shí)現(xiàn)。開(kāi)啟NX后棧和堆默認(rèn)不可執(zhí)行傳統(tǒng)“把shellcode放在棧上再跳過(guò)去”的方式就失效了于是產(chǎn)生了ROP這種“通過(guò)復(fù)用程序自身可執(zhí)行代碼片段gadget來(lái)構(gòu)造利用鏈”的技術(shù)。Canary棧保護(hù)的實(shí)現(xiàn)也是建立在??臻g和特殊寄存器之上的函數(shù)入口會(huì)從fs段的一個(gè)隨機(jī)位置線程局部存儲(chǔ)讀取一個(gè)值放在棧上緩沖區(qū)和返回地址之間函數(shù)返回前檢查這個(gè)值有沒(méi)有被改動(dòng)變了就觸發(fā)__stack_chk_fail。這解釋了為什么溢出時(shí)一旦覆蓋到返回地址正常情況下必然先覆蓋到canary也解釋了為什么需要先泄露canary才能繼續(xù)覆蓋返回地址。RELRO機(jī)制則與數(shù)據(jù)段的重定位表有關(guān)。它把GOT表設(shè)置為只讀防止攻擊者改寫(xiě)GOT表項(xiàng)。部分RELRO只在動(dòng)態(tài)鏈接器處理完重定位后把GOT表部分頁(yè)設(shè)為只讀而Full RELRO則更徹底。這些都是“內(nèi)存布局的理解”直接催生的利用或防御思路基礎(chǔ)不打好后面全是空中樓閣。2.4 用 vmmap 和 /proc/pid/maps 打開(kāi)進(jìn)程的眼睛紙上談兵沒(méi)有用工具幫你對(duì)著真實(shí)進(jìn)程驗(yàn)證模型。gdb內(nèi)有一個(gè)極其常用的命令vmmap如果你用pwndbg或等價(jià)的info proc mappings能打印當(dāng)前進(jìn)程的完整內(nèi)存映射。它的輸出長(zhǎng)這樣0x400000 0x401000 r-xp 0x0 /path/to/.../demo 0x401000 0x402000 r--p 0x1000 ... 0x402000 0x403000 rw-p 0x2000 ... 0x7ffff7a00000 0x7ffff7bcb000 r-xp ... /lib/x86_64-linux-gnu/libc.so.6 0x7ffff7bcb000 0x7ffff7dcb000 ---p ... 0x7ffff7dcb000 0x7ffff7dcf000 r--p ... /lib/.../libc.so.6 0x7ffffffde000 0x7ffffffff000 rw-p ... [stack]沒(méi)有g(shù)db圖形界面或沒(méi)用pwndbg時(shí)可以退出調(diào)試器直接讀/proc/進(jìn)程pid/maps文件輸出格式幾乎一樣。通過(guò)vmmap你可以快速確認(rèn)程序代碼段在哪、libc基地址是多少、棧在哪、堆是否已經(jīng)初始化。絕大多數(shù)地址泄露題目的第一步就是要通過(guò)某種漏洞比如格式化字符串讀出某個(gè)libc函數(shù)或棧上的殘留地址再結(jié)合vmmap的偏移反推出基址。我個(gè)人強(qiáng)烈建議新手拿到任意一個(gè)Pwn題先跑幾遍vmmap跟題目源碼對(duì)應(yīng)著看把“內(nèi)存布局不是抽象概念而是一張可以實(shí)時(shí)查看的地圖”這個(gè)感覺(jué)建立起來(lái)。3. 棧原理與緩沖區(qū)溢出復(fù)現(xiàn)把畫(huà)面變成實(shí)操3.1 棧幀的手工推演棧是個(gè)連續(xù)內(nèi)存區(qū)域特點(diǎn)是從高地址向低地址增長(zhǎng)rsp永遠(yuǎn)指向棧頂。函數(shù)調(diào)用時(shí)棧幀的形成順序是這樣的首先調(diào)用者把參數(shù)準(zhǔn)備好寄存器或壓棧執(zhí)行callCPU把返回地址壓到棧上進(jìn)入被調(diào)用函數(shù)后如果被調(diào)用者要使用rbp會(huì)保存舊rbp并把rsp賦值給rbp然后通過(guò)sub rsp, 局部變量大小給局部變量騰出空間。整塊區(qū)域向上是調(diào)用者的棧幀向下是當(dāng)前函數(shù)的??臻g。一個(gè)經(jīng)典的棧幀布局未開(kāi)優(yōu)化、未開(kāi)canary時(shí)的樣子高地址 ------------------ | 調(diào)用者的局部變量 | ------------------ | 返回地址 | - call 壓入 ------------------ | 保存的舊 rbp | - push rbp 壓入 ------------------ - rbp 指向這里 | 局部變量區(qū)域 | - sub rsp, X 后 rsp 指向底部局部變量從上往下分布 ------------------ - rsp 低地址緩沖區(qū)溢出發(fā)生后比如你往局部變量數(shù)組里拷貝了超出邊界的數(shù)據(jù)寫(xiě)入方向是從低地址往高地址走所以會(huì)先覆蓋掉局部變量區(qū)域的更高空間接著覆蓋保存的舊rbp再繼續(xù)就會(huì)覆蓋到返回地址最終在函數(shù)返回ret時(shí)跳轉(zhuǎn)到你寫(xiě)入的位置。這也就是經(jīng)典攻擊路徑“棧溢出控制返回地址”的來(lái)龍去脈。3.2 寫(xiě)一個(gè)可以“看見(jiàn)”溢出的Demo空談很難建立畫(huà)面我來(lái)寫(xiě)一個(gè)極簡(jiǎn)的例子。選擇經(jīng)典的危險(xiǎn)函數(shù)strcpy它不做邊界檢查復(fù)制到目標(biāo)緩沖區(qū)直到遇到字符串結(jié)束符天然適合展示溢出過(guò)程??紤]如下C代碼#include stdio.h #include string.h void win() { puts(you win!); } void vulnerable(const char *input) { char buf[16]; strcpy(buf, input); } int main(int argc, char **argv) { if (argc 2) { return 0; } vulnerable(argv[1]); puts(normal return); return 0; }用以下命令編譯關(guān)閉保護(hù)以便快速理解原理正式刷題時(shí)按題目情況決定gcc -o demo demo.c -fno-stack-protector -no-pie -O0checksec demo輸出會(huì)顯示沒(méi)有Canary、沒(méi)有PIE但默認(rèn)NX很可能是開(kāi)啟的RELRO部分開(kāi)啟。我們暫時(shí)不構(gòu)造漏洞利用先看它在內(nèi)存里長(zhǎng)什么樣。反匯編vulnerable函數(shù)objdump -d demo你大概率看到這樣的內(nèi)容push rbp mov rbp,rsp sub rsp,0x20 mov rax,QWORD PTR [rbp0x10] mov rdi,rax lea rax,[rbp-0x10] mov rsi,rax call strcpyplt nop leave ret注意幾個(gè)關(guān)鍵點(diǎn)sub rsp, 0x20表示棧上留了32字節(jié)空間。雖然buf聲明是16字節(jié)但編譯器為了對(duì)齊x64棧通常要求16字節(jié)對(duì)齊分配了32字節(jié)后面你會(huì)看到實(shí)際可寫(xiě)的緩沖區(qū)其實(shí)比聲明大這是很多新手的混淆點(diǎn)。lea rax, [rbp-0x10]把緩沖區(qū)首地址放到rsi也就是給strcpy的目標(biāo)地址。一個(gè)常見(jiàn)誤解是緩沖區(qū)一定從rbp-0x10開(kāi)始但這里分配的??臻g是0x20緩沖區(qū)卻從rbp-0x10開(kāi)始意味著緩沖區(qū)和保存的rbp之間還有0x10字節(jié)的空隙。這其實(shí)是編譯器為棧對(duì)齊和可能的其他局部變量留的空間。調(diào)用strcpy之前會(huì)把輸入的地址從rbp0x10取出來(lái)用寄存器傳遞x64前6個(gè)參數(shù)傳寄存器完全符合上面講的SysV調(diào)用約定。3.3 手工看棧變化gdb實(shí)操記錄把證據(jù)打在屏幕上比講十遍原理都有用。用gdb啟動(dòng)demo在strcpyplt調(diào)用前下斷點(diǎn)給程序傳一個(gè)輸入字符串。比如gdb ./demo (gdb) break *vulnerable20 # 在call strcpy那一行附近下斷 (gdb) run AAAA...AAAA # 傳入一串A (gdb) x/32gx $rsp # 查看rsp到上方32個(gè)內(nèi)存單元你會(huì)看到從rsp開(kāi)始依次能看到局部變量預(yù)留區(qū)、rbp、返回地址等等。然后單步執(zhí)行完strcpy再查看相同區(qū)域能看到0x41414141...已經(jīng)一路寫(xiě)到返回地址位置。你還可以用find 0x41414141來(lái)定位字符串。實(shí)操中最直觀的做法是單步到ret觀察rip被改寫(xiě)成什么。這種“逐字節(jié)看棧被覆蓋”的訓(xùn)練我認(rèn)為是理解所有棧類(lèi)漏洞的必經(jīng)之路。跑通這個(gè)流程之后再去看各類(lèi)ROP題目你會(huì)更容易想象出payload寫(xiě)入后棧上的完整畫(huà)面。3.4 從溢出到控制執(zhí)行流返回地址跳轉(zhuǎn)的原理當(dāng)你往buf里輸入足夠多的字符退出的順序就會(huì)是先覆蓋到舊rbp再覆蓋到返回地址。leave; ret中的leave會(huì)把rsp恢復(fù)成rbp然后從棧頂彈出舊rbp緊接著ret會(huì)從當(dāng)前rsp彈出返回地址跳轉(zhuǎn)到攻擊者控制的位置?;氐絛emowin函數(shù)地址如果已知比如編譯后通過(guò)readelf -s demo或nm demo查到假設(shè)它是0x401156實(shí)際以你自己編譯結(jié)果為準(zhǔn)那你只要精確算出返回地址離buf首地址的偏移再在偏移處放上win的地址就能讓程序在vulnerable返回時(shí)跳進(jìn)win。在x64下如果地址中會(huì)包含\x00字節(jié)大概率包含因?yàn)槟J(rèn)非PIE程序的代碼段地址是0x400000起直接用普通字符串輸入會(huì)截?cái)噙@時(shí)需要借助腳本寫(xiě)入。這個(gè)“字節(jié)截?cái)唷眴?wèn)題就是為什么實(shí)戰(zhàn)中payload都必須用腳本拼接而不是命令行手打字符串的原因。如果題目開(kāi)了PIE也沒(méi)關(guān)系思路類(lèi)似只是你得先泄露一個(gè)程序基地址比如從棧上殘留的main返回地址計(jì)算出基址再算出win的實(shí)際地址。這里背后依賴(lài)的就是“內(nèi)存區(qū)域相對(duì)偏移固定”這個(gè)事實(shí)相同可執(zhí)行文件映射下任何兩個(gè)代碼地址的差值固定所以知道一個(gè)代碼段地址就能算出任意代碼段地址。4. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄4.1 “偏移量怎么算都不對(duì)”你需要先搞清楚邊界很多新手計(jì)算溢出偏移時(shí)會(huì)直接用局部變量聲明大小比如buf[16]然后認(rèn)為返回地址離buf是168舊rbp字節(jié)共24字節(jié)。但在實(shí)際反匯編中因?yàn)闂?duì)齊、編譯器策略或數(shù)組位置不同情況會(huì)變。前面那個(gè)demo里聲明buf[16]實(shí)際gap是0x10加上舊rbp的0x8返回地址距buf首地址其實(shí)是0x1824字節(jié)——這里和聲明大小碰巧一致但真心不要依賴(lài)聲明大小來(lái)猜。正確做法是在gdb里下斷點(diǎn)查看buf首地址和當(dāng)前rsp及rbp分別計(jì)算差異或者使用pattern create生成字符串運(yùn)行崩潰后用pattern offset算出偏移。pwndbg的cyclic命令也是這個(gè)思路。實(shí)操里我會(huì)用兩個(gè)方法交叉確認(rèn)調(diào)試器看地址差以及pattern定位兩者一致時(shí)才算可靠。4.2 “為什么我明明覆蓋了返回地址程序還是崩了”常見(jiàn)原因有三個(gè)。第一覆蓋未對(duì)齊或地址寫(xiě)歪了。返回地址必須精確落在函數(shù)fun在ret時(shí)棧指針指向的位置如果差了一個(gè)字節(jié)ret會(huì)跳到一個(gè)錯(cuò)位的地址。第二x64地址包含\x00字節(jié)你用printf或命令行傳入字符串時(shí)被截?cái)鄬?dǎo)致只寫(xiě)入部分地址。這個(gè)要特別注意解決方法是所有地址都通過(guò)腳本精確寫(xiě)入字節(jié)不建議手打。第三你覆蓋了更多數(shù)據(jù)導(dǎo)致后續(xù)代碼期望的棧布局被破壞尤其在你使用system這類(lèi)函數(shù)時(shí)參數(shù)傳遞錯(cuò)誤也會(huì)引出新的crash。排查套路我推薦先在gdb中單步跟蹤到ret指令查看ret執(zhí)行前rsp指向的值是不是你預(yù)期地址以及該處內(nèi)容是否完整寫(xiě)入了你想要的地址然后用x/i查看rip將跳轉(zhuǎn)到的位置反匯編是否合理最后執(zhí)行并觀察崩潰時(shí)的寄存器、回溯。4.3 匯編指令風(fēng)格與調(diào)試器配置雷點(diǎn)初學(xué)階段最常見(jiàn)的問(wèn)題就是gdb默認(rèn)的ATT風(fēng)格讓人心智錯(cuò)亂。如果不統(tǒng)一成Intel風(fēng)格讀mov %eax,0x8(%rbp)時(shí)很容易把源和目的理解反。我的建議是在~/.gdbinit里直接寫(xiě)好set disassembly-flavor intel set pagination off另外gdb里地址前后綴格式不同也會(huì)帶來(lái)認(rèn)知負(fù)擔(dān)。pwndbg默認(rèn)會(huì)高亮地址并輸出十六進(jìn)制和符號(hào)信息很直觀。如果你堅(jiān)持原生gdb那么x命令查看內(nèi)存的方式要熟練x/20gx $rsp中的g表示以8字節(jié)為單位gnu“giant”每次顯示8字節(jié)x/gx $rbp一次顯示一個(gè)8字節(jié)的值。這些命令不熟所有分析都容易卡殼。4.4 新手最該避開(kāi)的幾個(gè)“看似正確”的練習(xí)姿勢(shì)第一不看反匯編直接背payload模板。Pwn不是背模板的科目同一個(gè)溢出點(diǎn)在不同保護(hù)策略和不同調(diào)用約定下payload完全不一樣。第二只看Writeup不自己動(dòng)手調(diào)。看別人exp的時(shí)候好像什么都懂了自己一收gdb輸出就傻眼。哪怕復(fù)現(xiàn)一個(gè)最簡(jiǎn)單的棧溢出也要親手走一遍“斷點(diǎn)、看內(nèi)存、算偏移、寫(xiě)payload、調(diào)試crash”的完整循環(huán)。第三忽略Ubuntu系統(tǒng)版本的libc差異。同一個(gè)system函數(shù)在不同glibc版本里偏移很可能不同因此刷題一定要確認(rèn)題目給出的libc版本本地用不同版本運(yùn)行會(huì)造成大量無(wú)意義debug。第二點(diǎn)是體驗(yàn)最深的一條我相信所有真正練過(guò)Pwn的人都經(jīng)歷過(guò)“看十篇不如調(diào)一次”的階段。面對(duì)一個(gè)新的漏洞點(diǎn)把調(diào)試器用熟用“假設(shè)-驗(yàn)證-修正”的節(jié)奏不斷逼近真相這個(gè)能力本身才是Pwn里面最值錢(qián)的東西。5. 實(shí)操建議收尾從這往后怎么走在文章的最后我本來(lái)還想把ROP鏈的構(gòu)造和libc泄露都展開(kāi)但那個(gè)內(nèi)容信息量極大放進(jìn)這篇文章里會(huì)稀釋掉前面基礎(chǔ)部分的濃度更適合作為系列第三、四篇的獨(dú)立主題。就個(gè)人實(shí)際體會(huì)而言一篇技術(shù)分享能把讀者帶到“能看懂匯編、知道操作系統(tǒng)內(nèi)存布局、能親手復(fù)現(xiàn)棧溢出”的程度已經(jīng)很了不起了。剩下的事需要靠你在真實(shí)的題目里把概念變成本能。給想繼續(xù)推進(jìn)的同學(xué)指?jìng)€(gè)方向找?guī)椎罌](méi)有開(kāi)啟任何保護(hù)的經(jīng)典棧溢出題目自己動(dòng)手做通幾遍把每個(gè)步驟做到不看任何Writeup也能完整復(fù)現(xiàn)然后逐步給編譯選項(xiàng)加上canary和PIE感受防護(hù)對(duì)利用思路的影響接著引入libc泄露和ROP鏈構(gòu)造用pwntools把自己從純手工調(diào)試中解放出來(lái)。這樣一步一步走Pwn的技術(shù)樹(shù)就會(huì)在你腦子里自然地生長(zhǎng)出來(lái)。有一個(gè)很小的實(shí)操心得值得分享初學(xué)者常常高估“理解”的作用低估“熟練”的作用。理解棧溢出原理很容易但要能在五分鐘內(nèi)完成一次“查看保護(hù)、找溢出點(diǎn)、算偏移、寫(xiě)payload、本地調(diào)通”的流程需要重復(fù)幾十道題目才做得到。所以如果你現(xiàn)在正卡在某個(gè)題目上別急著跳過(guò)去把調(diào)試器每一條輸出慢慢看懂這份復(fù)盤(pán)本身就是你最重要的技術(shù)積累。