ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言逆向基础:函数参数传递与返回值陷阱全解析

C语言逆向基础:函数参数传递与返回值陷阱全解析 干逆向这行的朋友应该都有过一个体会C语言写了好几年指针、结构体、内存管理都门儿清但一拿到反汇编代码就发懵。尤其是函数调用这块一段看着挺规整的C代码编译出来汇编却绕来绕去参数一会儿在寄存器里一会儿又跑到栈上返回值更是玄乎一个eax走到哪跟到哪。这其实就是典型的“正向写得熟逆向看不透”。今天这节C语言逆向学习基础课咱们就把函数参数传递与返回值陷阱单独拎出来掰碎讲清楚。这堂课解决的就是一个核心问题当你面对一段没有符号信息的汇编代码时怎么判断它是个函数、参数有几个、参数是什么类型、返回值又是什么。搞清楚这些你才算真正开始“逆向”而不是“看汇编”。适合刚接触逆向、被反汇编代码劝退的读者也适合想搞明白编译器后端到底干了啥的C语言开发者。1. 函数调用的全景图参数和返回值是如何在底层流动的1.1 一次函数调用CPU到底在做什么先拨开迷雾把函数调用这件事拉回到CPU视角。很多C语言学习者对函数理解的终点就是“调用时把参数传进去函数算完把结果传回来”但底层的问题是函数之间怎么传递数据寄存器还是内存要是都用栈栈又怎么分配和回收其实函数调用的底层动作并不复杂核心就是三步。第一步调用方caller按照某种约定把参数放到指定位置第二步执行call指令这个指令会先把下一条指令的地址也就是返回地址压栈然后跳转到被调用函数callee的入口第三步被调用函数执行完后通过ret指令把栈顶的返回地址弹出来CPU接着回到调用方继续跑。这三步里的关键在于“某种约定”它决定了逆向分析时你看什么、怎么猜。如果不了解约定你看到mov edi, 5这种指令根本不知道它是在传参还是随便写了个数。在x86-64平台这个约定叫System V AMD64 ABIWindows上有另一套但核心思想一致能用寄存器就用寄存器寄存器不够了再上栈。用生活化的类比来说函数调用就像你去柜台办业务。参数就是你要提交的材料有些小材料你直接拿在手上寄存器材料太多了手上拿不下就放包里栈。柜台人员callee接到材料后先放下自己的私人物品保存栈帧办完业务再把自己的东西收拾好最后告诉你结果返回值。你知道吗这个“结果”一般也放在一个固定位置就像业务回执单永远从同一个窗口递出来。1.2 调用约定C语言编译器与你心照不宣的契约不同架构、不同操作系统下的调用约定直接影响逆向分析的难度。我最初学逆向时最头疼的就是分不清哪些寄存器是参数的临时搬运工哪些是函数自己的“私人财产”。后来我把常见约定整理成一张表基本就再没犯过迷糊。以最常用的x86-64 Linux/macOS环境为例参数顺序整数/指针参数浮点参数备注第1个参数RDIXMM0额外参数使用栈第2个参数RSIXMM1方向从左到右第3个参数RDXXMM2第4个参数RCXXMM3第5个参数R8XMM4第6个参数R9XMM5第7个及以后栈上栈上入栈顺序从右到左返回值约定则相对简单返回类型存放位置整数/指针≤64位RAX浮点数XMM0128位整数或小结构体RAX:RDX 组合大结构体调用者分配缓冲区地址作为隐藏参数传入函数将结果写入该缓冲区这里有个关键点也是很多新手踩坑的地方32位x86平台用的是cdecl调用约定参数全部压栈而且是从右往左压。所以你在分析老程序时看到一堆push指令接一个call别慌那些push就是在传参。记住一个实用结论在x86-64逆向里看到被调函数开头用rdi、rsi、edx这些寄存器做初始化基本可以断定它们就是函数参数。这就相当于白纸黑字告诉你这个函数有几个参数、每个参数从哪来。2. 参数传递的底层拆解寄存器与栈的接力2.1 整型、指针参数的默认路线我最喜欢在逆向课上让学生干一件事写一个简单的加法函数编译成汇编看看。比如下面这个C语言程序正常运行会输出3#include stdio.h int add(int a, int b) { return a b; } int main(void) { int result add(1, 2); printf(%d\n, result); return 0; }用gcc编译并输出汇编gcc -S -O0 -o add.s add.c-O0表示不优化能最大化保留C语言原本的逻辑结构最适合初学者对照学习。生成的汇编关键片段去掉一堆符号修饰后是这样add: push rbp mov rbp, rsp mov DWORD PTR [rbp-4], edi mov DWORD PTR [rbp-8], esi mov eax, DWORD PTR [rbp-4] add eax, DWORD PTR [rbp-8] pop rbp ret main: push rbp mov rbp, rsp sub rsp, 16 mov edi, 1 mov esi, 2 call add mov DWORD PTR [rbp-4], eax mov eax, DWORD PTR [rbp-4] mov edi, eax call printf mov eax, 0 leave ret看到这段汇编你应该能总结出几条真正的规律。第一main函数调用add之前把参数1放进了edi把参数2放进了esi这就是System V约定里第一、第二个整型参数的位置。这里有个细节值得注意C代码里是int汇编用的是edi/esi而非rdi/rsi因为32位int只占32位操作低32位寄存器足够还能顺带把高32位清零。第二add函数内部做的第一件事是把参数从寄存器存到栈上。mov DWORD PTR [rbp-4], edi就是把edi里的值存入局部变量区。为什么从不优化代码里能看到这种操作因为编译器为了调试方便会把寄存器里的参数“落地”到栈上生成一个可寻址的局部变量副本。这样你在gdb里就能print出参数的值。第三add计算完a b后结果放在eax中。这就是返回值的默认存放位置。main里mov DWORD PTR [rbp-4], eax把返回值又从eax取走存到局部变量result中。整个数据流动路径清晰得像是被标记了路牌。2.2 浮点参数、结构体参数的特殊通道整数和指针参数走RDI、RSI、RDX这几个寄存器那浮点数呢System V约定给了浮点参数独立的通道XMM0到XMM7。这意味着一个函数如果参数是double类型你会看到指令操作的是xmm0而不是rdi。我踩过一次实实在在的坑。早期逆向一个数值计算程序时看到一个函数传入一个整数和一个浮点数我本能地认为整数参数在rdi浮点数在xmm1结果完全对不上。后来才意识到这个函数签名大概是double func(int x, double y)整数用了edi浮点用的其实是xmm0因为第一个参数的寄存器是按参数类型序列分配的浮点参数和整型参数各自有一组编号。结构体参数则是另一个容易翻车的地方。如果结构体特别大超过16字节编译器不会一股脑压到寄存器而是把整个结构体复制到栈上调用方和被调用方都通过栈偏移来访问。要是结构体足够小例如两个int拼成的8字节结构体它会被拆开放进寄存器一个结构体字段对应一个寄存器槽位。这一点对逆向特别有提示作用当你反汇编看到一个函数开头频繁访问rbp附近的栈地址而且偏移量很大它很可能是在接收一个打包传进来的结构体参数。别急着把它当成局部变量先看调用方传参的代码。2.3 参数传递中最容易被忽略的坑理论说多了容易飘落地时总有几个坑是所有人都得挨一遍的。第一个坑是数组参数退化成指针。你写void func(int arr[10])看着像是传了10个int进去但实际上编译后形参就是一个int *汇编里只传了一个地址到rdi。所以你反汇编时根本看不到那10个数被复制进栈只看到一个指针。这也是为什么sizeof(arr)在函数内部永远是864位指针大小而不是40。逆向时你要是按10个int去栈上找数据方向从一开始就错了。第二个坑是可变参数函数如printf的寄存器使用。System V ABI里可变参数函数需要额外设置al寄存器来表示使用了多少个向量寄存器。反汇编printf的调用点经常能看到mov eax, 0这种指令就是在告诉被调函数“我没用XMM寄存器”。如果你在分析时忽略al的设置遇到浮点格式串时就会莫名奇妙地输出错误值。第三个坑是参数求值顺序。C语言标准从未规定函数参数的求值顺序是左到右还是右到左编译器可以自由决定。反汇编时如果你妄图根据push参数的顺序反推代码书写顺序很容易得出完全相反的结论。在x86-64下参数进寄存器一般是从左到右依次放但求值的顺序可能完全不同比如最右的参数先被计算并缓存然后再填靠左的寄存器。这就要求你在逆向时必须把“谁先执行”当成未定义事件不要被源代码习惯带偏。3. 返回值陷阱EAX是个“共享车位”3.1 返回值的默认存放位置及扩展前面提到过整数返回值放在eax/rax中。听上去简单但逆向里的麻烦在于rax不只是用来看函数结果的它还是大量算术指令的默认操作数是系统调用的号码寄存器是函数内部临时运算的常客。所以你不能看到一个指令操作rax就说它跟返回值有关要结合上下文判断这到底是不是函数的出口位置。从逆向角度判断函数返回值最可靠的方法是看ret前后的寄存器状态。一个函数在ret之前如果往eax里放了一个明确计算出来的值且之后的调用方立刻读取eax那基本可以确定这就是返回值。相反如果一个函数ret前没碰eax那它大概率是个void函数返回值无意义。这里还有一个容易被带偏的细节64位程序的返回值如果类型是long或指针会用完整的rax返回但如果类型是int编译器通常只保证eax有效高32位可能是任何值。有些优化情况下编译器会在写eax时自动把高32位清零因为x86-64写32位寄存器有这个硬件特性但不要依赖这个行为。3.2 大结构体返回的隐藏指针比单个返回值更特殊的是“返回一个结构体”。我在分析一个图形库时遇到过这样一个函数反汇编看到调用方先sub rsp, 80然后mov rdi, rsp接着又设置了rsi、rdx我当时猜测这个函数有三个参数但怎么都对不上号。后来查了ABI文档才算彻底明白这里第一个参数rdi根本不是业务参数而是编译器偷偷分配的返回缓冲区地址。具体机制是这样的当一个函数要返回一个大于16字节的结构体时调用方会在自己的栈帧上分配一块足够大的空间然后把这块空间的地址作为第一个参数隐藏参数传给被调用函数。被调函数把要返回的结构体数据写进这块缓冲区然后在rax中返回这个缓冲区的地址。从C语言源码看来是“返回结构体”但从汇编看来是“调用方提供地址被调函数帮你把数据填进去”。这个陷阱我称之为“隐藏的第一参数”。一旦你没意识到rdi指向返回缓冲区整个函数参数分析都会崩盘。比如一个业务上需要两个参数的函数在反汇编里可能看到三个寄存器被赋值你以为源码里肯定有三个参数实际上中间藏了一个编译器自己加的返回地址。逆向工程中识别这种隐藏参数是判断函数原型准确性的分水岭。3.3 三种典型的返回值陷阱现场先看第一种返回值被无视。调用方调用了函数但完全不用返回值比如你没把scanf的返回值当回事。在逆向里这种调用的特征是call之后没有任何读取rax的指令函数返回值白白丢在寄存器里。反过来说如果你看到某个函数调完后紧接着对rax做了条件判断那么那个返回值通常承载着成功/失败信息可能是错误码或指针有效性标志。再看第二种返回指针指向栈空间。C语言里最常见的“悬空指针”bug函数返回一个指向局部变量的指针。这种代码在开优化时经常整出匪夷所思的行为因为函数返回后栈帧销毁那块内存随时可能被后续调用覆盖。逆向中遇到这种情况更难缠你看到函数返回了一个地址顺着地址查数据发现数据在函数返回后一会儿对一会儿错因为栈上同一位置被另一个函数占用了。这并不一定是编译器bug可能源码本身就是个未定义行为大户。第三种是返回值的高8位残留。有些平台ABI规定小于int的类型如char、short作为返回值时只保证低几位有效。你要是执着地读取整个rax就会把一堆垃圾高位当成数据。正确的逆向做法是只关心与类型宽度对应的位或者看调用方是否对eax做了符号扩展或零扩展操作通过这个扩展指令也能反向推断出返回值的类型。4. 一个实例从反汇编反向推导函数原型4.1 准备环境与反汇编光说不练假把式。我准备了一个小例子目标是让你体验一下“拿到汇编反推出C语言原型”的全过程。我们待会儿要分析的是一个编译后的二进制片段假设你没有任何源文件和符号信息。先用一个简单的C程序生成汇编int calculate(int a, int b, int c) { int sum a b; return sum * c; }编译命令gcc -S -O1 -o calc.s calc.c这次用-O1让编译器稍微做点优化更贴近真实逆向遇到的代码风格。生成的汇编核心部分如下calculate: lea eax, [rdirsi] imul eax, edx ret你没看错优化后的函数就这三条指令。怎么从这几条指令反推出原来的C代码这是真正的逆向思维训练。4.2 逐条解读汇编第一条指令lea eax, [rdirsi]它的意思是把rdi rsi计算出来存入eax但不访问内存。在x86-64上lea经常被编译器用来做算术运算。因为rdi是第一个整型参数rsi是第二个整型参数edi/esi是它们低32位的别名所以这条指令实际在算int int。第二条指令imul eax, edx把eax和edx相乘结果存回eax。edx是第三个参数的32位寄存器所以这一步是在做乘法。第三条指令ret返回时eax就是运算结果。把这三条串成C语言逻辑函数接收三个整型参数先做加法再做乘法。所以原函数原型大概是int calculate(int a, int b, int c) { return (a b) * c; }看到没有从汇编反推C代码的关键不是逐行翻译而是理解寄存器的角色rdi/rsi/rdx分别是第一、第二、第三个参数计算过程体现在寄存器间的数据流动最后eax作为结果输出。整个推断过程就像拼图每一块寄存器都有它固定的身份。4.3 实战技巧没有源码时怎么判断函数原型实战中你遇到的情况肯定比这复杂函数可能几百行参数可能五六个。这时候我建议你按下面四步走。第一步找函数入口。在现代编译产物里函数入口通常有endbr64针对CET保护或push rbp; mov rbp, rsp这样的序列。找到入口后立刻注意它操作了哪些寄存器。如果函数开头就把rbx、r12这些被调用者保存的寄存器压栈说明函数内部要使用它们这往往意味着程序逻辑比较复杂。第二步记录参数寄存器。从上到下扫描函数的汇编指令凡是第一次以rdi、rsi、rdx、rcx、r8、r9的32位或64位形式出现在mov、lea、add、sub、imul等指令中的极大概率就是参数。如果这些寄存器只出现为指针解引用的基地址那参数类型多半是指针。第三步观察栈帧布局。sub rsp, 0x20之类的指令决定了局部变量区大小。如果一个函数开头大量分配栈空间很可能是参数里包含了结构体传值或者是局部变量太多需要临时存储。栈上偏移较大的位置往往对应被保存的寄存器或传入的栈参数。第四步锁定返回路径。找到所有ret指令看执行到ret之前在做什么操作。常见的返回值准备指令包括mov eax, 常量返回固定值、mov rax, [rbp-8]返回局部变量、xor eax, eax返回0也可能是错误码或假值。注意如果函数既有错误分支又有成功分支每个分支在跳转前都会设置不同的eax值这就构成了一张错误码表逆向时可以用它反推函数的业务逻辑。5. 常见问题速查表与排查技巧实录5.1 常见问题速查表这一节我把实际操作中反复遇到的高频问题整理成了一张速查表方便你以后逆向时直接对照。现象可能原因破解思路函数参数在栈上不在寄存器参数超过6个或二进制是32位程序重点看call之前的push指令顺序从右往左看函数开头的第一个操作是mov rdi, rsp返回值是大结构体RDI是隐藏的返回缓冲区地址不要把这个指针当成业务参数参数类型是浮点但编译产物中看不到XMM寄存器代码可能被优化掉了或参数实际是整型被当作浮点读取检查调用方是否向浮点寄存器写入值ret前连续多条指令操作rax返回值被多个分支分别设置从函数结构找分支判断点调用函数后接着test rax, rax和jz返回值是标志值用于错误处理或空指针判断提取判断分支对应枚举错误处理逻辑函数用eax返回了结构体首地址是返回大结构体的隐藏指针模式或返回了堆内存指针查调用方是否对该指针继续解引用5.2 排查技巧假设-验证法实际操作中逆向一个函数原型很少一次就能对上我自己经常用的是“假设-验证”的迭代思路。假设阶段根据你看到的前几条指令先猜一个函数签名。比如看到rdi和rsi都被当作地址使用就假设函数是两个指针参数的函数。验证阶段找调用方的代码看看调用这个函数之前它往rdi和rsi里放的是什么。如果放的是栈变量的地址而那个栈变量是个字符串缓冲区那你的假设成立一半。如果放的是整数值那你之前“当作地址”的假设就要推翻重来。这种思路还有一个特别实用的场景判断返回值的真正用途。看到一个函数返回后rax立刻被存进局部变量后面过了一大段才用你可以暂时忽略它的具体值先观察它被哪些指令消费。如果最终被当成指针解引用了它大概率是malloc或字符串查找类的函数如果被当成容量参数和另一个数做比较那它很可能返回的是长度或计数。这种方式被我称为“出口反推法”从事后使用场景倒推出入口类型比死抠指令含义高效得多。5.3 几条实践心得讲到最后分享几条我自己总结的体会。第一条别一开始就上动态调试。先把静态汇编读明白再去用gdb验证寄存器值效率会高很多。很多人一上来就断点、单步结果被一堆中间状态搅浑了思路。静态分析能帮你建立“预期的执行路径”动态调试只负责验证和纠偏。第二条优化等级不同汇编风格完全不同。-O0的代码啰嗦得像老太太的裹脚布-O2的代码精简到你可能认不出原逻辑。我建议在学习阶段同一个C函数分别用-O0、-O1、-O2三个等级编译对照着看你会很快摸清编译器在不同优化策略下的思考方式对参数传递和返回值的理解也会更立体。第三条善用工具但别迷信工具。反汇编器能帮你把AA55这种机器码变成push rbp但你拿来直接分析的永远是符号化之后的汇编而不是机器码。像objdump、rizin、Ghidra都可以用但它们只会机械地解码不会替你思考参数类型和业务语义。真正决定分析质量的还是你对ABI和编译原理的理解。第四条看别人的反汇编输出时先确认平台和调用约定。我之前在Windows x64的可执行文件里套用Linux的参数寄存器规则结果一路分析全错位。两者的参数寄存器是完全不同的Windows x64用rcx、rdx、r8、r9第一参数不在rdi里。这种低级错误一次就够你长记性。函数参数和返回值在底层其实没那么玄乎翻来覆去就是寄存器、栈和调用约定这三样东西的组合。把这堂课里的规则记牢再上手几个实际二进制练一练你会发现自己读反汇编代码的速度能快上一截。下次遇到一个陌生函数至少能一眼看出它吃几个参数、大概返回个什么东西。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进