逆向工程权威指南学习——(1-4章)

CPU简介

CPU寄存器:每种cpu都有其固定的通用寄存器(GPR)。x86cpu里一般有8个GPR,x64里往往有16个,而arm里则通常有16个GPR,您可以认为CPU寄存器是一种存储单元,能够无差别的存储所有临时变量。

最简函数

int f()
{
    return 123;
}

arm GCC4.9.4

f:
        mov     w0, 123
        ret

x86-64 gcc4.1.2

f:
        push    %rbp            ;rbp入栈
        mov     %rbp, %rsp      ;开辟函数栈空间
        mov     %eax, 123       ;eax寄存器作为返回值
        leave                   ;恢复栈空间和栈指针的值
        ret                     ;返回函数调用处

 mips gcc 4.9.4

f:
        addiu   $sp,$sp,-8              #开辟两个字的空间在栈上
        sw      $fp,4($sp)              #保存fp到栈顶 fp是帧指针
        move    $fp,$sp                 #保存sp到fp,就相当于sp入栈
        li      $2,123                  # 0x7b
        move    $sp,$fp                 #恢复sp的值
        lw      $fp,4($sp)              #恢复fp的值
        addiu   $sp,$sp,8               #恢复栈空间
        j       $31                     #31寄存器存放着返回地址
        nop

 HELLO WORLD

#include<stdio.h>
int main()
{
    printf("hello woorld\n");
    return 0;
}

x86 msvc 19.0

_DATA   SEGMENT
$SG4501 DB        'hello woorld', 0aH, 00H
_DATA   ENDS
_DATA   SEGMENT
COMM    ?_OptionsStorage@?1??__local_stdio_printf_options@@9@9:QWORD                                                    ; `__local_stdio_printf_options'::`2'::_OptionsStorage
_DATA   ENDS

_main   PROC
        push    ebp
        mov     ebp, esp
        push    OFFSET $SG4501
        call    _printf
        add     esp, 4
        xor     eax, eax
        pop     ebp
        ret     0
_main   ENDP

这里分为两个代码段,DATA数据段和代码断

上述代码对数据的申明相当于

 

#include<stdio.h>
char SG4501[] = "hello world\n";
int main()
{
    printf(SG4501);
    return 0;
}

 再次使用x86 mscv v19.0编译

_DATA   SEGMENT
_SG4501 DB        'hello world', 0aH, 00H
_DATA   ENDS
_DATA   SEGMENT
COMM    ?_OptionsStorage@?1??__local_stdio_printf_options@@9@9:QWORD                                                    ; `__local_stdio_printf_options'::`2'::_OptionsStorage
_DATA   ENDS

_main   PROC
        push    ebp
        mov     ebp, esp
        push    OFFSET _SG4501
        call    _printf
        add     esp, 4
        xor     eax, eax
        pop     ebp
        ret     0
_main   ENDP

可以发现和上一次的并无差异

可以看到为编译器为这个字符串尾部添加了00h,这将用于判断这个字符串的结束.通过push指令,程序吧字符串的指针推送入栈.这样,printf函数就可以调用栈里的指针.printf函数结束之后,程序的控制流会返回到main函数之中.此时,字符串地址仍残留在数据栈之中.这个时候就需要调整栈指针(ESP寄存器里的值)来释放这个指针.

这里使用add  esp,4

来释放数据栈,但其实还有可以使用pop指令来释放数据栈,比如,intel C++编译器使用pop ecx指令来释放数据栈,这样做的还有一个好处就是pop ecx对应的opcode比add esp,x的短.

这里还使用了xor eax,eax来使eax的值为0 ,因为eax是用于保存函数返回值.

gcc4.4.1编译的如上图

AND  ESP ,0fffffff0h,它使得esp的值向16字节边界对齐,属于初始化指令,如果地址没有对齐,那么cpu可能需要两次访问内存才能获得栈内数据.

SUB ESP,10h将在栈中分配0x10bytes,即16字节.我们在后文看到,程序只会用到4字节空间.但是因为编译器对栈地址esp进行了16字节对齐.而后程序将字符串地址的值直接写入到数据栈.此处,GCC使用的是mov指令,而msvc使用的是push指令.

GCC:AT&T语体

AT&T语体同样是编程语言的显示风格

 

在上述代码里,由小数点开头的指令就是宏.这种形式的汇编语体大量使用汇编宏,可读性很差.为了便于演示,我们将其中字符串以外的宏忽略不计.

