逆向工程——参数获取、返回值、指针、GOTO语句

[TOC]

参数获取

示例程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int f ( int a, int b, int c)
{
return a*b+c;
}

int main()
{
printf("%d\n", f(1,2,3));
return 0;
}

x86

MSVC

MSVC编译后的指令清单如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
_TEXT	SEGMENT
_a$ = 8 ; size = 4
_b$ = 12 ; size = 4
_c$ = 16 ; size = 4
_f PROC
push ebp
mov ebp,esp
mov eax, DWORD PTR _a$[ebp]
imul eax, DWORD PTR _b$[ebp]
add eax, DWORD PTR _c$[ebp]
pop ebp
ret 0
_f ENDP

_main PROC
push ebp
push ebp,esp
push 3 ; 3rd argument
push 2 ; 2nd argument
push 1 ; 1st argument
call _f
add esp, 12
push eax
push OFFSET $SG2463 ; '$d', 0aH, 00H
call _printf
add esp, 8
; return 0
xor eax, eax
pop ebp
ret 0
_main ENDP

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$SG2997	DB	'%d', 0aH, 00H

main PROC
sub rsp, 40
mov edx, 2
lea r8d, QWORD PTR [rdx+1] ; r8d=3
lea ecx, QWORD PTR [rdx-1] ; ecx=1
call f
lea rcx, OFFSET FLAT:$SG2997 ; '%d'
mov edx, eax
call printf
xor eax,eax
add rsp, 40
ret 0
main ENDP

f PROC
; ECX - 1st argument
; EDX - 2nd argument
; R8D - 3rd argument
imul ecx,edx
lea eax,DWORD PTR [r8+rcx]
ret 0
f ENDP

我们可以看到,f()函数通过寄存器获取了全部的所需参数。此处求址的加法运算是通过LEA指令实现 的。很明显,编译器认为 LEA 指令的效率比 ADD 指令的效率高,所以它分配了 LEA 指令。在制备 f()函 数的第一个和第三个参数时,main()函数同样使用了 LEA 指令。编译器无疑认为 LEA 指令向寄存器赋值 的速度比常规的 MOV 指令速度快。

未开启优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
f   proc near
; shadow space:
arg_0 = dword ptr 8
arg_8 = dword ptr 10h
arg_10 = dword ptr 18h
; ECX - 1st argument
; EDX - 2nd argument
; R8D - 3rd argument
mov [rsp+arg_10], r8d
mov [rsp+arg_8], edx
mov [rsp+arg_0], ecx
mov eax, [rsp+arg_0]
imul eax, [rsp+arg_8]
add eax, [rsp+arg_10]
retn
f endp

main proc near
sub rsp, 28h
mov r8d,3 ; 3rd argument
mov edx,2 ; 2nd argument
mov ecx,1 ; 1st argument
call f
mov edx, eax
lea rcx, $SG2931 ; "%d\n"
call printf
; return 0
xor eax, eax
add rsp, 28h
retn
main endp

比较意外的是,原本位于寄存器的 3 个参数都被推送到了栈里。这种现象叫作阴影空间/shadow space 。 每个Win64程序都可以(但非必须)把 4 个寄存器的值保存到阴影空间里。
使用阴影空间有以下两个优点:

  1. 通过栈传递参数,可避免浪费寄存器资源(有时可能会占用 4 个寄存器);
  2. 便于调试器debugger在程序中断时找到函数参数

大型函数可能会把输入参数保存在阴影空间里,但是小型函数(如本例)可能就不会使用阴影空间了。
在使用阴影空间时,由调用方函数分配栈空间,由被调用方函数根据需要将寄存器参数转储到它们的阴影空间中。

ARM

未开启优化的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.text:000000A4 0030A0E1 MOV R3, R0
.text:000000A8 932120E0 MLA R0, R3, R1, R2
.text:000000AC 1EFF2FE1 BX LR
...
.text:000000B0 main
.text:000000B0 10402DE9 STMFD SP!, {R4,LR}
.text:000000B4 0320A0E3 MOV R2, #3
.text:000000B8 0210A0E3 MOV R1, #2
.text:000000BC 0100A0E3 MOV R0, #1
.text:000000C0 F7FFFFEB BL f
.text:000000C4 0040A0E1 MOV R4, R0
.text:000000C8 0410A0E1 MOV R1, R4
.text:000000CC 5A0F8FE2 ADR R0, aD_0 ; "%d\n"
.text:000000D0 E31800EB BL __2printf
.text:000000D4 0000A0E3 MOV R0, #0
.text:000000D8 0000A0E3 LDMFD SP!, {R4,PC}

