x86-64汇编入门:寄存器、栈帧与调用约定助你破解CTF逆向

发布时间:2026/10/17 7:23:00
x86-64汇编入门:寄存器、栈帧与调用约定助你破解CTF逆向 如果你是顺着系列第一篇一路看下来的估计已经跟我一样有过这种体验第一次打开反汇编窗口满屏都是push rbp、mov rbp, rsp、sub rsp, 0x10这种连续短句单词都认识连起来完全不知道程序在干嘛。这个瞬间几乎是所有逆向新手的分水岭。跨过去以后你再看一个二进制文件看到的就不再是乱码而是一个有动作、有意图、有前后因果的执行过程。这篇是CTF逆向工程入门系列的第二篇专门解决看不懂反汇编的问题。我会围绕 x86-64 架构展开先把寄存器、内存分区、栈帧这几个地基概念说清楚再把数据搬运、比较跳转、函数调用这几类最常见指令当一个一个的翻译样板讲透。后面会穿插函数调用约定这个大杀器帮你从寄存器推断一个函数到底接收了什么参数、返回什么值。最后我用一个模拟题目完整走一遍C代码 - 汇编 - 逆向还原的闭环全程有可以直接照着敲的命令和调试方法。没有逆向基础的读者可以放心我会默认你完全不懂汇编但知道 C 语言长什么样。1. 逆向学习为什么要从汇编开始先认清你面对的“翻译对象”很多新手一上来就沉迷反编译器的伪代码觉得汇编没必要学。这个思路在简单的签到题里可能不翻车但一旦题目加了混淆、去符号、花指令伪代码立刻开始不靠谱。到那个阶段汇编是你唯一能抓住的可靠证据。1.1 CPU不认识C语言它只按机器码走先绕回到最底层的问题程序在 CPU 里运行时处理器看的不是if、for、printf而是一串硬编码的二进制字节也就是机器码。机器码本身就是 CPU 的指令集编码什么字节对应什么操作是硬件设计时定死的。正常人直接读机器码就像看天书所以需要反汇编器把它转成助记符比如8B 45 F8转成mov eax, DWORD PTR [rbp-0x8]。也就是说你在反汇编工具里看到的每一条汇编指令其实对应着真实执行的每一个 CPU 动作。如果你想精确知道程序在某一个分支到底做了什么、某个数据在哪一步被覆盖就只有汇编能给你这个精度。C 源码在编译之后会丢掉变量名、丢失类型信息、丢失结构化语法所谓“还原源码”其实是逆向工程师一层层拼回去的假设前提就是你能读懂汇编。1.2 伪代码是“二手翻译”汇编才是“一手证据”IDA 和 Ghidra 的伪代码功能确实省时间我平时分析题目也会先扫一眼伪代码定方向。但一定要记住伪代码是反编译器根据汇编推断出来的中间表示这个推断过程不是百分百可靠。举个最常见的例子编译器在优化代码时会把循环展开成排列整齐的比较和跳转反编译器可能会把它还原成一个形状很怪异的while或者do-while。再比如加了控制流平坦化的混淆题伪代码会把逻辑压成一大坨按switch分派的状态机你根本看不出原来的流程。这时候只有对着汇编把一个个基本块串起来才能恢复出真正的调用关系。我见过不少人在某个混淆题上卡了一整天最后发现是反编译器把某个jz方向理解拧了。逆向这件事工具是辅助最终解释权永远在汇编手里。1.3 汇编在CTF逆向里的三个高频场景除了通用分析有些场景几乎是专门为汇编准备的缓冲区溢出类题目你要明确知道局部变量在栈上的偏移、距离ret地址有多少字节这些信息只靠伪代码看不清楚。算法还原很多题把 flag 逐字节做异或、置换、查表你要从位运算指令还原每个字节的处理流程。补丁题要求你把某个跳转条件改掉或者把某个函数直接改成return 0你不看懂jz和jmp的目标就没法下手。所以与其说逆向入门要先啃汇编不如说汇编本来就是逆向这个动作的最小单位。后面几节内容就是在帮我们把这一层基础打扎实。2. 寄存器、栈与内存布局先把程序运行的“场地”看清楚汇编指令看起来多但它们翻来覆去就是干三件事把数据放到寄存器或内存里、对数据做运算、根据结果跳转。要理解这三件事你得先知道数据在 CPU 和内存里是怎么被安排的。2.1 x86-64通用寄存器速查表x86-64 架构下有 16 个 64 位通用寄存器每个寄存器没有绝对的“固定职责”但在系统调用约定和编译器使用习惯下有几个长期形成的默认分工寄存器常见角色备注rax函数返回值、运算累加器也用于系统调用号rbx通用数据保存被调用者保存rcx第4个参数部分调用约定、循环计数在 Windows x64 中是第4个参数rdx第3个参数、乘除法扩展也存除法余数rsi第2个参数、源地址rdi第1个参数、目标地址记法destination/dirbp栈帧基址手动保留帧指针的典型标志rsp栈顶指针永远指向当前栈位置r8-r15通用寄存器r8、r9 也是第5、第6个参数另外每一对 64 位寄存器都能以低半部分方式访问rax的低 32 位是eax低 16 位是axax又拆成高 8 位ah和低 8 位al。新手看反汇编时要习惯这种「写小寄存器会清零高 32 位」的特性比如mov eax, 0会连带着把整个rax清零因为向 32 位寄存器写入时高 32 位会自动填 0。这一点在分析返回值、判断判断结果时经常成为迷惑点。2.2 专用寄存器与标志位RIP、RSP、RBP、RFLAGS通用寄存器之上还有几个玩家必须认识的专用寄存器。rip是指令指针存的是 CPU 下一次要取哪条指令的地址它不归普通指令直接操作。rsp是栈顶指针始终指向当前栈帧的最低地址注意栈是朝低地址方向长的所以函数分配空间时rsp会变小。rbp是栈帧基址指向当前函数栈帧的固定位置编译器常用它来定位局部变量你可以把rbp想成“当前函数这一层空间的基准线”。rflags是标志寄存器里面一堆标志位是判断的根据。最常遇到的四个ZF零标志结果为 0 时置 1SF符号标志结果有符号解释为负时置 1CF进位标志无符号溢出时出现OF溢出标志有符号溢出时出现。你不需要背全但一定要知道cmp和test这两类指令的本质就是改标志位后面紧跟的jz、jl这些跳转指令看的就是这些标志。2.3 内存分区代码、数据、堆和栈四个“抽屉”各管各的一个进程的地址空间大致分成几段。.text段放指令也就是汇编本身.rodata和.data放只读常量和已初始化全局变量.bss放未初始化全局变量堆从低地址向高地址动态分配栈从高地址向低地址生长。逆向时最常见的操作是“找字符串”。字符串常量一般泡在.rodata里objdump会显示类似401024这样的地址然后你可以用x/s 0x401024在调试器里看内容。另外堆和栈是相向而长的栈越用越低堆越用越高它们之间不会见面但缓冲区溢出攻击要跨越的恰恰就是这条看不见的安全边界。2.4 函数调用时栈上发生了什么栈帧的诞生与销毁一台简单程序内部栈帧就是每次函数调用来临时搭起的“工作台”。调用者要调用函数时先把参数放进寄存器或者压栈然后执行call。call指令做两件事把下一条指令的地址返回地址压到栈上再跳转到目标函数。被调函数开头常见的三连是push rbp; mov rbp, rsp; sub rsp, 0x20这就是建立新的栈帧。前两句把调用者的rbp存起来再把rbp指向当前位置sub rsp是为局部变量腾出空间。函数返回前的招牌动作是leave等效于mov rsp, rbp; pop rbp然后ret把返回地址弹回rip。初学者只要把这条“搭台、演戏、拆台”的流程刻在脑子里再去看断点处的栈就很容易定位每一层是谁在调用谁。3. 常用指令的“翻译”训练看到它们就想到什么汇编指令一大堆但 CTF 复现高频出现的就那么几十条。我按功能分成四组每一组给出一套翻译习惯你看到指令能条件反射式地说出这行在干嘛那这关就过了。3.1 数据搬运与运算mov、lea、add、sub、xormov dst, src是双人运输队把源操作数的值复制到目标操作数。注意 Intel 语法是“目标在左边源在右边”和我一会要讲的 ATT 语法刚好相反。大多数情况下你在逆向里看到一行mov rax, [rbp-8]就是“把某个局部变量的值拿出来放到 rax”。lea rax, [rbp-0x20]是另一个高频指令它算的是“有效地址”也就是把[rbp-0x20]这个地址本身放进rax而不是取地址里的内容。你可以类比成拿到快递柜的柜号而不是柜号里的包裹。编译器计算数组地址、指针运算时喜欢用lea因为它能在一个指令里完成“基址 偏移”的计算。运算指令里add/sub就是加减xor a, a就是清零因为它本身等于同时把每一位归零inc/dec是自增自减mul和无符号除法div比较特殊因为它们使用隐式寄存器比如div rcx实际是把rdx:rax这个 128 位组合除以rcx商留在rax余数留在rdx。新手分析除法时第一反应往往是“这是个复杂的公式”其实编译器经常会用移位替代除法所以看到sar右移和imul魔法数往往是遇到除数为常量的优化。3.2 比较与跳转cmp、test 与 jcc 分支指令cmp a, b会计算 a - b但结果不写回只更新标志位。紧随其后的jcc指令根据标志位决定是否跳转这就是源码里的if、switch、循环在汇编里的长相。高频跳转表可以这样记忆指令含义判断条件jz/je结果为零 / 相等则跳转ZF1jnz/jne结果非零 / 不等则跳转ZF0jg有符号大于SFOF 且 ZF0jl有符号小于SF≠OFjg的亲戚jge、jle有符号大于等于、小于等于组合条件ja无符号大于CF0 且 ZF0jb无符号小于CF1这里最容易踩的坑是把有符号和无符号跳转搞混。strlen返回长度、地址和数组长度这类无符号比较编译器爱用ja/jb而常规整数比较则用jg/jl。一旦你把无符号比较看成有符号还原出来的判断条件就可能差之千里。test a, b更像是隐形探针它计算 a b也不写结果只更新标志位。它的高频姿势是test eax, eax后面跟jz来判断某个值是否为 0。看到这个组合你基本就知道代码里有一句“如果该变量等于 0 就怎么样”的逻辑。3.3 函数边界call、ret、leave 与 push/poppush和pop的职责很单纯压栈和弹出。push rax等于sub rsp, 8; mov [rsp], raxpop rax等于mov rax, [rsp]; add rsp, 8。它们最重要的用途不是传参而是保存现场——进入函数时把调用者需要保留的值压栈返回前弹回去。call看起来是跳转实际上比跳转多压了一条返回地址。ret就是pop rip把栈顶弹进指令指针程序立刻回到call的下一条指令。leave前面提到过等价于先恢复栈顶到rbp再弹出旧的rbp把栈帧整体拆掉。分析函数边界时我习惯先找push rbp和leave; ret把它们之间的代码看作一个函数。如果一条指令序列里没有call却频繁ret往往就碰到了花指令或者混淆段。3.4 逆向里的“小暗号”与惯性识别汇编看多了以后你会发现很多套路是反反复复出现的xor eax, eax几乎等于“这个表达式结果为 0”因为编译器用 2 字节的xor比 5 字节的mov eax,0更省空间。lea rsi, [rip0x2eb3]后面跟call strlenplt基本是“取一个编译期字符串的地址丢给 strlen/printf”。循环里出现add rax, 1; cmp rax, 0x10; jne就是标准的遍历数组rax 是游标。test eax, eax; jne xxx是判断返回值是否为 0 的典型组合。这类“暗号”形成速度很快只要你看的题目超过十道就再也不会把lea和mov搞混了。关键是别死记要在下文实操里多接触真实反汇编。4. 函数调用约定把汇编“翻译”回 C 函数的标尺当你看到一段汇编里连续几条mov rdi, ...、mov rsi, ...然后call一个外部函数你大概就能猜出这个 C 调用长什么样。这靠的就是调用约定。4.1 System V AMD64 调用约定速记CTF 里常见的 Linux ELF 文件绝大多数遵循 System V AMD64 调用约定。速记版是前六个整数或指针参数按顺序放在rdi、rsi、rdx、rcx、r8、r9第七个及以后的参数从右往左压栈返回值放rax。浮点参数另有xmm0-xmm7初学阶段可以先放一放。这个约定有多实用你只要在反汇编里看到call strlenplt之前有人把rdi设置成一个地址就能确定 strlen 的参数就是这个地址。再看到call scanf之前rdi是格式串、rsi是缓冲区地址你就能直接还原出scanf(%31s, buf)这样的调用。4.2 调用者保存与被调用者保存寄存器到底谁来“善后”调用约定里还有个看似绕但其实很重要的分配有些寄存器是调用者保存caller-saved有些是被调用者保存callee-saved。意思是调用者如果希望某个寄存器在函数调用前后保持原值要么自己保存调用者负责要么期望被调函数不修改那些被调用者保存的寄存器。System V 约定里rax、rcx、rdx、rsi、rdi、r8-r11是调用者保存rbx、rbp、r12-r15是被调用者保存。实际观察时被调用者保存的寄存器经常出现在函数 prologuepush rbx中间把rbx拿来存一些要跨调用存活的局部量函数结尾再pop rbx。这个现象也给你一个判断依据如果一个函数中间调用了外部函数还指望某个寄存器值跨调用不丢那它大概率用的是rbx或r12-r15这类被调用者保存寄存器。4.3 用调用约定快速反推函数原型一个典型片段假设你看到了这段反汇编mov rdi, [rbp-0x8] call strlenplt cmp rax, 8 je 0x401168 mov eax, 0 jmp 0x40117d这种片段在签到题里出现频率极高。先把寄存器映射一下rdi里放了某个局部变量值它被当作 strlen 的参数所以局部变量至少是个字符串指针或 char 数组地址。strlen 的返回值在rax紧跟着cmp rax, 8就是“这个字符串长度是否等于 8”。后面的mov eax, 0是返回值 0jmp跳向函数尾部。你几乎可以不加思索地写成if (strlen(input) ! 8) return 0;这就是调用约定给逆向带来的直接效果。你不光能看到指令还能看到一条条 C 语句的影子。5. 实操C 源码到汇编再到逆向还原完整走一遍纸上谈兵够了我们现在取一个模拟题目二进制把从“源码编译”到“反汇编还原”的完整链路走一遍。这个二进制是我为演示生成的没有符号混淆适合作为入门复现样本。5.1 准备一个模拟样例程序先用 C 写一段逻辑简单但覆盖面足够的代码保存为 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; }编译进一个叫 demo 的二进制gcc -O0 -no-pie -fno-stack-protector -o demo demo.c-O0关闭优化让汇编和 C 代码的对应关系最直观-no-pie让地址固定方便我们按绝对地址找目标-fno-stack-protector是为了避免金丝雀干扰阅读。入门阶段这样开编译选项最友好。5.2 用 objdump 看一眼反汇编拿到二进制后先做两件事strings 和 objdump。strings 看字符串资源objdump 看函数清单strings demo objdump -d -M intel demo | lessstrings输出里能看到Input:、Correct、Wrong和secret01这已经告诉你程序核心是个字符串比对。objdump出来可以搜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那个地址基本就是 data 段里字符串常量的位置。我们在 GDB 里验证它是不是 secret01。5.3 现场解读 check 的每一个基本块这个函数开头三句push rbp; mov rbp,rsp; sub rsp,0x10是标准开场不用多说。mov [rbp-0x8], rdi把参数暂时存到栈上mov rdi,rax又把参数取出来放回rdi然后call strlenplt。编译器在 -O0 下经常会出现这种“参数放栈又取出”的冗余搬运别当成复杂逻辑。strlen 结果出来后cmp rax,0x8判断长度是否等于 8。如果相等就je跳到0x401168直接进入 strcmp 部分不相等就mov eax,0返回 0。再看 strcmp 调用结束后的test eax,eax; jne如果 strcmp 的结果不是 0说明两个字符串不相等跳到mov eax,0相等则走到mov eax,1。两个分支最后都汇合到leave; ret。这个流程对应源码的 check 函数几乎是一一映射。5.4 逆向还原成可读逻辑按照我们前面的方法先识别函数边界、再识别参数寄存器、最后看返回路径可以还原出如下的伪代码int check(char *s) { if (strlen(s) ! 8) return 0; if (strcmp(s, secret01) 0) return 1; return 0; }你再看 main 部分会看到类似scanf(%31s, buf)后mov rdi, rax; call check后面接test eax,eax; jz之类。这些合起来就是if (check(buf)) puts(Correct); else puts(Wrong);到这里一个签到题的逆向还原就完成了。整个过程没有依赖反编译器的伪代码完全靠汇编和调用约定推出来。想练手的读者可以把我上面代码用不同优化等级重新编译再自己还原一遍效果比看十遍教程都管用。6. 新手最容易踩的坑与调试经验实录汇编这块的坑大多不是“看不懂”而是“看岔了”。下面这四条是我在带练习时见得最多的翻车现场。6.1 Intel 语法和 ATT 语法混着看操作数方向全反Linux 下gcc生成的汇编默认是 ATT 语法而多数破解工具默认给你看 Intel 语法。两者的操作数方向是反的Intel 是“目标在左源在右”mov eax, ebx表示把 ebx 复制到 eaxATT 是“源在左目标在右”mov %ebx, %eax表示同样的操作。如果你一会看 objdump 的 ATT 输出一会看 Ghidra 的 Intel 视图第一反应很容易把赋值方向搞反后面所有判断就全错了。我建议统一成 Intel 语法objdump加-M intelGDB 里执行set disassembly-flavor intel。先统一再分析能帮你少走很多弯路。6.2 栈向下增长、数组向上增长方向不要搞反栈帧的“向下”指的是地址变小。所以函数开头sub rsp, 0x20是给局部变量腾空间而不是在内存里向上扩展。数组访问则相反buf[0]在低地址buf[15]在高地址。这个方向问题在处理溢出题时尤其要命。假设你在栈上有一块char buf[16]它位于某个更低的地址而返回地址在高地址方向输入数据越界写越多就会往返回地址方向覆盖。如果你把方向记反了算偏移时就会差出一整个缓冲区长度定位不到真正的覆盖点。6.3 题目没有 main、没有符号时别在入口处迷路CTF 的题经常被 strip 过main符号会消失。这时候别慌程序的真正入口是_start不是main。可以用readelf -h demo看入口点但更实用的做法是在调试器里断在只被调用一次、并且后面跟着大量栈初始化的函数或者直接看动态符号表里调用了哪些外部函数反推它所在函数的功能。还有一类题型是静态库打包main 藏在某个你完全没听过名字的函数里。这种判断方法一致找puts、scanf、fgets等输入输出函数的调用位置最像用户入口的函数基本就在那一带。6.4 调试时几个保命习惯建议现在就练每分析一个函数先在纸上写清“参数从哪里来、返回值是什么、条件跳转分成几支”。不用很工整但一定要写。GDB 里多用info registers、x/gx $rsp、x/s 某地址来对照寄存器与内存的实况不要闭着眼只看反汇编。遇到call外部函数之前先看它往寄存器里塞了什么那就是你的参数表。还原伪代码时先把所有jmp汇合点标出来再回头看条件跳转这样不会在多个分支间转晕。这些习惯坚持两三个题目效果会非常明显。写到这儿我想起自己刚开始练汇编时的土办法找一道基础签到题把check或者加密函数那一段汇编打印出来用笔一行行做注释标清楚每一步改了哪个寄存器、跳向了哪里。这个办法看着笨但它是把寄存器分工、栈布局、调用约定全部焊进脑子里的最快路径。如果你正在某道题上卡住别急着开反编译器的伪代码窗口先手动抄一遍关键函数的汇编你的思路大概率会突然被打开。下一篇我们接着往后聊把加密识别和常见算法逆向带进来到时候你就会发现眼前这些密密麻麻的指令已经变得相当顺眼了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询