intel语体和AT&T语体的区别

intel格式<指令><目标><源>

AT&T格式<指令><源><目标>

AT&T 中,寄存器名称之前使用百分号%标记,在立即数之前使用美元符号($)标记

AT&T使用圆括号(),intel使用方括号[]

AT&T语体里,每个运算操作符都需要声明操作数据的类型

-l 指代32位long型数据

-w指代16位word型数据

-b指代8位byte型数据

x86-64

x64 msvc 19.14

_DATA   SEGMENT
_DATA   ENDS
_DATA   SEGMENT
COMM    ?_OptionsStorage@?1??__local_stdio_printf_options@@9@9:QWORD                                                    ; `__local_stdio_printf_options'::`2'::_OptionsStorage
_DATA   ENDS

main    PROC
$LN3:
        sub     rsp, 40                             ; 00000028H
        lea     rcx, OFFSET FLAT:SG4501
        call    printf
        xor     eax, eax
        add     rsp, 40                             ; 00000028H
        ret     0
main    ENDP

在x86-64框架的cpu里,所有的物理寄存器都被扩展位64位的寄存器.程序可通过R-字头的名称直接调用整个64位寄存器.为了尽可能地充分利用寄存器,减少访问内数据的次数,编译器会充分利用寄存器传递参数.,也就是说编译器会优先使用寄存器传递部分参数,再利用内存(数据栈)传递其余的参数.win64的程序还会使用rcx,rdx,r8,r9这4个寄存器来存放函数参数.

在x86-64硬件平台上,寄存器和指针都是64位的,存储于R-字头的寄存器里.但是处于兼容性的考虑,64位寄存器的低32位也是能够担当32位寄存器的角色,才能运行32位程序.

main()函数的返回值是整数类型的0,但是处于兼容性和可移植性的考虑,c语言的编译器仍将使用32位的0,即使是64位的应用 程序,在程序结束时eax的值为0,rax的值不一定为0.

GCC -x86-64

linux,BSD,和mac OS X系统中的应用程序,会优先使用RDI,RSI,RDX,RCX,R8,R9这6个寄存器传递函数所需的6个参数,然后使用栈传递其余的参数.

因此64位的GCC编译器使用EDI寄存器存储字符串指针.EDI不过是RDI寄存器的低32位,为何GCC不使用整个RDI寄存器呢.

需要注意的是,64位汇编指令mov在写入R-寄存器的低32位地址的时候,即对e-寄存器进行写操作的时候,会同时清楚R寄存器的高32位,因为对edi寄存器的opcode占了5个字节,相比之下,rdi的opcode占了7个字节. 

GCC的其他特性

只要c语言代码里使用了字符串型常量,编译器就会把这个字符串常量置于常量字段,以保证其内容不会发生变化.不过gcc有个有趣的特征;他可能会把字符串拆出来单独使用.

将上述代码使用gcc4.8.1编译

f1()函数调用puts()函数时,它输出字符串"word"和外加结束符(数值为0的1个字节)因为puts函数并不知道字符串可以和前面的字符串连起来形成新的字符串。

ARM

未启用优化功能的arm模式

在本节的例子里,每条指令都占4个字节。正如您所见到,我们确实要把源程序编译为arm模式指令集的应用程序,而不是把它编译为thumb模式的应用程序。

stmfd sp!,{R4,LR}相当于x86的push指令。它把R4寄存器和LR寄存器的数值放到数据栈中。是因为arm模式的指令集里没有push指令,只有thumb模式里的指令集里才有”push/pop“指令。

这条指令首先将sp递减,在栈中分配一个新的空间以便存储R4和LR的值。

STMFD指令能够一次存储多个寄存器的值,thumb模式的push指令也可以这样用,实际上x86指令集并没有这样方便的指令。STMFD指令可以看作是增强版的PUSH指令,它不仅能够存储sp的值,也能够存储任何寄存器的值,STMFD可以在指定的内存空间存储多个寄存器的值。

接下来的指令是ADR R0,aHelloWorld。它首先对PC进行取值工作,然后把hello,world字符串的偏移与PC的值相加,将其结果存储到R0之中。有些读者可能不明白此处PC寄存器的作用。编译器通常帮助PC把某些指令强制变为”位置无关代码,在操作系统把程序加载在内存里的时候,OS  分配给程序代码的内存地址是不固定的;但是程序内部既定指令和数据常量之间的偏移是固定的。这种情况下,要在程序内部进行寻址,就需要借助PC指针,ADR将当前指令的地址和字符串指针地址的差值传递给R0.