主函数只起到了调用另外 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
2
.text:00000098							f
.text:00000098 91 20 20 E0 MLA R0, R1, R0, R2 .text:0000009C 1E FF 2F E1 BX LR

在启用最大幅度的优化功能(-O3)之后,前面那条 MOV 指令被优化了,或者说被删除了。MLA 直接使用所有寄存器的参数,并且把返回值保存在 R0 寄存器里。调用方函数继而可从 R0 寄存器获取返回值。

开启优化的Thumb模式:

1
2
3
.text:0000005E 48 43		MULS R0, R1
.text:00000060 80 18 ADDS R0, R0, R2
.text:00000062 70 47 BX LR

因为 Thumb 模式的指令集里没有 MLA 指令,所以编译器将它分为两个指令。第一条 MULS 指令计算 R0 和 R1 的积,把运算结果存储在 R1 寄存器里。第二条 ADDS 计算 R1 和 R2 的和,并且把计算结果存储在 R0 寄存器里。

ARM64

开启优化的GCC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
f:
madd w0, w0, w1, w2
ret
main:
; save FP and LR to stack frame:
stp x29, x30, [sp, -16]!
mov w2, 3
mov w1, 2
add x29, sp, 0
mov w0, 1
bl f
mov w1, w0
adrp x0, .LC7
add x0, x0, :lo12:.LC7 bl printf
; return 0
mov w0, 0
; restore FP and LR
ldp x29, x30, [sp], 16
ret
.LC7:
.string "%d\n"

ARM64 的情况简单一些。MADD 指令可以一次进行乘法和加法的混合运算,与前文的 MLA 指令十 分类似。全部 3 个参数由 X-字头寄存器的低 32 位传递。这是因为这些参数都是 32 位整型数据。函数的返 回值存储在 W0 寄存器。

采用 64 位参数之后,程序使用了整个 64 位 X 寄存器。程序通过两条指令才能把长数据类型的 64 位值存储到寄存器里。

未开启优化的GCC

1
2
3
4
5
6
7
8
9
10
11
12
f:
sub sp, sp, #16
str w0, [sp,12]
str w1, [sp,8]
str w2, [sp,4]
ldr w1, [sp,12]
ldr w0, [sp,8]
mul w1, w1, w0 l
dr w0, [sp,4]
add w0, w1, w0
add sp, sp, 16
ret

函数 f()把传入的参数保存在数据栈里,以防止后期的指令占用W0~W2 寄存器。这可防止后续指令覆盖函数参数,起到保护传入参数的作用。这种技术叫作寄存器保护区/Register Save Area。 但是,本例的这种被调用方函数可以不这样保存参数。
在启用优化选项后,GCC会把这部分寄存器存储指令删除。这是因为优化功能判断出后续指令不会再操作函数参数的相关地址,所以编译器不再另行保存 W0~W2 中存储的数据。
此外,上述代码使用了 MUL/ADD 指令对,而没有使用 MADD 指令。

MIPS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
.text:00000000	f:
; $a0=a
; $a1=b
; $a2=c
.text:00000000 mult $a1, $a0
.text:00000004 mflo $v0
.text:00000008 jr $ra
.text:0000000C addu $v0, $a2, $v0 ; branch delay slot
; result in $v0 upon return
text:00000010 main:
.text:00000010
.text:00000010 var_10 = -0x10
.text:00000010 var_4 = -4
.text:00000010
.text:00000010 lui $gp, (__gnu_local_gp >> 16)
.text:00000014 addiu $sp, -0x20
.text:00000018 la $gp, (__gnu_local_gp & 0xFFFF)
.text:0000001C sw $ra, 0x20+var_4($sp)
.text:00000020 sw $gp, 0x20+var_10($sp)
; set c:
.text:00000024 li $a2, 3
; set a:
.text:00000028 li $a0, 1
.text:0000002C jal f
; set b:
.text:00000030 li $a1, 2 ; branch delay slot
; result in $v0 now
.text:00000034 lw $gp, 0x20+var_10($sp)
.text:00000038 lui $a0, ($LC0 >> 16)
.text:0000003C lw $t9, (printf & 0xFFFF)($gp)
.text:00000040 la $a0, ($LC0 & 0xFFFF)
.text:00000044 jalr $t9
; take result of f() function and pass it as a second argument to printf():
.text:00000048 move $a1, $v0 ; branch delay slot
.text:0000004C lw $ra, 0x20+var_4($sp)
.text:00000050 move $v0, $zero
.text:00000054 jr $ra
.text:00000058 addiu $sp, 0x20 ; branch delay slot

