:寄存器、棧幀與調(diào)用約定助你破解CTF逆向)
如果你是順著系列第一篇一路看下來(lái)的估計(jì)已經(jīng)跟我一樣有過(guò)這種體驗(yàn)第一次打開(kāi)反匯編窗口滿(mǎn)屏都是push rbp、mov rbp, rsp、sub rsp, 0x10這種連續(xù)短句單詞都認(rèn)識(shí)連起來(lái)完全不知道程序在干嘛。這個(gè)瞬間幾乎是所有逆向新手的分水嶺??邕^(guò)去以后你再看一個(gè)二進(jìn)制文件看到的就不再是亂碼而是一個(gè)有動(dòng)作、有意圖、有前后因果的執(zhí)行過(guò)程。這篇是CTF逆向工程入門(mén)系列的第二篇專(zhuān)門(mén)解決看不懂反匯編的問(wèn)題。我會(huì)圍繞 x86-64 架構(gòu)展開(kāi)先把寄存器、內(nèi)存分區(qū)、棧幀這幾個(gè)地基概念說(shuō)清楚再把數(shù)據(jù)搬運(yùn)、比較跳轉(zhuǎn)、函數(shù)調(diào)用這幾類(lèi)最常見(jiàn)指令當(dāng)一個(gè)一個(gè)的翻譯樣板講透。后面會(huì)穿插函數(shù)調(diào)用約定這個(gè)大殺器幫你從寄存器推斷一個(gè)函數(shù)到底接收了什么參數(shù)、返回什么值。最后我用一個(gè)模擬題目完整走一遍C代碼 - 匯編 - 逆向還原的閉環(huán)全程有可以直接照著敲的命令和調(diào)試方法。沒(méi)有逆向基礎(chǔ)的讀者可以放心我會(huì)默認(rèn)你完全不懂匯編但知道 C 語(yǔ)言長(zhǎng)什么樣。1. 逆向?qū)W習(xí)為什么要從匯編開(kāi)始先認(rèn)清你面對(duì)的“翻譯對(duì)象”很多新手一上來(lái)就沉迷反編譯器的偽代碼覺(jué)得匯編沒(méi)必要學(xué)。這個(gè)思路在簡(jiǎn)單的簽到題里可能不翻車(chē)但一旦題目加了混淆、去符號(hào)、花指令偽代碼立刻開(kāi)始不靠譜。到那個(gè)階段匯編是你唯一能抓住的可靠證據(jù)。1.1 CPU不認(rèn)識(shí)C語(yǔ)言它只按機(jī)器碼走先繞回到最底層的問(wèn)題程序在 CPU 里運(yùn)行時(shí)處理器看的不是if、for、printf而是一串硬編碼的二進(jìn)制字節(jié)也就是機(jī)器碼。機(jī)器碼本身就是 CPU 的指令集編碼什么字節(jié)對(duì)應(yīng)什么操作是硬件設(shè)計(jì)時(shí)定死的。正常人直接讀機(jī)器碼就像看天書(shū)所以需要反匯編器把它轉(zhuǎn)成助記符比如8B 45 F8轉(zhuǎn)成mov eax, DWORD PTR [rbp-0x8]。也就是說(shuō)你在反匯編工具里看到的每一條匯編指令其實(shí)對(duì)應(yīng)著真實(shí)執(zhí)行的每一個(gè) CPU 動(dòng)作。如果你想精確知道程序在某一個(gè)分支到底做了什么、某個(gè)數(shù)據(jù)在哪一步被覆蓋就只有匯編能給你這個(gè)精度。C 源碼在編譯之后會(huì)丟掉變量名、丟失類(lèi)型信息、丟失結(jié)構(gòu)化語(yǔ)法所謂“還原源碼”其實(shí)是逆向工程師一層層拼回去的假設(shè)前提就是你能讀懂匯編。1.2 偽代碼是“二手翻譯”匯編才是“一手證據(jù)”IDA 和 Ghidra 的偽代碼功能確實(shí)省時(shí)間我平時(shí)分析題目也會(huì)先掃一眼偽代碼定方向。但一定要記住偽代碼是反編譯器根據(jù)匯編推斷出來(lái)的中間表示這個(gè)推斷過(guò)程不是百分百可靠。舉個(gè)最常見(jiàn)的例子編譯器在優(yōu)化代碼時(shí)會(huì)把循環(huán)展開(kāi)成排列整齊的比較和跳轉(zhuǎn)反編譯器可能會(huì)把它還原成一個(gè)形狀很怪異的while或者do-while。再比如加了控制流平坦化的混淆題偽代碼會(huì)把邏輯壓成一大坨按switch分派的狀態(tài)機(jī)你根本看不出原來(lái)的流程。這時(shí)候只有對(duì)著匯編把一個(gè)個(gè)基本塊串起來(lái)才能恢復(fù)出真正的調(diào)用關(guān)系。我見(jiàn)過(guò)不少人在某個(gè)混淆題上卡了一整天最后發(fā)現(xiàn)是反編譯器把某個(gè)jz方向理解擰了。逆向這件事工具是輔助最終解釋權(quán)永遠(yuǎn)在匯編手里。1.3 匯編在CTF逆向里的三個(gè)高頻場(chǎng)景除了通用分析有些場(chǎng)景幾乎是專(zhuān)門(mén)為匯編準(zhǔn)備的緩沖區(qū)溢出類(lèi)題目你要明確知道局部變量在棧上的偏移、距離ret地址有多少字節(jié)這些信息只靠偽代碼看不清楚。算法還原很多題把 flag 逐字節(jié)做異或、置換、查表你要從位運(yùn)算指令還原每個(gè)字節(jié)的處理流程。補(bǔ)丁題要求你把某個(gè)跳轉(zhuǎn)條件改掉或者把某個(gè)函數(shù)直接改成return 0你不看懂jz和jmp的目標(biāo)就沒(méi)法下手。所以與其說(shuō)逆向入門(mén)要先啃匯編不如說(shuō)匯編本來(lái)就是逆向這個(gè)動(dòng)作的最小單位。后面幾節(jié)內(nèi)容就是在幫我們把這一層基礎(chǔ)打扎實(shí)。2. 寄存器、棧與內(nèi)存布局先把程序運(yùn)行的“場(chǎng)地”看清楚匯編指令看起來(lái)多但它們翻來(lái)覆去就是干三件事把數(shù)據(jù)放到寄存器或內(nèi)存里、對(duì)數(shù)據(jù)做運(yùn)算、根據(jù)結(jié)果跳轉(zhuǎn)。要理解這三件事你得先知道數(shù)據(jù)在 CPU 和內(nèi)存里是怎么被安排的。2.1 x86-64通用寄存器速查表x86-64 架構(gòu)下有 16 個(gè) 64 位通用寄存器每個(gè)寄存器沒(méi)有絕對(duì)的“固定職責(zé)”但在系統(tǒng)調(diào)用約定和編譯器使用習(xí)慣下有幾個(gè)長(zhǎng)期形成的默認(rèn)分工寄存器常見(jiàn)角色備注rax函數(shù)返回值、運(yùn)算累加器也用于系統(tǒng)調(diào)用號(hào)rbx通用數(shù)據(jù)保存被調(diào)用者保存rcx第4個(gè)參數(shù)部分調(diào)用約定、循環(huán)計(jì)數(shù)在 Windows x64 中是第4個(gè)參數(shù)rdx第3個(gè)參數(shù)、乘除法擴(kuò)展也存除法余數(shù)rsi第2個(gè)參數(shù)、源地址rdi第1個(gè)參數(shù)、目標(biāo)地址記法destination/dirbp棧幀基址手動(dòng)保留幀指針的典型標(biāo)志rsp棧頂指針永遠(yuǎn)指向當(dāng)前棧位置r8-r15通用寄存器r8、r9 也是第5、第6個(gè)參數(shù)另外每一對(duì) 64 位寄存器都能以低半部分方式訪(fǎng)問(wèn)rax的低 32 位是eax低 16 位是axax又拆成高 8 位ah和低 8 位al。新手看反匯編時(shí)要習(xí)慣這種「寫(xiě)小寄存器會(huì)清零高 32 位」的特性比如mov eax, 0會(huì)連帶著把整個(gè)rax清零因?yàn)橄?32 位寄存器寫(xiě)入時(shí)高 32 位會(huì)自動(dòng)填 0。這一點(diǎn)在分析返回值、判斷判斷結(jié)果時(shí)經(jīng)常成為迷惑點(diǎn)。2.2 專(zhuān)用寄存器與標(biāo)志位RIP、RSP、RBP、RFLAGS通用寄存器之上還有幾個(gè)玩家必須認(rèn)識(shí)的專(zhuān)用寄存器。rip是指令指針存的是 CPU 下一次要取哪條指令的地址它不歸普通指令直接操作。rsp是棧頂指針始終指向當(dāng)前棧幀的最低地址注意棧是朝低地址方向長(zhǎng)的所以函數(shù)分配空間時(shí)rsp會(huì)變小。rbp是棧幀基址指向當(dāng)前函數(shù)棧幀的固定位置編譯器常用它來(lái)定位局部變量你可以把rbp想成“當(dāng)前函數(shù)這一層空間的基準(zhǔn)線(xiàn)”。rflags是標(biāo)志寄存器里面一堆標(biāo)志位是判斷的根據(jù)。最常遇到的四個(gè)ZF零標(biāo)志結(jié)果為 0 時(shí)置 1SF符號(hào)標(biāo)志結(jié)果有符號(hào)解釋為負(fù)時(shí)置 1CF進(jìn)位標(biāo)志無(wú)符號(hào)溢出時(shí)出現(xiàn)OF溢出標(biāo)志有符號(hào)溢出時(shí)出現(xiàn)。你不需要背全但一定要知道cmp和test這兩類(lèi)指令的本質(zhì)就是改標(biāo)志位后面緊跟的jz、jl這些跳轉(zhuǎn)指令看的就是這些標(biāo)志。2.3 內(nèi)存分區(qū)代碼、數(shù)據(jù)、堆和棧四個(gè)“抽屜”各管各的一個(gè)進(jìn)程的地址空間大致分成幾段。.text段放指令也就是匯編本身.rodata和.data放只讀常量和已初始化全局變量.bss放未初始化全局變量堆從低地址向高地址動(dòng)態(tài)分配棧從高地址向低地址生長(zhǎng)。逆向時(shí)最常見(jiàn)的操作是“找字符串”。字符串常量一般泡在.rodata里objdump會(huì)顯示類(lèi)似401024這樣的地址然后你可以用x/s 0x401024在調(diào)試器里看內(nèi)容。另外堆和棧是相向而長(zhǎng)的棧越用越低堆越用越高它們之間不會(huì)見(jiàn)面但緩沖區(qū)溢出攻擊要跨越的恰恰就是這條看不見(jiàn)的安全邊界。2.4 函數(shù)調(diào)用時(shí)棧上發(fā)生了什么棧幀的誕生與銷(xiāo)毀一臺(tái)簡(jiǎn)單程序內(nèi)部棧幀就是每次函數(shù)調(diào)用來(lái)臨時(shí)搭起的“工作臺(tái)”。調(diào)用者要調(diào)用函數(shù)時(shí)先把參數(shù)放進(jìn)寄存器或者壓棧然后執(zhí)行call。call指令做兩件事把下一條指令的地址返回地址壓到棧上再跳轉(zhuǎn)到目標(biāo)函數(shù)。被調(diào)函數(shù)開(kāi)頭常見(jiàn)的三連是push rbp; mov rbp, rsp; sub rsp, 0x20這就是建立新的棧幀。前兩句把調(diào)用者的rbp存起來(lái)再把rbp指向當(dāng)前位置sub rsp是為局部變量騰出空間。函數(shù)返回前的招牌動(dòng)作是leave等效于mov rsp, rbp; pop rbp然后ret把返回地址彈回rip。初學(xué)者只要把這條“搭臺(tái)、演戲、拆臺(tái)”的流程刻在腦子里再去看斷點(diǎn)處的棧就很容易定位每一層是誰(shuí)在調(diào)用誰(shuí)。3. 常用指令的“翻譯”訓(xùn)練看到它們就想到什么匯編指令一大堆但 CTF 復(fù)現(xiàn)高頻出現(xiàn)的就那么幾十條。我按功能分成四組每一組給出一套翻譯習(xí)慣你看到指令能條件反射式地說(shuō)出這行在干嘛那這關(guān)就過(guò)了。3.1 數(shù)據(jù)搬運(yùn)與運(yùn)算mov、lea、add、sub、xormov dst, src是雙人運(yùn)輸隊(duì)把源操作數(shù)的值復(fù)制到目標(biāo)操作數(shù)。注意 Intel 語(yǔ)法是“目標(biāo)在左邊源在右邊”和我一會(huì)要講的 ATT 語(yǔ)法剛好相反。大多數(shù)情況下你在逆向里看到一行mov rax, [rbp-8]就是“把某個(gè)局部變量的值拿出來(lái)放到 rax”。lea rax, [rbp-0x20]是另一個(gè)高頻指令它算的是“有效地址”也就是把[rbp-0x20]這個(gè)地址本身放進(jìn)rax而不是取地址里的內(nèi)容。你可以類(lèi)比成拿到快遞柜的柜號(hào)而不是柜號(hào)里的包裹。編譯器計(jì)算數(shù)組地址、指針運(yùn)算時(shí)喜歡用lea因?yàn)樗茉谝粋€(gè)指令里完成“基址 偏移”的計(jì)算。運(yùn)算指令里add/sub就是加減xor a, a就是清零因?yàn)樗旧淼扔谕瑫r(shí)把每一位歸零inc/dec是自增自減mul和無(wú)符號(hào)除法div比較特殊因?yàn)樗鼈兪褂秒[式寄存器比如div rcx實(shí)際是把rdx:rax這個(gè) 128 位組合除以rcx商留在rax余數(shù)留在rdx。新手分析除法時(shí)第一反應(yīng)往往是“這是個(gè)復(fù)雜的公式”其實(shí)編譯器經(jīng)常會(huì)用移位替代除法所以看到sar右移和imul魔法數(shù)往往是遇到除數(shù)為常量的優(yōu)化。3.2 比較與跳轉(zhuǎn)cmp、test 與 jcc 分支指令cmp a, b會(huì)計(jì)算 a - b但結(jié)果不寫(xiě)回只更新標(biāo)志位。緊隨其后的jcc指令根據(jù)標(biāo)志位決定是否跳轉(zhuǎn)這就是源碼里的if、switch、循環(huán)在匯編里的長(zhǎng)相。高頻跳轉(zhuǎn)表可以這樣記憶指令含義判斷條件jz/je結(jié)果為零 / 相等則跳轉(zhuǎn)ZF1jnz/jne結(jié)果非零 / 不等則跳轉(zhuǎn)ZF0jg有符號(hào)大于SFOF 且 ZF0jl有符號(hào)小于SF≠OFjg的親戚jge、jle有符號(hào)大于等于、小于等于組合條件ja無(wú)符號(hào)大于CF0 且 ZF0jb無(wú)符號(hào)小于CF1這里最容易踩的坑是把有符號(hào)和無(wú)符號(hào)跳轉(zhuǎn)搞混。strlen返回長(zhǎng)度、地址和數(shù)組長(zhǎng)度這類(lèi)無(wú)符號(hào)比較編譯器愛(ài)用ja/jb而常規(guī)整數(shù)比較則用jg/jl。一旦你把無(wú)符號(hào)比較看成有符號(hào)還原出來(lái)的判斷條件就可能差之千里。test a, b更像是隱形探針?biāo)?jì)算 a b也不寫(xiě)結(jié)果只更新標(biāo)志位。它的高頻姿勢(shì)是test eax, eax后面跟jz來(lái)判斷某個(gè)值是否為 0。看到這個(gè)組合你基本就知道代碼里有一句“如果該變量等于 0 就怎么樣”的邏輯。3.3 函數(shù)邊界call、ret、leave 與 push/poppush和pop的職責(zé)很單純壓棧和彈出。push rax等于sub rsp, 8; mov [rsp], raxpop rax等于mov rax, [rsp]; add rsp, 8。它們最重要的用途不是傳參而是保存現(xiàn)場(chǎng)——進(jìn)入函數(shù)時(shí)把調(diào)用者需要保留的值壓棧返回前彈回去。call看起來(lái)是跳轉(zhuǎn)實(shí)際上比跳轉(zhuǎn)多壓了一條返回地址。ret就是pop rip把棧頂彈進(jìn)指令指針程序立刻回到call的下一條指令。leave前面提到過(guò)等價(jià)于先恢復(fù)棧頂?shù)絩bp再?gòu)棾雠f的rbp把棧幀整體拆掉。分析函數(shù)邊界時(shí)我習(xí)慣先找push rbp和leave; ret把它們之間的代碼看作一個(gè)函數(shù)。如果一條指令序列里沒(méi)有call卻頻繁ret往往就碰到了花指令或者混淆段。3.4 逆向里的“小暗號(hào)”與慣性識(shí)別匯編看多了以后你會(huì)發(fā)現(xiàn)很多套路是反反復(fù)復(fù)出現(xiàn)的xor eax, eax幾乎等于“這個(gè)表達(dá)式結(jié)果為 0”因?yàn)榫幾g器用 2 字節(jié)的xor比 5 字節(jié)的mov eax,0更省空間。lea rsi, [rip0x2eb3]后面跟call strlenplt基本是“取一個(gè)編譯期字符串的地址丟給 strlen/printf”。循環(huán)里出現(xiàn)add rax, 1; cmp rax, 0x10; jne就是標(biāo)準(zhǔn)的遍歷數(shù)組rax 是游標(biāo)。test eax, eax; jne xxx是判斷返回值是否為 0 的典型組合。這類(lèi)“暗號(hào)”形成速度很快只要你看的題目超過(guò)十道就再也不會(huì)把lea和mov搞混了。關(guān)鍵是別死記要在下文實(shí)操里多接觸真實(shí)反匯編。4. 函數(shù)調(diào)用約定把匯編“翻譯”回 C 函數(shù)的標(biāo)尺當(dāng)你看到一段匯編里連續(xù)幾條mov rdi, ...、mov rsi, ...然后call一個(gè)外部函數(shù)你大概就能猜出這個(gè) C 調(diào)用長(zhǎng)什么樣。這靠的就是調(diào)用約定。4.1 System V AMD64 調(diào)用約定速記CTF 里常見(jiàn)的 Linux ELF 文件絕大多數(shù)遵循 System V AMD64 調(diào)用約定。速記版是前六個(gè)整數(shù)或指針參數(shù)按順序放在rdi、rsi、rdx、rcx、r8、r9第七個(gè)及以后的參數(shù)從右往左壓棧返回值放rax。浮點(diǎn)參數(shù)另有xmm0-xmm7初學(xué)階段可以先放一放。這個(gè)約定有多實(shí)用你只要在反匯編里看到call strlenplt之前有人把rdi設(shè)置成一個(gè)地址就能確定 strlen 的參數(shù)就是這個(gè)地址。再看到call scanf之前rdi是格式串、rsi是緩沖區(qū)地址你就能直接還原出scanf(%31s, buf)這樣的調(diào)用。4.2 調(diào)用者保存與被調(diào)用者保存寄存器到底誰(shuí)來(lái)“善后”調(diào)用約定里還有個(gè)看似繞但其實(shí)很重要的分配有些寄存器是調(diào)用者保存caller-saved有些是被調(diào)用者保存callee-saved。意思是調(diào)用者如果希望某個(gè)寄存器在函數(shù)調(diào)用前后保持原值要么自己保存調(diào)用者負(fù)責(zé)要么期望被調(diào)函數(shù)不修改那些被調(diào)用者保存的寄存器。System V 約定里rax、rcx、rdx、rsi、rdi、r8-r11是調(diào)用者保存rbx、rbp、r12-r15是被調(diào)用者保存。實(shí)際觀察時(shí)被調(diào)用者保存的寄存器經(jīng)常出現(xiàn)在函數(shù) prologuepush rbx中間把rbx拿來(lái)存一些要跨調(diào)用存活的局部量函數(shù)結(jié)尾再pop rbx。這個(gè)現(xiàn)象也給你一個(gè)判斷依據(jù)如果一個(gè)函數(shù)中間調(diào)用了外部函數(shù)還指望某個(gè)寄存器值跨調(diào)用不丟那它大概率用的是rbx或r12-r15這類(lèi)被調(diào)用者保存寄存器。4.3 用調(diào)用約定快速反推函數(shù)原型一個(gè)典型片段假設(shè)你看到了這段反匯編mov rdi, [rbp-0x8] call strlenplt cmp rax, 8 je 0x401168 mov eax, 0 jmp 0x40117d這種片段在簽到題里出現(xiàn)頻率極高。先把寄存器映射一下rdi里放了某個(gè)局部變量值它被當(dāng)作 strlen 的參數(shù)所以局部變量至少是個(gè)字符串指針或 char 數(shù)組地址。strlen 的返回值在rax緊跟著cmp rax, 8就是“這個(gè)字符串長(zhǎng)度是否等于 8”。后面的mov eax, 0是返回值 0jmp跳向函數(shù)尾部。你幾乎可以不加思索地寫(xiě)成if (strlen(input) ! 8) return 0;這就是調(diào)用約定給逆向帶來(lái)的直接效果。你不光能看到指令還能看到一條條 C 語(yǔ)句的影子。5. 實(shí)操C 源碼到匯編再到逆向還原完整走一遍紙上談兵夠了我們現(xiàn)在取一個(gè)模擬題目二進(jìn)制把從“源碼編譯”到“反匯編還原”的完整鏈路走一遍。這個(gè)二進(jìn)制是我為演示生成的沒(méi)有符號(hào)混淆適合作為入門(mén)復(fù)現(xiàn)樣本。5.1 準(zhǔn)備一個(gè)模擬樣例程序先用 C 寫(xiě)一段邏輯簡(jiǎn)單但覆蓋面足夠的代碼保存為 demo.c#include stdio.h #include string.h int check(char *s) { if (strlen(s) ! 8) return 0; return strcmp(s, secret01) 0; } int main(void) { char buf[32]; puts(Input:); scanf(%31s, buf); if (check(buf)) puts(Correct); else puts(Wrong); return 0; }編譯進(jìn)一個(gè)叫 demo 的二進(jìn)制gcc -O0 -no-pie -fno-stack-protector -o demo demo.c-O0關(guān)閉優(yōu)化讓匯編和 C 代碼的對(duì)應(yīng)關(guān)系最直觀-no-pie讓地址固定方便我們按絕對(duì)地址找目標(biāo)-fno-stack-protector是為了避免金絲雀干擾閱讀。入門(mén)階段這樣開(kāi)編譯選項(xiàng)最友好。5.2 用 objdump 看一眼反匯編拿到二進(jìn)制后先做兩件事strings 和 objdump。strings 看字符串資源objdump 看函數(shù)清單strings demo objdump -d -M intel demo | lessstrings輸出里能看到Input:、Correct、Wrong和secret01這已經(jīng)告訴你程序核心是個(gè)字符串比對(duì)。objdump出來(lái)可以搜check、main。我先貼出 check 的反匯編帶注釋版check: push rbp mov rbp,rsp sub rsp,0x10 mov QWORD PTR [rbp-0x8],rdi mov rax,QWORD PTR [rbp-0x8] mov rdi,rax call strlenplt cmp rax,0x8 je 0x401168 mov eax,0x0 jmp 0x40117d mov rax,QWORD PTR [rbp-0x8] lea rsi,[rip0x2eb3] # 0x404028 mov rdi,rax call strcmpplt test eax,eax jne 0x40117a mov eax,0x1 jmp 0x40117d mov eax,0x0 leave ret注意lea rsi,[rip0x2eb3]里的0x404028那個(gè)地址基本就是 data 段里字符串常量的位置。我們?cè)?GDB 里驗(yàn)證它是不是 secret01。5.3 現(xiàn)場(chǎng)解讀 check 的每一個(gè)基本塊這個(gè)函數(shù)開(kāi)頭三句push rbp; mov rbp,rsp; sub rsp,0x10是標(biāo)準(zhǔn)開(kāi)場(chǎng)不用多說(shuō)。mov [rbp-0x8], rdi把參數(shù)暫時(shí)存到棧上mov rdi,rax又把參數(shù)取出來(lái)放回rdi然后call strlenplt。編譯器在 -O0 下經(jīng)常會(huì)出現(xiàn)這種“參數(shù)放棧又取出”的冗余搬運(yùn)別當(dāng)成復(fù)雜邏輯。strlen 結(jié)果出來(lái)后cmp rax,0x8判斷長(zhǎng)度是否等于 8。如果相等就je跳到0x401168直接進(jìn)入 strcmp 部分不相等就mov eax,0返回 0。再看 strcmp 調(diào)用結(jié)束后的test eax,eax; jne如果 strcmp 的結(jié)果不是 0說(shuō)明兩個(gè)字符串不相等跳到mov eax,0相等則走到mov eax,1。兩個(gè)分支最后都匯合到leave; ret。這個(gè)流程對(duì)應(yīng)源碼的 check 函數(shù)幾乎是一一映射。5.4 逆向還原成可讀邏輯按照我們前面的方法先識(shí)別函數(shù)邊界、再識(shí)別參數(shù)寄存器、最后看返回路徑可以還原出如下的偽代碼int check(char *s) { if (strlen(s) ! 8) return 0; if (strcmp(s, secret01) 0) return 1; return 0; }你再看 main 部分會(huì)看到類(lèi)似scanf(%31s, buf)后mov rdi, rax; call check后面接test eax,eax; jz之類(lèi)。這些合起來(lái)就是if (check(buf)) puts(Correct); else puts(Wrong);到這里一個(gè)簽到題的逆向還原就完成了。整個(gè)過(guò)程沒(méi)有依賴(lài)反編譯器的偽代碼完全靠匯編和調(diào)用約定推出來(lái)。想練手的讀者可以把我上面代碼用不同優(yōu)化等級(jí)重新編譯再自己還原一遍效果比看十遍教程都管用。6. 新手最容易踩的坑與調(diào)試經(jīng)驗(yàn)實(shí)錄匯編這塊的坑大多不是“看不懂”而是“看岔了”。下面這四條是我在帶練習(xí)時(shí)見(jiàn)得最多的翻車(chē)現(xiàn)場(chǎng)。6.1 Intel 語(yǔ)法和 ATT 語(yǔ)法混著看操作數(shù)方向全反Linux 下gcc生成的匯編默認(rèn)是 ATT 語(yǔ)法而多數(shù)破解工具默認(rèn)給你看 Intel 語(yǔ)法。兩者的操作數(shù)方向是反的Intel 是“目標(biāo)在左源在右”mov eax, ebx表示把 ebx 復(fù)制到 eaxATT 是“源在左目標(biāo)在右”mov %ebx, %eax表示同樣的操作。如果你一會(huì)看 objdump 的 ATT 輸出一會(huì)看 Ghidra 的 Intel 視圖第一反應(yīng)很容易把賦值方向搞反后面所有判斷就全錯(cuò)了。我建議統(tǒng)一成 Intel 語(yǔ)法objdump加-M intelGDB 里執(zhí)行set disassembly-flavor intel。先統(tǒng)一再分析能幫你少走很多彎路。6.2 棧向下增長(zhǎng)、數(shù)組向上增長(zhǎng)方向不要搞反棧幀的“向下”指的是地址變小。所以函數(shù)開(kāi)頭sub rsp, 0x20是給局部變量騰空間而不是在內(nèi)存里向上擴(kuò)展。數(shù)組訪(fǎng)問(wèn)則相反buf[0]在低地址buf[15]在高地址。這個(gè)方向問(wèn)題在處理溢出題時(shí)尤其要命。假設(shè)你在棧上有一塊char buf[16]它位于某個(gè)更低的地址而返回地址在高地址方向輸入數(shù)據(jù)越界寫(xiě)越多就會(huì)往返回地址方向覆蓋。如果你把方向記反了算偏移時(shí)就會(huì)差出一整個(gè)緩沖區(qū)長(zhǎng)度定位不到真正的覆蓋點(diǎn)。6.3 題目沒(méi)有 main、沒(méi)有符號(hào)時(shí)別在入口處迷路CTF 的題經(jīng)常被 strip 過(guò)main符號(hào)會(huì)消失。這時(shí)候別慌程序的真正入口是_start不是main??梢杂胷eadelf -h demo看入口點(diǎn)但更實(shí)用的做法是在調(diào)試器里斷在只被調(diào)用一次、并且后面跟著大量棧初始化的函數(shù)或者直接看動(dòng)態(tài)符號(hào)表里調(diào)用了哪些外部函數(shù)反推它所在函數(shù)的功能。還有一類(lèi)題型是靜態(tài)庫(kù)打包main 藏在某個(gè)你完全沒(méi)聽(tīng)過(guò)名字的函數(shù)里。這種判斷方法一致找puts、scanf、fgets等輸入輸出函數(shù)的調(diào)用位置最像用戶(hù)入口的函數(shù)基本就在那一帶。6.4 調(diào)試時(shí)幾個(gè)保命習(xí)慣建議現(xiàn)在就練每分析一個(gè)函數(shù)先在紙上寫(xiě)清“參數(shù)從哪里來(lái)、返回值是什么、條件跳轉(zhuǎn)分成幾支”。不用很工整但一定要寫(xiě)。GDB 里多用info registers、x/gx $rsp、x/s 某地址來(lái)對(duì)照寄存器與內(nèi)存的實(shí)況不要閉著眼只看反匯編。遇到call外部函數(shù)之前先看它往寄存器里塞了什么那就是你的參數(shù)表。還原偽代碼時(shí)先把所有jmp匯合點(diǎn)標(biāo)出來(lái)再回頭看條件跳轉(zhuǎn)這樣不會(huì)在多個(gè)分支間轉(zhuǎn)暈。這些習(xí)慣堅(jiān)持兩三個(gè)題目效果會(huì)非常明顯。寫(xiě)到這兒我想起自己剛開(kāi)始練匯編時(shí)的土辦法找一道基礎(chǔ)簽到題把check或者加密函數(shù)那一段匯編打印出來(lái)用筆一行行做注釋標(biāo)清楚每一步改了哪個(gè)寄存器、跳向了哪里。這個(gè)辦法看著笨但它是把寄存器分工、棧布局、調(diào)用約定全部焊進(jìn)腦子里的最快路徑。如果你正在某道題上卡住別急著開(kāi)反編譯器的偽代碼窗口先手動(dòng)抄一遍關(guān)鍵函數(shù)的匯編你的思路大概率會(huì)突然被打開(kāi)。下一篇我們接著往后聊把加密識(shí)別和常見(jiàn)算法逆向帶進(jìn)來(lái)到時(shí)候你就會(huì)發(fā)現(xiàn)眼前這些密密麻麻的指令已經(jīng)變得相當(dāng)順眼了。