BL __2printf调用print函数。BL实施的具体实际操作是

将下一条指令的地址,即地址0xc处的mov R0,#0的地址写入LR寄存器

然后将printf函数的地址写入PC寄存器,以引导系统执行该函数

mov R0 ,#0;将寄存器R0置零

LDMFD sp!,R4,PC 这一指令,它与STMFD成对出现,做的工作相反。它将栈中的数值取出,依次赋值给R4和PC,并且调整栈指针sp,可以说和pop很相似

thumb模式下,未开启优化选项的keil

thumb模式程序的每条指令,都对应着2个字节的opcode,这是thumb模式程序的特征。但是thumb模式的跳转指令BL看上去占用了4个字节的opcode,实际上它是由2条指令组成的,单挑16位opcode传递的信息太有限,所以BL分为两条指令,第一条指令16位指令可以传递偏移量的高10位,第二条指令可以传递偏移量的低11位。而thumb模式的opcode都是固定的2个字节长,目标地址最后一位一定是0,执行thumb模式的转移指令时,处理器会将目标地址左移1位,形成22位的偏移量。

ARM模式下,开启优化选项的xcode

movt 是对寄存器的高16位进行赋值操作。

thumb2模式下、开启优化选项的xcode

此处的BLX与thumb模式的BL指令有着根本的区别,它不仅将puts函数的返回地址RA存储了LR寄存器,将控制权交给了puts函数,而且还把处理器从thumb/thumb2模式调整为arm模式;它同时也负责在函数退出时把处理器的运行模式进行还原。总之,它同时实现了模式转换和控制权交接的功能,相当于执行了下面的ARM模式的指令:

专用函数(thunk function)形实转换函数

可以以ARM模式运行的独立函数,让他专门处理动态链接库的接口问题。

ARM64

一方面ARM 64的cpu只可能运行在arm模式下,不可运行在thumb或者thumb-2模式,所以它必须使用32位的指令。另一方面,64位平台的寄存器数量也翻了一番,拥有了64个X头寄存器.当然程序还可以直接使用w-字头的名称直接访问寄存器的低32位空间.

上述程序的STP指令把两个寄存器的值存储到栈里.虽然这个指令实际上可以把这对数值存储到内存的任意地址,但是由于该指令明确了sp寄存器,所以他就是通过栈来存储这对数值.

这条指令中的感叹号标志,意味着其标注的运算会被优先执行.即,该指令集先把sp的值减去16,在此之后再把两个寄存器的值写在栈里.这属于预索引.此外还有延迟索引

MIPS

全局指针:

全局指针是MIPS软件系统的一个重要概念.我们已经知道,每条MIPS指令都是32位的指令,所以单挑指令无法容纳32位地址,这种情况下MIPS就得传递一对指令才能使用一个完整的指针.从另一方面来说,单挑指令确实可以容纳一组由寄存器的符号,有符号的16位偏移量,因此任何一条指令都可以构成的表达式,访问某个取值范围为"寄存器-32768"-"寄存器+32767"之间的地址(总共69kb).为了简化静态数据的访问操作,MIPS平台特地为此保留了一个专用的寄存器,并且把常用的数据分配到了一个大小为64kb的内存数据空间里.这种专用的寄存器就叫做"全局指针'寄存器.它的值是一个指针,指向64kb数据空间的正中间.而这64kb空间通常用于存储全局变量,以及printf这类由外部导入的外部函数地址gccd的开发团队认为:获取函数地址这类的操作,应该由单条指令完成:双指令取址的运行效率不可接受.

在elf文件格式中,这个64kb的静态数据位于.sbss和.sdata之中.".sbss"是small BSS的缩写,用于存储非初始化的数据.".sdata"是small data"的缩写,用于存储初始化数值的数据.

根据这种数据布局编程人员可以自行决定把需要快速访问的数据方在.sdata,还是.sbss数据段中.

Optimizing GCC

主函数序言启动部分的指令初始化了全局指针寄存器GP寄存器的值,并且把它指向64kb数据段的正中央.同时,程序把RA寄存器的值存储于本地数据栈.它同样使用puts()函数替代了printf()函数.而puts函数的地址.则通过LW的指令加载到了$25寄存器.此后字符串的高16位地址和低16位地址分别由LUI和ADDIU 两条指令加载到$4寄存器.LUI中的upper一词说明它将数据存储于寄存器的高16位.与此相对应,ADDIU则把操作符地址的低16位进行了求和运算,ADDIU指令位于JALR指令之后,但是会先于后者运行.$4寄存器其实就是$A0寄存器,在调用函数时传递第一个参数.

