汇编基础(x86/x64):从寄存器到调用约定
各位同学,欢迎来到第二章。这一章我们聊聊汇编基础。我知道很多人一听到“汇编”就头疼,觉得那是上古时代的东西。但做二进制安全,尤其是Pwn,汇编就是你的“母语”。你想想看,漏洞利用的每一步,最终都要落实到CPU执行的指令上。看不懂汇编,你就永远是个“脚本小子”。
我个人习惯把汇编基础拆成四块:寄存器体系、栈帧结构、常见指令、调用约定。咱们一块一块啃。
寄存器体系:CPU的“手边工作台”
寄存器是CPU内部最快的存储单元,没有之一。我经常跟团队新人说:“寄存器就是你的手边工作台,内存是远处的货架,磁盘是仓库。” 你干活的时候,肯定希望常用工具就在手边,对吧?
x86和x64的寄存器体系有继承关系,也有重大区别。咱们先看x86的通用寄存器:
| 寄存器 | 名称 | 我的常用理解 |
|---|---|---|
| EAX | 累加器 | 函数返回值、算术运算主力 |
| EBX | 基址寄存器 | 常用来存内存基址,我习惯用它做数据段指针 |
| ECX | 计数器 | 循环、移位操作,硬件层面就优化过 |
| EDX | 数据寄存器 | I/O操作、乘除法辅助 |
| ESI | 源变址 | 字符串操作源地址 |
| EDI | 目的变址 | 字符串操作目的地址 |
| ESP | 栈指针 | 指向当前栈顶,千万别乱改 |
| EBP | 基址指针 | 指向当前栈帧底部,调试利器 |
重点:x64架构将寄存器扩展为64位,命名变为RAX、RBX、RCX、RDX、RSI、RDI、RSP、RBP。同时新增了R8-R15共8个通用寄存器。这意味着x64的函数传参可以更多使用寄存器,减少栈操作。
我记得有一次逆向一个x64的恶意软件,它用R8-R15存了一堆解密密钥,我当时就在想:“这要是x86,早就在栈上被扒干净了。” 所以,x64的寄存器富裕,对安全研究来说既是好事也是挑战——攻击面更隐蔽了。
栈帧结构:函数的“临时工位”
栈帧,说白了就是函数被调用时,在栈上划出的一块“临时工位”。每个函数都有自己的工位,里面放着局部变量、保存的寄存器、返回地址等等。
一个典型的x86栈帧长这样(从高地址到低地址):
高地址
+------------------+
| 调用者局部变量 |
+------------------+
| 调用者参数n | <-- 调用者压入
+------------------+
| ... |
+------------------+
| 调用者参数1 |
+------------------+
| 返回地址 | <-- CALL指令压入
+------------------+
| 保存的EBP | <-- 当前EBP指向这里
+------------------+
| 局部变量1 |
+------------------+
| 局部变量2 |
+------------------+
| ... |
+------------------+
| 临时空间 | <-- 当前ESP指向这里
+------------------+
低地址
我的经验:调试栈溢出漏洞时,我第一件事就是看返回地址在栈上的偏移。怎么算?EBP+4就是返回地址的位置(x86)。如果你能控制EBP,你就能控制返回地址。我曾经用这个技巧绕过一个ASLR保护——当然,那是后话了。
x64的栈帧结构类似,但有一个关键区别:参数传递优先使用寄存器。在Windows x64中,前4个参数分别放在RCX、RDX、R8、R9中;在Linux x64中,前6个参数放在RDI、RSI、RDX、RCX、R8、R9中。只有参数太多时,才会压栈。
避坑指南:我曾经在分析一个Linux x64的漏洞时,一直盯着栈上找参数,找了半天没找到。后来才反应过来——参数在寄存器里!所以,分析x64程序时,别忘了看寄存器上下文。
常见指令:你的“汇编工具箱”
咱们不需要背几百条指令,常用的就那么十几条。我把它们分成几类:
数据传送类
- MOV:把数据从一个地方搬到另一个地方。注意,不能直接从内存搬到内存,得经过寄存器中转。
- PUSH:把操作数压入栈顶,同时ESP减4(x86)或8(x64)。
- POP:从栈顶弹出数据到操作数,同时ESP加4或8。
控制流类
- CALL:先把返回地址(下一条指令的地址)压栈,然后跳转到目标函数。
- RET:从栈顶弹出返回地址,然后跳转过去。嗯,这里要注意——如果栈被破坏了,RET就会跳到错误的地方,这就是经典的栈溢出利用点。
- JMP/JCC:无条件/条件跳转。JZ、JNZ、JE、JNE这些在逆向分析中天天见。
系统调用类
- INT 0x80(x86 Linux):触发系统调用。EAX放系统调用号,EBX、ECX、EDX等放参数。
- SYSCALL(x64 Linux):更快的系统调用方式。RAX放调用号,RDI、RSI、RDX、R10、R8、R9放参数。
实战要点:在Pwn中,我们经常需要构造ROP链来执行系统调用。比如在x64下执行execve("/bin/sh", NULL, NULL),你需要设置RAX=59(系统调用号),RDI指向"/bin/sh"字符串,RSI=0,RDX=0,然后执行SYSCALL。每一步都需要一个gadget来完成。
调用约定:函数间的“握手协议”
调用约定就是函数调用者和被调用者之间约定好的“握手协议”。谁负责清理栈?参数怎么传?返回值放哪?这些都得说清楚。
| 约定名称 | 平台 | 参数传递 | 栈清理 | 备注 |
|---|---|---|---|---|
| cdecl | x86 Linux/Windows | 栈(从右向左压入) | 调用者 | C语言默认,可变参数支持好 |
| stdcall | x86 Windows | 栈(从右向左压入) | 被调用者 | Win32 API常用,代码更紧凑 |
| fastcall | x86 Windows | ECX、EDX传前两个,其余栈 | 被调用者 | 速度更快,但寄存器冲突需注意 |
| System V AMD64 | x64 Linux | RDI、RSI、RDX、RCX、R8、R9传前6个,其余栈 | 调用者 | Linux x64默认,我工作中最常用的 |
| Microsoft x64 | x64 Windows | RCX、RDX、R8、R9传前4个,其余栈 | 调用者 | 有“影子空间”要求,需预留32字节 |
我的建议:做Pwn题时,第一件事就是确定目标程序的调用约定。怎么确定?看函数开头有没有保存EBP/RBP,看参数是不是从栈上取的。我一般用GDB的info frame命令,一目了然。
为什么会存在这么多调用约定?说白了,就是不同场景下的性能权衡。cdecl简单但代码体积大,stdcall省空间但不支持可变参数,fastcall快但寄存器不够用。x64统一了大部分场景,但Windows和Linux还是各玩各的。
本章知识结构图
下面这张SVG图总结了本章的核心知识体系,我建议你把它存下来,复习时对照着看:
好了,这一章的内容就到这里。寄存器、栈帧、指令、调用约定,这四个东西你吃透了,后面学ROP、学格式化字符串、学堆利用,都会轻松很多。记住,汇编不是背出来的,是“用”出来的。多调试、多逆向、多写shellcode,慢慢就熟了。