第一次用IDA打开ELF64可执行文件进行分析,有种刚学逆向的错觉,各种之前不认识的寄存器,函数调用完全找不到函数参数是怎么传递的。看来有必要恶补一下x64位CPU的和逆向分析相关的知识。
首先先来科普一下64位CPU的混乱的术语:
术语 | 说明 |
---|---|
AMD64 | AMD研制的64位CPU(直接向下兼容x86). |
EM64T | Intel研制的兼容AMD64的CPU. |
Intel64 | EM64T的新名字. |
IA-64 | Intel和HP合作开发的64位CPU(可以通过模拟器间接兼容x86). |
x86 | Intel的IA-32、IA-16、IA-8系列的CPU. |
x64 | AMD64&Intel64. |
接下来就让我们一起看下x64中新增或变更的知识点(这里只介绍和逆向分析相关的,更详细内容可以参考Intel用户手册)。
内存地址变成64位,当然程序使用的指针也相应的编程64位的指针。所以含有绝对地址(VA)的指令大小比原来增加了4字节。同样,寄存器的大小和栈的基本单元也变成64位。
虚拟内存的实际大小为16TB(内核和用户空间各占8TB)。
大小扩大到64(8字节),个数增加到18个(新增了R8~R15寄存器)。x64系统下的所有通用寄存器的名称均以字母”R”开头(x86以字母”E”开头),为了向下兼容,支持访问寄存器的8位、16位、32位(eg:AL、AX、EAX)。
**Note:**64位本地模式中不使用段寄存器:CS、DS、ES、SS、FS、GS,他们仅用于向下兼容32位程序。
仍延续x86相同的指令,eg,CALL XXXXXXXX –> FF15XXXXXXXX,其中XXXXXXXX“绝对地址”指向IAT区域的某个位置。但对地址解析方法不同了。具体解析方式暂时先不深入。
整数和浮点数参数
32位:cdecl、stdcall、fastcall等几种,但64位统一为一种变形的fastcall。64位的fastcall中最多可以把函数的4个参数存储到寄存器中传递:
参数 | 整数型 | 浮点数型 |
---|---|---|
1st | RCX | XMM0 |
2st | RDX | XMM1 |
3st | R8 | XMM2 |
4st | R9 | XMM2 |
超过4个参数,使用栈来传递,传递顺序依照“从右向左”。此外,函数返回时传递的参数过程中所用的栈由调用者清理。看上去64位的fastcall就像32位下的cdcel和fastcall的结合。函数的前4个参数虽然使用寄存器传递,但在栈中仍为这4个参数预留了空间(32个字节)。
Note:当整数和浮点数参数混合出现时,eg:
void func(float a, int b, double c, int d);
a放入XMM0中,b放入RDX,c放入XMM2,d放入R9。
这个存放的顺序很怪异,其实这是严格按照表2.1中整数和浮点数的4个参数一一对应,需要为没用的参数预留空间,eg,c参数没有放到XMM1中,XMM1被预留位置了。
指针参数
指针参数的传递遵循整数参数传递的方式。
结构体参数
结构体参数比较特殊,如果结构体长度小于64bit,则使用整数参数的传递规则。但如果是一个很大的结构体,那么应该还是要在堆栈中申请临时空间的(但ddk没有明说这一点,参考x86的规则应该如此)。
未声明函数调用
func1();
func2()
{
func1(2, 1.0, 7);
}
在这种情况下,func1()的参数表其实不明确,那么参数的传递要怎样进行?这里采用了一个比较保守的规则,就是:整数参数还是按照寄存器映射关系放入对应的寄存器中,浮点数在按照映射关系放入XMM寄存器后,还需要按照整数参数的寄存器映射关系放入整数寄存器中一次,这就是“比较保守的规则”的意思。就现在这个例子而言,结果如下:
2在RCX中,1.0在RDX和XMM1中,7在R8中。
和Windows平台的编译器一样,在Linux下的GCC编译器编译的函数,默认也是采用fastcall调用约定,但参数传递的方式却和Windows平台下截然不同,最多会把8个参数存储到寄存器中传递:
参数 | 整数型 | 浮点数型 |
---|---|---|
1st | RDI | XMM0 |
2st | RSI | XMM1 |
3st | RDX | XMM2 |
4st | RCX | XMM3 |
5st | R8 | XMM4 |
6st | R9 | XMM5 |
7st | 栈 | XMM6 |
8st | 栈 | XMM7 |
这里当参数都是整数时大于6个参数时使用栈传递,浮点数型参数大于8个时,使用栈传递,传递顺序依照“从右向左”。
Note:
当整数和浮点数参数混合出现时,eg:
void func(float a, int b, double c, int d);
a放在XMM0,b放在RDI,c放在XMM1,d放在RSI。
可以看到Linux的GCC下正浮混合时没有为没用到得参数预留空间。RAX、RCX、RDX、RSI、RDI、R8、R9都是易失寄存器(它们的值经常被改变),所以被调用函数不必恢复它们的值,可以看到上述几个寄存器大多被用于函数传参,值被修改了也无妨。其他的寄存器是非易失寄存器(RBX、RBP、RSP、R10~R15),通常在函数中使用时需要保存原值,并在函数返回时恢复它们的值。
从Windows和Linux的64位函数调用约定不同可以总结出两点:
64位的操作系统中使用的栈与栈帧方式也发生了变化。简言之,栈的大小比函数实际需要的大小要大很多。调用子函数时不在使用PUSH命令来传递参数,而是通过MOV指令操作寄存器与预定的栈(fastcall)来传递。使用VC++创建的x64程序代码几乎看不到PUSH/POP指令(替换成MOV了,我晕!!)。
创建栈帧时也不再使用RBP寄存器,而是直接使用RSP寄存器来实现(这点让我们分析栈帧更加困难,幸亏还有IDA)。之前可以在32位下的编译器选项中开启优化功能。
让我们看下具体的变化:
sub rsp,48h
...
add rsp,48h
Ret
由于Linux平台逆向分析很少,能总结的也不多。由于Linux平台下的GCC比Windows平台的VC使用更多的寄存器(达到6~8个),所以很少看到使用栈传递的情况。这里以一个玩具代码为例(GCC没有开启优化):
int func(int a , int b , int c , int d , int e , int f , int g , int h , int i)
{
return a + b + c + d + e + f + g + h + i;
}
int main(int argc, char* argv[])
{
int ret;
ret = func(1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9);
printf("ret : %d" , ret);
return 0;
}
可以看到多余6个参数使用MOV指令向栈内存[RSP+XXXh]位置压入参数,并且看到参数的传递顺序,和Windows一样木有PUSH指令压入参数了。接下来再来看一下栈帧:
这里我编译时使用了“-g”选项,同时保留了符号表和重定位信息(没有使用“-s”),这里很尴尬IDA显示的func函数的调用约定是cdecl,可能64位的调用约定很像32位的cdecl和fastcall的结合体,所以IDA显示为cdecl。也可以看到清晰的栈帧结构:
但有时,可以看到就不那么幸运的看到清晰的栈帧了,比如下面的反汇编代码:
已经找不到func的身影,我们使用插件反编译一下:
现,反编译插件显示的是fastcall调用约定。这可能是IDA还没能跟上编译器的变化,无法做出正确的调用约定匹配。
恶补完x64位的知识后,现在回过头再看下IDA中的汇编代码,顺眼很多了。
接下来,利用IDA的静态库SIG更进一步增加反汇编的可阅读行。