MIPS参数传递会用$a0 - $a3这四个寄存器。
MIPS平台有两个特殊的寄存器:HI LO。它们用来存储MULT指令的乘法计算结果——64位的积。 只有 MFLOMFHI 指令能够访问 HI 和 LO 寄存器。其中,MFLO 负责访问积的低 32 位部分。本例中它把积的低 32 位部分存储到$V0 寄存器。
因为本例没有访问积的高 32 位,所以那半部分被丢弃了。不过我们的程序的积是 32 位的整型数据。
最终 ADDU(Add Unsigned)指令计算第三个参数与积的和。
在MIPS平台上,ADDADDU是两个不同的指令。此二者的区别体现在异常处理的方式上,而符号位的处理方式反而没有区别。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
2
3
4
5
6
push envp 
push argv
push argc
call main
push eax
call exit

将其转换为源代码,也就是exit(main(argc,argv,envp));
如果声明 main()的数据类型是 void,则 main()函数不会明确返回任何值(没有 return 指令)。不过在 main()函数退出时,EAX 寄存器还会存有数据,EAX 寄存器保存的数据会被传递给 exit()函数、成为后者的输入参数。通常 EAX 寄存器的值会是被调用方函数残留的确定数据,所以 void 类型函数的返回值、也就是主函数退出代码往往属于伪随机数(pseudorandom)

例如下面的程序:

1
2
3
4
5
6
#include <stdio.h>

void main()
{
printf("hello, world!\n");
}

在main()函数结束时,EAX寄存器的值不会是零,应当是上一个函数——puts()函数的返回值。

函数返回值不被调用的情况

例如如下程序:

1
2
3
4
5
6
7
int f()
{
rand();
rand();
rand();
return rand();
}

上述四个rand()函数都会把运算结果存储到EAX寄存器里,但是前三个rand()函数留在EAX寄存器的运算结果都被抛弃了。

返回值为结构体型数据

函数只能够使用 EAX 这 1 个寄存器回传返回 值。因为这种局限,过去的 C 编译器无法编译返回值超过 EAX 容量(一般来说,就是 int 型数据)的函 数。那个时候,如果要让返回多个返回值,那么只能用函数返回一个值、再通过指针传递其余的返回值。 现在的 C 编译器已经没有这种短板了,return 指令甚至可以返回结构体型的数据,只是时下很少有人会这 么做。如果函数的返回值是大型结构的数据,那么应由调用方函数(caller)负责分配空间,给结构体分配 指针,再把指针作为第一个参数传递给被调用方函数。现在的编译器已经能够替程序员自动完成这种复杂 的操作了,其处理方式相当于上述几个步骤,只是编译器隐藏了有关操作。
调用方函数(caller)创建数据结构、分配数据空间,被调用的函数仅向结构体填充数据。
其效果等同于返回结构体。这种处理方法并不会影响程序性能。

指针

全局变量、局部变量

全局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

void f1( int x, int y, int *sum, int *product)
{
*sum = x+y;
*product = x*y;
}
int sum, product;

void main()
{
f1(123,456,&sum,&product);
printf("sum=%d, product=%d\n", sum, product);
}

数据传递过程如下:
屏幕快照 2017-12-11 下午12.05.07.png
局部:

1
2
3
4
5
6
void main()
{
int sum, product; // now variables are local in this function
f1(123,456,&sum,&product);
printf("sum=%d, product=%d\n", sum, product);
}

数据传递过程如下:
屏幕快照 2017-12-11 下午12.02.06.png

GOTO语句

GOTO语句在反汇编后得到的命令是jmp,无条件跳转指令。