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