1. 二进制安全入门必修课:理解函数调用约定
第一次接触PWN题时,很多人会在函数传参这个基础环节栽跟头。记得我当初用32位EXP去打64位程序时,那种参数死活传不对的绝望感至今难忘。本文将用实战案例带你彻底掌握x86和x64架构下函数调用的底层差异。
函数调用约定(Calling Convention)决定了参数如何传递、栈帧如何构建。在二进制漏洞利用中,这直接关系到ROP链构造、栈溢出布局等核心技巧。不同架构的主要差异体现在:
- 32位程序完全依赖栈传递参数
- 64位程序优先使用寄存器传参
- 调用前后栈平衡规则不同
2. 32位程序传参机制详解
2.1 经典栈传参模式
在x86架构下,所有参数都通过栈来传递。假设我们反编译看到以下函数调用:
void vulnerable(char *buf) { char local[40]; strcpy(local, buf); // 典型的栈溢出点 }对应的汇编代码会是这样:
push ebp ; 保存旧栈帧 mov ebp, esp ; 建立新栈帧 sub esp, 0x28 ; 分配局部变量空间 push [ebp+8] ; 参数buf压栈 lea eax, [ebp-0x28] push eax ; local变量地址压栈 call strcpy ; 调用函数 add esp, 8 ; 调用者负责平衡栈关键特征:
- 参数从右向左依次压栈
- 调用者(caller)负责清理参数栈空间
- 返回值存放在EAX寄存器
2.2 栈帧结构可视化
典型的32位函数调用栈布局如下(假设调用func(1,2,3)):
| 栈地址 | 内容 | 说明 |
|---|---|---|
| esp+0 | 返回地址 | call指令下一条指令地址 |
| esp+4 | 参数1 (3) | 最右边的参数先入栈 |
| esp+8 | 参数2 (2) | |
| esp+12 | 参数3 (1) | 最左边的参数最后入栈 |
| ebp-0x10 | 局部变量 | 函数内部使用的变量 |
重要提示:在构造栈溢出payload时,必须严格按照这个顺序覆盖返回地址和参数。比如要调用system("/bin/sh"),就需要先覆盖返回地址,然后是system的返回地址(可填垃圾数据),最后才是字符串地址。
3. 64位程序传参规则演变
3.1 寄存器优先的传参方式
x64架构对调用约定进行了重大优化,前六个参数通过寄存器传递:
| 参数顺序 | 整数/指针类型 | 浮点类型 |
|---|---|---|
| 第1参数 | RDI | XMM0 |
| 第2参数 | RSI | XMM1 |
| 第3参数 | RDX | XMM2 |
| 第4参数 | RCX | XMM3 |
| 第5参数 | R8 | XMM4 |
| 第6参数 | R9 | XMM5 |
超过6个的参数才会使用栈传递。我们看个实际例子:
void example(int a, int b, int c, int d, int e, int f, int g) { // 函数体 }对应的调用汇编:
mov edi, 1 ; a=1 mov esi, 2 ; b=2 mov edx, 3 ; c=3 mov ecx, 4 ; d=4 mov r8d, 5 ; e=5 mov r9d, 6 ; f=6 push 7 ; g=7 通过栈传递 call example add rsp, 8 ; 平衡栈3.2 栈空间的特殊要求
64位调用约定还有个容易被忽视的特点:调用时栈指针rsp必须16字节对齐。这意味着即使只传递一个参数,也需要预留"影子空间":
sub rsp, 0x20 ; 预留32字节空间(保持16字节对齐) mov rdi, [rbp-0x10] ; 参数设置 call function add rsp, 0x20 ; 恢复栈4. 两种架构下的ROP构造差异
4.1 32位ROP链构建要点
以调用system("/bin/sh")为例,典型payload结构:
[垃圾数据填充缓冲区的40字节] [system函数地址] [返回地址(可填exit地址)] ["/bin/sh"字符串地址]对应的Python代码示例:
from pwn import * context(arch='i386', os='linux') offset = 40 payload = flat( b'A'*offset, 0x08048320, # system@plt 0xdeadbeef, # 返回地址 0x0804a024 # "/bin/sh"字符串地址 )4.2 64位ROP链的特殊处理
64位下需要通过寄存器传参,因此需要先设置rdi寄存器。典型利用方式:
[填充缓冲区的40字节] [pop rdi; ret gadget地址] ["/bin/sh"字符串地址] [system函数地址]对应的Python实现:
from pwn import * context(arch='amd64', os='linux') offset = 40 payload = flat( b'A'*offset, 0x4005a3, # pop rdi; ret 0x4006d8, # "/bin/sh" 0x4004e0 # system@plt )5. 实战中的常见坑点
5.1 调用约定混淆错误
最常见的问题就是搞混架构类型。症状包括:
- 在64位程序中使用32位payload构造方式
- 忘记设置rdi寄存器直接调用函数
- 没有预留足够的栈空间导致段错误
调试技巧:
# 检查程序架构 file ./vuln_binary # GDB中观察寄存器变化 break *0x400567 commands info registers x/10x $rsp continue end5.2 栈对齐问题处理
在64位环境下,如果遇到神秘的段错误,很可能是栈对齐问题。解决方法:
- 在ROP链中插入调整rsp的gadget
- 保持payload总长度是8的倍数
- 必要时添加ret指令来微调
示例调整gadget:
... 0x400123, # 原始返回地址 0x40056b, # ret指令(用于对齐) 0x4005a3, # pop rdi; ret ...6. 不同编译器的特殊处理
6.1 Windows平台的差异
虽然本文主要讨论Linux环境,但值得注意Windows x64调用约定的不同:
- 前四个参数用RCX、RDX、R8、R9传递
- 调用者必须预留32字节"影子空间"
- 浮点参数使用XMM0-XMM3
6.2 编译器优化影响
高优化级别可能导致:
- 参数传递方式变化(部分参数可能直接优化掉)
- 栈帧结构改变(如省略帧指针)
- 内联函数导致调用约定不可见
应对策略:
- 使用-O0编译测试程序进行调试
- 结合反汇编和源码分析
- 关注实际寄存器/栈的变化而非理论约定
7. 自动化工具辅助分析
7.1 pwntools的架构感知
pwntools可以自动处理部分架构差异:
context.update(arch='amd64', os='linux') # 自动适配当前架构的调用约定 payload = flat( 'A'*offset, elf.plt['system'], next(elf.search(b'/bin/sh\x00')) )7.2 ROPgadget的高级用法
查找特定架构的gadget:
# 32位下查找pop链 ROPgadget --binary vuln32 --only 'pop|ret' # 64位下查找参数设置gadget ROPgadget --binary vuln64 --only 'pop rdi|ret'8. 从理论到实践的提升建议
- 使用不同架构编译相同代码,对比反汇编结果
gcc -m32 -o test32 test.c gcc -m64 -o test64 test.c objdump -d test32 > disasm32 objdump -d test64 > disasm64在GDB中单步跟踪函数调用过程,观察寄存器/栈变化
尝试用两种架构分别编写相同的漏洞利用代码
分析真实CTF赛题中的历史漏洞(如pwnable.kr的fd题是经典32位案例)