《Reverse Engineering for Beginners》 - 第1章 代码模式 - 笔记(1.7)

来源:互联网 发布:查看所有node版本号 编辑:程序博客网 时间:2024/06/16 02:17

1.7. 带有参数的printf()

示例代码:

#include <stdio.h>int main(){    printf(“a=%d; b=%d; c=%d”, 1, 2, 3);    return 0;}

1.7.1. x86

x86: 3个参数

MSVC

参数是以3,2,1的顺序入栈的。因为参数类型是int,是32bits大小,所以一个参数占4byte。除了参数,还要入栈字符串的地址,所以是一共4个阐述,在栈中占据了16byte。所以,我们看看函数调用之后的ADD ESP, X指令,用X除以4就可以得到参数的个数。以上针对cdecl调用约定和32位环境。

注意有的时候编译器会在好几个函数调用完毕之后一起恢复栈。也就是很多个CALL之后才有ADD ESP, X指令,X的大小是此前所有调用函数参数大小总和。

MSVC和OllyDbg

发现printf()执行后ECX和EDX也有变化,说明函数内部机制用了这两个寄存器。如果不执行下一步,我们发现ESP和栈中内容都没有改变。这是因为cdecl调用约定,被调函数不恢复ESP,调用函数需要恢复ESP。继续执行下一条ADD ESP, 16指令,会发现ESP改变了,但栈中内容依然没变。

GCC

用GCC编译后,我们可以看到与MSVC的不同之处。GCC编译出的汇编代码,并没有使用PUSH/POP来将参数入栈,而是先设置好esp的值(一个基址),再使用mov [esp+10h+var_4], 3这样的指令,将参数放在esp加偏移指示的内存地址,实际上就是把值放在了栈中。

GCC和GDB

在printf()处下断点,然后查看esp指向的地址,可以证明该处放的是返回地址。

xchg %ax, %ax等同于nop。

紧跟着返回地址之后存放的就是要打印的字符串。(地址递增)再后面放的就是1,2,3三个参数值。

然后执行到结束。查看寄存器值,printf()返回值放在EAX里,为13(打印字符串长度)。

x64:8个参数

输出1-8这8个数字。加上字符串一共是9个参数。

MSVC

在Win64中,前4个参数放在RCX, RDX, R8, R9这4个寄存器中,其他的放在栈中。入栈的指令并不是PUSH,而是MOV。

在32位中,4byte是一个基本单位。而在64位中,8byte是一个基本单位。

GCC

前6个参数用RDI,RSI,RDX,RCX,R8,R9传递,其他的用栈传递。字符串指针用EDI存储而不是RDI。在printf()调用前会把EAX清零。因为返回值即打印字符个数要放在EAX中。

GCC+GDB

在printf()处断点。可以看到rdi的值就是字符串的地址。栈中第一个元素就是返回地址。然后余下的3个参数放在栈中。

1.7.2. ARM

ARM: 3个参数

前4个参数用R0-R3传递。余下的用栈传递。这种方式与fastcall调用约定以及win64相似。

32位ARM

Non-optimizing Keil (ARM模式)

字符串放在R0中,1,2,3放在R1-R3中。调用printf函数后将R0中的值变为0。

Optimizing Keil (Thumb模式)

与ARM模式的不同就是把开头的STMFD指令换成PUSH,把结尾的LDMFD指令换成POP。

Optimizing Keil (ARM模式) + 去掉返回值的情况

在源代码中去掉return语句。发现生成汇编的结果完全不一样了。前面的STMFD和后面的将R0清零和LDMFD指令都没有了。调用printf的指令BL变成了B。B指令类似x86中的JMP,直接跳转到一条指令,而不处理LR。

这块代码为什么行得通?1)没有改变栈的内容和SP的值。2)调用printf是最后一条指令,下面就没有指令了,所以不需要返回。

这种类型的优化通常运用在最后一条指令是调用函数指令的情况。

ARM64

Non-optimizing GCC

第一条指令STP会把FP(X29)和LR(X30)存在栈里。第二条指令ADD X29, SP, 0形成了栈帧。也就是把SP的值写入X29。接下来的ADRP/ADD指令对形成了指向字符串的指针,其中的lo12表示低12位,链接器会把LC1地址(即字符串地址)的低12位写入ADD指令的操作码。然后会把1,2,3这三个数放在w1,w2,w3,即32位。

优化的GCC会产生一样的代码。

ARM: 8个参数

加上字符串是一共9个参数。

Optimizing Keil (ARM模式)

