逆向工程——参数获取、返回值、指针、GOTO语句
[TOC]
参数获取
示例程序如下:
1 |
|
x86
MSVC
MSVC编译后的指令清单如下:
1 | _TEXT SEGMENT |
main()函数把 3 个数字推送入栈,然后调用了f(int, int, int)
。被调用方函数f()通过_a$=8
一类的汇编宏访问所需参数以及函数自定义的局部变量。只不过从被调用方函数的数据栈的角度来看,外部参考的偏移量是正值
,而局部变量的偏移量是负值
。可见,当需要访问栈帧(stack frame)以外的数据时,被调用方函数可把汇编宏(例如_a$)与EBP寄存器的值相加
,从而求得所需地址。
当变量 a 的值存入EAX
寄存器之后,f()函数通过各参数的地址依次进行乘法和加法运算,运算结果 一直存储于 EAX 寄存器。此后 EAX 的值就可以直接作为返回值传递给调用方函数。调用方函数 main()再 把 EAX 的值当作参数传递给 printf()函数。
GCC
GCC编译结果和MSVC基本相同。如果函数尾声使用了leave
指令,则不需要被调用房函数还原栈指针sp
。
x64
MSVC
x86-64系统能够使用寄存器传递参数
(一般为前4个或前6个),被调用方函数会从寄存器里获取参数,而不需要访问栈。
开启优化:
1 | $SG2997 DB '%d', 0aH, 00H |
我们可以看到,f()函数通过寄存器
获取了全部的所需参数。此处求址的加法运算是通过LEA
指令实现 的。很明显,编译器认为 LEA 指令的效率比 ADD 指令的效率高,所以它分配了 LEA 指令。在制备 f()函 数的第一个和第三个参数时,main()函数同样使用了 LEA 指令。编译器无疑认为 LEA 指令向寄存器赋值 的速度比常规的 MOV 指令速度快。
未开启优化:
1 | f proc near |
比较意外的是,原本位于寄存器的 3 个参数都被推送到了栈里。这种现象叫作阴影空间/shadow space
。 每个Win64程序都可以(但非必须)把 4 个寄存器的值保存到阴影空间里。
使用阴影空间有以下两个优点:
- 通过栈传递参数,可避免浪费寄存器资源(有时可能会占用 4 个寄存器);
- 便于调试器debugger在程序中断时找到函数参数
大型函数可能会把输入参数保存在阴影空间里,但是小型函数(如本例)可能就不会使用阴影空间了。
在使用阴影空间时,由调用方
函数分配栈空间,由被调用方函数根据需要将寄存器参数转储到它们的阴影空间中。
ARM
未开启优化的:
1 | .text:000000A4 0030A0E1 MOV R3, R0 |
主函数只起到了调用另外 2 个函数的作用。它把 3 个参数传递给了f()函数。
在 ARM 系统里,前 4 个寄存器R0~R3
负责传递前 4 个参数。
在本例中,f()函数通过前 3 个寄存器(R0~R2)读取参数。
MLA(Multiply Accumulate)
指令将前两个操作数(R3 和 R1 里的值)相乘,然后再计算第三个操作数(R2 里的值)和这个积的和,并且把最终运算结果存储在零号寄存器 R0 之中。根据 ARM 指令的有关 规范,返回值
就应该存放在 R0
寄存器里。
首条指令MOV R3, R0
属于冗余指令。即使此处没有这条指令,后面的 MLA 指令直接使用有关的 寄存器也不会出现任何问题。
BL
指令把程序的控制流交给LR
寄存器里的地址,而且会在必要的时候切换处理器的运行模式(Thumb 模式和ARM模式之间进行模式切换)。被调用方函数f()并不知道它会被什么模式的代码调用,不知道调用方函数属于 ARM模式的代码还是Thumb模式的代码。所以这种模式切换的功能还是必要的。如果它被 Thumb模式的代码调用,BX指令不仅会进行相应的跳转,还会把处理器模式调整为Thumb。如果它被ARM 模式的指令调用,则不会进行模式切换。
开启优化的:
1 | .text:00000098 f |
在启用最大幅度的优化功能(-O3
)之后,前面那条 MOV 指令被优化了,或者说被删除了。MLA 直接使用所有寄存器的参数,并且把返回值保存在 R0 寄存器里。调用方函数继而可从 R0 寄存器获取返回值。
开启优化的Thumb模式:
1 | .text:0000005E 48 43 MULS R0, R1 |
因为 Thumb 模式的指令集里没有 MLA 指令,所以编译器将它分为两个指令。第一条 MULS 指令计算 R0 和 R1 的积,把运算结果存储在 R1 寄存器里。第二条 ADDS 计算 R1 和 R2 的和,并且把计算结果存储在 R0 寄存器里。
ARM64
开启优化的GCC
1 | f: |
ARM64 的情况简单一些。MADD
指令可以一次进行乘法和加法的混合运算,与前文的 MLA 指令十 分类似。全部 3 个参数由 X-字头寄存器的低 32 位传递。这是因为这些参数都是 32 位整型数据。函数的返 回值存储在 W0
寄存器。
采用 64 位参数之后,程序使用了整个 64 位
X 寄存器
。程序通过两条指令
才能把长数据类型的 64 位值存储到寄存器里。
未开启优化的GCC
1 | f: |
函数 f()把传入的参数保存在数据栈里,以防止后期的指令占用W0~W2 寄存器
。这可防止后续指令覆盖函数参数,起到保护传入参数的作用。这种技术叫作寄存器保护区/Register Save Area
。 但是,本例的这种被调用方函数可以不这样保存参数。
在启用优化选项后,GCC会把这部分寄存器存储指令删除。这是因为优化功能判断出后续指令不会再操作函数参数的相关地址,所以编译器不再另行保存 W0~W2 中存储的数据。
此外,上述代码使用了 MUL/ADD
指令对,而没有使用 MADD 指令。
MIPS
1 | .text:00000000 f: |
MIPS参数传递会用$a0 - $a3
这四个寄存器。
MIPS平台有两个特殊的寄存器:HI
和 LO
。它们用来存储MULT指令的乘法计算结果——64位的积
。 只有 MFLO
和 MFHI
指令能够访问 HI 和 LO 寄存器。其中,MFLO 负责访问积的低 32 位部分。本例中它把积的低 32 位部分存储到$V0
寄存器。
因为本例没有访问积的高 32 位,所以那半部分被丢弃了。不过我们的程序的积是 32 位的整型数据。
最终 ADDU(Add Unsigned)
指令计算第三个参数与积的和。
在MIPS平台上,ADD
和ADDU
是两个不同的指令。此二者的区别体现在异常处理
的方式上,而符号位的处理方式反而没有区别。ADD指令可以触发溢出处理
机制。溢出有时候是必要的,而且被Ada和其他编程语言支持。ADDU不会引发溢出。因为C/C++不支持这种机制,所以本例使用的是ADDU指令而非ADD指令。
此后$V0
寄存器存储这 32 位的运算结果。
main()函数使用到了 JAL(Jump and Link)
指令。JAL 和 JALR 指令有所区别,前者使用的是相对地址—— 偏移量
,后者则跳转到寄存器存储的绝对地址里。JALR 的 R 代表 Register。由于 f()函数和 main()函数都位于同一个 object 文件,所以 f()函数的相对地址是已知的,可以被计算出来。
返回值
在x86系统里,被调用方函数通常通过EAX
寄存器返回运算结果。若返回值属于byte或char类型数据,返回值将存储于EAX寄存器的低 8 位——AL
寄存器存储返回值。如果返回值是浮点float型数据,那么返回值将存储在FPU的 ST(0)
寄存器里。ARM系统的情况相对简单一些,它通常使用R0
寄存器回传返回值。
void型函数的返回值
主函数 main()的数据类型通常是 void 而不是 int,调用 main()函数的有关代码大体会是这样的:
1 | push envp |
将其转换为源代码,也就是exit(main(argc,argv,envp));
如果声明 main()的数据类型是 void,则 main()函数不会明确返回任何值(没有 return 指令)。不过在 main()函数退出时,EAX 寄存器还会存有数据,EAX 寄存器保存的数据会被传递给 exit()函数、成为后者的输入参数。通常 EAX 寄存器的值会是被调用方函数残留的确定数据,所以 void 类型函数的返回值、也就是主函数退出代码往往属于伪随机数(pseudorandom)
。
例如下面的程序:
1 |
|
在main()函数结束时,EAX寄存器的值不会是零,应当是上一个函数——puts()函数的返回值。
函数返回值不被调用的情况
例如如下程序:
1 | int f() |
上述四个rand()函数都会把运算结果存储到EAX寄存器里,但是前三个rand()函数留在EAX寄存器的运算结果都被抛弃了。
返回值为结构体型数据
函数只能够使用 EAX 这 1 个寄存器回传返回 值。因为这种局限,过去的 C 编译器无法编译返回值超过 EAX 容量(一般来说,就是 int 型数据)的函 数。那个时候,如果要让返回多个返回值,那么只能用函数返回一个值、再通过指针传递其余的返回值。 现在的 C 编译器已经没有这种短板了,return 指令甚至可以返回结构体型的数据,只是时下很少有人会这 么做。如果函数的返回值是大型结构的数据,那么应由调用方函数(caller)负责分配空间,给结构体分配 指针,再把指针作为第一个参数传递给被调用方函数。现在的编译器已经能够替程序员自动完成这种复杂 的操作了,其处理方式相当于上述几个步骤,只是编译器隐藏了有关操作。
调用方函数(caller)创建数据结构、分配数据空间,被调用的函数仅向结构体填充数据。
其效果等同于返回结构体。这种处理方法并不会影响程序性能。
指针
全局变量、局部变量
全局:
1 |
|
数据传递过程如下:
局部:
1 | void main() |
数据传递过程如下:
GOTO语句
GOTO语句在反汇编后得到的命令是jmp
,无条件跳转指令。