jalr指令跳转到$25寄存器中的地址,即puts函数的启动地址,并且把下一条指令LW指令的地址存储于RA寄存器。可见,MIPS系统调用函数的方法和arm系统相似。需要注意的是,由于分支延迟槽效益,存储于RA寄存器的值并非是已经运行过的“下一条”指令的地址,而是更后面那条指令的地址,所以在执行JALR指令时,写入RA寄存器的值是PC+8,即ADDIU后面的那条指令的地址。

第20行的LW指令,用于把本地栈中的RA值恢复回来。请注意,这条指令并不位于被调用函数的函数尾声,也就是函数调用约定的不同,这里采用的是调用函数来平衡堆栈的方法。

第22行的MOVE指令把$0的值复制给$2.MIPS 有一个常量寄存器,它里面的值是常量0.很明显,因为MIPS的研发人员认为0是计算机编程里用得最多的常量,所以他们开创了一个使用$0寄存器提供数值0的机制。这个例子演示了另外一个值得注意的现象;在MIPS系统之中,没有在寄存器之间复制数值的硬件指令,确切的说,MOV DST,SRC 是通过加法指令ADD DST,SRC,$ZERO 变相实现的。由此可见,MIPS研发人员希望尽可能地复用opcode,从而精简opcode的总数。然后这并不代表每次运行move指令的时候cpu都会进行实际意义上的加法运算。cpu能够为这些指令进行优化处理,在运行他们的时候并不会用到ALU。第24行的J指令会跳转到RA所指向的地址,完成从被调用函数返回调用者函数的操作。还是由于分支延迟槽效益,其后的ADDIU指令会先于J指令运行,构成函数尾声。

第15行的指令使用局部栈保存GP的值。令人感到匪夷所思的是,GCC的汇编输出里看不到这条指令,或许这时GCC自身的问题。严格的说,此时有必要保存GP的值。毕竟每个函数都有着自己的64kb数据窗口。

程序中保存puts函数地址的寄存器叫做$T9寄存器。这类T-开头的寄存器叫做“临时”寄存器,用于保存代码里的临时值。调用者函数负责保存这些寄存器的数值,因为它很有可能会被被调用的函数重写。

Non-optimizing GCC

未经优化处理的GCC输出要详细得多。此处,我们可以观察到程序把FP当作栈帧的指针来使用,而且它还有3个nop(空操作)指令。在这3个空操作指令中,第二个,第三个指令都位于分支跳转指令之后。

Non-optimizing GCC 4.4.5(IDA)

栈帧

本例使用寄存器来传递文本字符串的地址。但是它同时设置了局部栈,这是为什么呢,由于程序在调用printf函数的时候由于程序必须保存RA寄存器的值GP的值,故而出现了数据栈。

总结:

x64和x86指令的主要区别体现在指针上,前者使用64位指针而后者使用32位指针。近年来,内存的价格在不断降低,而cpu的计算能力也不断增强,当计算机的内存增加到一定程度时,32指针就无法满足寻址的需要了,所以指针也随之演变位64位。

首先看call了messagbeep,这是windows发声函数的API,这个函数有一个常数参数,这里是ffffffff,也就是0,所以这里就是调用这个函数,并且eax置零,eax存放的是返回0,

c语言版

#include<winuser.h>
int mian ()
{
    MessageBeep(0);
    retrun 0;
}

首先第一行是将rbp入栈,然后,rsp复制给rbp,也就是开启栈空间,然后将2复制给edi,调用sleep函数,最后恢复栈,返回。

c语言版

void main()
{
    sleep(2);
}

第4章 函数序言和函数尾声

函数序言是函数在启动的时候运行的一系列指令,汇编指令大致如下

push ebp

mov ebp,esp

sub esp,x

这些指令的功能就是:在栈里保存ebp寄存器的内容,将esp的值复制到ebp寄存器,然后修改栈的高度,为局部变量申请空间。

函数在退出时会做相反的事。

mov esp,ebp

pop ebp

ret 0

 

posted @ 2024-06-06 13:24  robot__i  阅读(85)  评论(0)    收藏  举报