函数前言部分:STR LR, [SP, #VAR_4]!指令将LR存放在栈上,因为我们会在调用printf的时候使用这个寄存器。首先SP会减少4,然后LR会被放在SP指示的地址。与PUSH有点像。第二个语句SUB SP, SP, #0x14减少SP以在栈上分配20bytes的空间。因为我们需要通过栈传递5个32位的参数,每个参数4bytes,总共就是20bytes。其他4个32位参数用寄存器传递。

将值5,6,7,8用栈传递。先存储在R0-R3里,然后用ADD R12, SP, #0x18+var_14指令把这4个值要存放的栈地址放到R12里。var_14是IDA定义的相当于-0x14,所以相当于把SP+4放在R12寄存器。下一条指令STMIA R12, R0-R3将R0-R3寄存器的内容写入R12指向的内存。STMIA是Store Multiple Increment After的缩写。

然后用栈传递值4。先存到R0,再存入栈中。

用寄存器传递数值1,2,3。分别放在R1-R3中。R0放字符串。

然后就可以调用printf()函数了。

函数结尾部分:ADD SP, SP, #0x14指令将SP恢复为原来的值,即清栈。LDR PC, [SP+4+var_4], #4将已存的LR从栈中加载到PC寄存器,让函数可以退出返回。与x86里的POP PC有点像。

Optimizing Keil: Thumb模式

与ARM模式差不多,还是换成了PUSH和POP指令,以及传参顺序不一样:先8,然后5,6,7,然后是4。

Optimizing Xcode (LLVM): ARM模式

基本与之前的差不多,就是STMFA指令,与STMIB相同。这个指令增加SP中的值,然后将下一个寄存器值写入内存,而不是以相反的顺序。

还有一个不同就是指令的分布是随机化的。编译器会为了效率做一些布置。编译器会把一些能够并行执行的指令放在一起。

Optimizing Xcode (LLVM): Thumb-2模式

与上一个例子几乎差不多,只是换成了Thumb模式特有的指令,比如PUSH和POP。

ARM64

Non-optimizing GCC

前8个参数都被传递到X或W开头的寄存器。字符串指针需要64位寄存器,所以传递到X0。其他的参数都是int类型,所以传递到W开头的32位寄存器。第9个参数即数字8用栈传递。不能用寄存器传递大量参数,因为寄存器就那么几个。

优化的GCC形成的代码差不多。

1.7.3. MIPS

3个参数

Optimizing GCC

这里与HelloWorld例子不同的是,调用了printf()而不是puts()。多出来的3个参数用寄存器57(A0A2)。这些寄存器以A为前缀就表明了这些寄存器用来传递函数参数。

如果用IDA加载的话,可以看到把LUI和ADDIU指令合并为LA。这个指令的作用是,加载字符串的地址并设置printf的第一个调用参数。

Non-optimizing GCC

没有优化的情况下有点冗长。总之步骤都是一样的:函数前言、加载字符串地址并设置第一个参数、设置余下的参数、加载printf地址、调用printf函数、设置返回值、函数结尾(通常恢复栈和设置指令指针)。

8个参数

Optimizing GCC

前4个参数会被传递到A0A3寄存器。剩下的通过栈传递。这种调用约定称为O32,是MIPS中最常见的一种。另一种N32会把寄存器用作不同的目标。SW即Store Word的简称(从寄存器存储到内存)。MIPS不能直接将值保存到内存,所以需要LI/SW指令对,即先将数值放入一个寄存器,再从寄存器把值复制到内存。可以发现传递参数的顺序很乱。

Non-optimizing GCC

冗长一点。

1.7.4. 结论

x86就是PUSH所有的参数到栈里,然后调用函数,最后恢复栈。

x64如果用MSVC编译,会用RCX,RDX,R8,R9传递前4个参数,剩下的用栈传递。然后调用函数,最后恢复栈。如果用GCC编译,会用RDI,RSI,RDX,RCX,R8,R9传递前6个参数,余下的用栈传递,然后调用函数,最后恢复栈。

ARM用R0-R3传递前4个参数,后面的用栈传递。用BL调用函数,最后恢复栈。

ARM64用X0-X7传递前8个参数,余下的放在栈中。BL CALL调用函数,最后恢复栈。

MIPS的O32调用约定是用47即A0A3传递参数,剩下的放在栈中。然后使用指令LW temp_reg, address of function和JALR temp_reg指令调用函数。

1.7.5. BTW

CPU对调用约定毫无洞察!它根本不管你用什么调用约定,它只管执行汇编指令。你完全可以使用任何一个寄存器,或者根本不用栈。
0 0
原创粉丝点击