汇编基础(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图总结了本章的核心知识体系,我建议你把它存下来,复习时对照着看:

汇编基础(x86/x64)知识结构图 汇编基础(x86/x64)知识体系 寄存器体系 栈帧结构 常见指令 调用约定 汇编 x86: EAX, EBX, ECX, EDX... x64: RAX, RBX, RCX, RDX... 新增R8-R15通用寄存器 返回地址在EBP+4(x86) 局部变量在EBP下方 x64参数优先用寄存器 MOV/PUSH/POP 数据传送 CALL/RET/JMP 控制流 INT 0x80/SYSCALL 系统调用 cdecl/stdcall/fastcall System V AMD64 (Linux) Microsoft x64 (Windows) 💡 核心:理解寄存器与栈的交互,是Pwn漏洞利用的基石 x64下注意寄存器传参,x86下注意栈布局

好了,这一章的内容就到这里。寄存器、栈帧、指令、调用约定,这四个东西你吃透了,后面学ROP、学格式化字符串、学堆利用,都会轻松很多。记住,汇编不是背出来的,是“用”出来的。多调试、多逆向、多写shellcode,慢慢就熟了。