逆向工程——HELLO WORLD!
内容主要参考《RE4B》
从今天开始,我们将学习《RE4B》这本书。这本书和汇编基础一起,将做为我们入门逆向工程的基础。
为了省去大家安装各种不同编译器的麻烦,我们将使用在线工具进行编译,地址如下:
[https://gcc.godbolt.org/]
HELLO WORLD
现在,我们形如演示各种编程书中的最著名的程序:
1 | #include <stdio.h> |
x86
函数调用约定 1,后 面一条指令会把EAX
的值当作返回值传递给调用者函数,而调用者函数(caller)会从EAX寄存器里取值, 把它当作返回结果。
MSVC
使用在线工具的MSVC编译器,我们可以得到下面的汇编指令结果:
1 | ... |
汇编语言存在两种主流语体,即Intel语体
和AT&T语体
。MSVC生成的汇编清单文件都采用了Intel语体。
在hello world这个例子中,我们可以看到结果中的代码中有COST
和_TEXT
段。它们分别代表数据段
和代码段
。C/C++程序为字符串常量”hello, world!”分配了一个指针(cost char[]),只是在代码中这个指针的名称并不明显。然后,编译器自己进行了处理,并在内部把字符串常量命名为$SG5328
。
因此,上述程序的源代码等效于:
1 | #include <stdio.h> |
回顾编译结果,我们发现编译器在字符串常量的尾部添加了十六进制的数字0
,即00h。依据C/C++字符串的标准规范,编译器要为这个字符串常量添加结束标志
(即数值为零的单个字节)。
在代码段_TEXT
中,我们可以看到,函数体都有标志性的函数序言(function prologue)
和函数尾声(function epilogue)
。
函数序言
函数序言是函数在启动的时候运行的一系列指令。其汇编指令大致如下:
1 | push ebp |
这些指令的功能是:在栈里保存EBP寄存器的内容,将ESP的值复制到EBP寄存器,然后修改栈的高度,以便为本函数的局部变量申请存储空间。
在函数执行期间,EBP寄存器不受函数运行的影响,它是函数访问局部变量
和函数参数
的基准值。虽然我们也使用ESP寄存器存储局部变量和运行参数,但是ESP寄存器的值总是会发生变化,使用起来并不方便。
函数尾声
函数在退出时,要做启动过程的反操作,释放栈中申请的内存,还原EBP寄存器的值,将代码控制权还原给调用者函数。
1 | mov esp,ebp |
在函数序言标志后,我们能够看到调用printf()
函数的指令call _printf
。
通过PUSH指令,程序把字符串推送入栈。这样,printf()函数就可以调用栈里的指针,即字符串”hello, world!”的地址。
在printf()函数调用结束以后,程序的控制流会返回到main()函数之中。此时,字符串地址(即指针)仍残留在数据栈之中。这个时候就需要调整栈指针(ESP寄存器里的值)来释放这个指针。
下一条语句是add esp,4
,把ESP寄存器(栈指针/Stack Pointer)里的数值加4
。
为什么要加上4呢?这是因为x86平台的内存地址使用32位
(即4字节)数据描述。同理,在x64平台上释放这个指针时,就应该add esp,8
。
因此,这条指令可以理解为POP 某寄存器
。只是本例的指令直接舍弃了栈里的数据而POP指令还要把寄存器里的值存储到既定寄存器。
某些编译器(如Intel C++编译器)不会使用ADD指令来释放数据栈,它们可能会用pop ecx
指令。例如,Oracle RDBMS(由Intel C++编译器编译)就会用POP ECX指令而不会用ADD指令。虽然POP ECX命令确实会修改ECX寄存器的值,但是它也同样释放了栈空间。
Intel C++编译器使用POP ECX指令另外一个理由就是,pop ecx
对应的OPCODE(1字节),比add esp
的OPCODE(3字节)要短。
上述C/C++程序里,printf()函数结束之后,main()函数会返回0(函数正常退出的返回码)。即main()函数的运算结果是0.
这个返回值是由指令xor eax,eax
计算出来的。
顾名思义,XOR
就是异或
。编译器通常采用异或运算指令,而不会使用mov eax,0
指令。主要是因为异或运算的OPCODE较短(2字节:5字节)。
也有一些编译器会使用sub eax,eax
指令把EAX寄存器置零,其中SUB代表减法运算。
总之,main()函数的最后一项任务是使EAX的值为零。
汇编列表中最后的操作指令是ret
,将控制权交给调用程序。通常它起到的作用就是将控制权交给操作系统,这部分功能由C/C++编译器的CRT实现。
GCC
接下来,我们使用GCC编译器来编译这个hello world程序。(书中的gcc版本为4.4.1),执行命令:gcc 1.c -o 1
然后使用反汇编工具IDA查看main()函数的具体情况。IDA所输出的汇编指令格式,与MSVC生成的汇编指令格式相同,它们都采用Intel语体显示汇编指令。
1 | main proc near |
GCC生成的汇编指令,与MSVC生成的结果基本相同。它首先把”hello, world”字符串在数据段的地址(指针)存储到EAX寄存器里,然后再把它存储到数据栈里。
其中值得注意的是还有开场部分的and esp,0fffffff0h
指令。它令栈地址(ESP的值)向16字节边界对齐(成为16的整数倍),属于初始化的指令。如果地址没有对齐,那么CPU可能需要访问再次内存才能获得栈内数据。虽然在8字节边界对齐就可以满足32位x86 CPU和64位x64 CPU的要求,但是主流编译器的编译规则规定程序访问的地址必须向16字节对齐(被16整除)
。人们还是为了提高指令的执行效率而特意拟定了这条编译规范。
sub esp,10h
将在栈中分配0x10 bytes
,即16字节。我们在后文看到,程序只会用到4字节空间。但是因为编译器对栈地址(ESP)进行了16字节对齐,所以每次都会分配16字节的空间。
而后,程序将字符串地址(指针的值)直接写入到数据栈。此处,GCC使用的是MOV指令;而MSVC生成的是PUSH指令。其中var_10是局部变量,用来向后面的printf()函数传递参数。
随即,程序调用printf()函数。
GCC和MSVC不同,除非人工指定优化选项,否则它会生成与源代码直接对应的mov eax,0
指令。但是,我们已经知道MOV指令的OPCODE肯定要比XOR指令的OPCODE长。
最后一条leave
指令,等效于mov esp,ebp
和pop ebp
两条指令。可见,这个指令调整了数据栈指针ESP,并将EBP的数值恢复到调用这个函数之前的初始状态。毕竟,程序段在开始部分就对EBP和ESP进行了操作,所以函数要在退出之前恢复这些寄存器的值。
GCC:AT&T语体
AT&T语体同样是汇编语言的显示风格。这种语体在UNIX之中较为常见。
我们使用GCC4.7.3编译源程序并启用GCC编译选项-fno-asynchronous-unwind-tables
,将会得到如下指令
1 | .LC0: |
Intel语体和AT&T语体的区别
- 运算表达式的书写顺序相反。
- Intel 格式:<指令> <目标> <源>
- AT&T 格式:<指令> <源> <目标>
如果我们将Intel语体的指令认为是等号(=)赋值,那么AT&T语体结构使用的就是右箭头(->)进行赋值。部分C标准函数的运算单元的书写格式确实是相同的,例如memcpy()、strcpy()。 - AT&T语体中,在寄存器名称之前使用百分号(%)标记,在立即数之前使用美元符号($)标记。AT&T语体使用圆括号,而Intel语体使用方括号。
- AT&T语体里,每个运算操作符都需要声明操作数据的类型:
-9:quad(64位)
-l:指代32位long类型数据
-w:指代16位word类型数据
-b:指代8位byte类型数据
x64
MSVC-x86-64
若用64位的MSVC编译上述程序,则会得到下述指令:
1 | ... |
在x86-64框架的CPU里,所有的物理寄存器都被扩展为64位寄存器。程序可通过R-
字头的名称直接调用整个64位寄存器。为了尽可能充分地利用寄存器、减少访问内存数据的次数,编译器会充分利用寄存器传递参数(fastcall约定)。也就是说,编译器会优先使用寄存器传递部分参数,再利用内存(数据栈)传递其余的参数。Win64的程序还会使用RCX
、RDX
、R8
、R9
这4个寄存器来存放函数参数。例如,printf()使用RCX寄存器传递参数,而没有像32位程序那样使用栈传递数据。
在x86-64硬件平台上,寄存器和指针都是64位的,存储于R-字头的寄存器里。但是出于兼容性的考虑,64位寄存器的低32位,也要能够担当32位寄存器的角色,才能运行32位程序。
在64位x86兼容的CPU中,RAX/EAX/AX/AL的对应关系如下:
main()函数的返回值是整数类型的零,但是出于兼容性和可移植性考虑,C语言的编译器仍将使用32位的零。换言之,即使是64位的应用程序,在程序结束时,EAX的值是零,而RAX不一定会是零。
此时,数据栈的对应空间里仍留有40字节的数据。这部分数据空间有个专有名词,即阴影空间
。我们将在后面的课程中详细介绍。
GCC 4.4.6 x64
使用64位的Linux的GCC编译器编译上述程序,可得到如下指令:
1 | .string "hello, world\n" |
Linux/BSD/Max OSX 系统中的应用程序,会优先使用RDI
、RSI
、RDX
、RCX
、R8
、R9
这6个寄存器传递函数所需的头6个参数,然后使用数据栈传递其余的参数。
因此,64位的GCC编译器使用EDI寄存器(寄存器的32位)存储字符串指针。EDI不过是RDI寄存器中地址位较低的32位地址部分。
为何GCC不直接使用整个RDI寄存器呢?
这是因为,64位汇编指令MOV在写入R-寄存器的低32位地址位的时候,即对E-寄存器进行写操作的时候,会同时清除R-寄存器中的高32位地址位。所以,MOV EAX,011223344H
能够对RAX寄存器进行正确的赋值操作,因为该指令会清除(清零)高地址位的内容。这种操作是出于空间方面的考虑,GCC进行的优化。
在调用printf()之前,程序清空了EAX寄存器,这是x86-64框架的系统规范决定的。在系统与应用程序接口规范中,EAX寄存器用来保存用过的向量寄存器(VECTOR REGISTERS)
。
GCC的其他特性
只要C语言代码里使用了字符串常量,编译器就会把这个字符串常量置于常量字段,以保证其内容不会发生变化。不过GCC有个有趣的特征:它可能会把字符串拆出来单独使用。
例如:
1 | #include <stdio.h> |
多数的C/C++编译器(包括MSVC编译器)会分配出两个直接对应的字符串,不过GCC 4.8.1的编译结果则更为可圈可点:
1 | f1 proc near |
在打印字符串”hello world”的时候,这两个词的指针地址实际上是前后相邻的。在调用puts()函数进行输出时,函数本身不知道它所输出的字符串分为两个部分。实际上我们在汇编指令清单中可以看到,这两个字符串没有被 切实 分开。
在f1()函数调用puts()函数时,它输出字符串“world”和外加结束符(数值为零的1个字节),因为puts()函数并不知道字符串可以和前面的字符连起来形成新的字符串。
GCC编译器会使用这种技术充分节省内存。
ARM
ARM 程序使用R0
寄存器传递函数返回值。
ARM 程序使用LR
寄存器(Link Register)存储函数结束之后的返回地址(RA/ Return Address)。x86 程序使用“栈”结构存储上述返回地址。BX LR
指令的作用是跳转到返回地址,即返回到调用者函 数,然后继续执行调用体 caller 的后续指令。
Keil 6/2013——未启用优化功能的ARM模式
可使用下面的命令,用Keil编译器把hello world程序编译为ARM指令集架构的汇编程序:armcc.exe --arm --c90 -O0 1.c
虽然armcc编译器生成的汇编指令清单同样采用了Intel语体,但是程序所使用的宏却很有ARM处理器的特色。
1 | .text:00000000 main |
可以看到,每条指令都占用4个字节,即ARM模式指令集。
第一句STMFD SP!,{R4,LR}
相当于x86的PUSH
指令。它把R4寄存器和LR(Link Register)寄存器的数值放到数据栈中。注意是相当于
,而非完全是
。这是因为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把某些指令强制变为位置无关代码/position-independent code
。在多数操作系统把程序加载在内存里的时候,OS分配给程序代码的内存地址是不固定的;但是程序内部既定指令和数据常量之间的偏移量是固定的(由二进制文件决定)。这种情况下,要在程序内部进行指令寻址(如跳转),就需要借助PC指针。ADR将当前指令的地址与字符串指针地址的减值(偏移量)传递给R0,程序借助PC指针可找到字符串指针的偏移地址,从而使操作系统确定字符串常量在内存里的绝对地址。
BL __2printf
调用printf()函数。BL实施的具体操作实际上是:
- 将下一条指令的地址,即地址0x以处
MOV R0,#0
的地址,写入LR寄存器。 - 然后将printf()函数的地址写入PC寄存器,以引导系统执行该函数。
当printf()完成工作之后,计算机必须知道返回地址,即它应当从哪里开始继续执行下一条指令。所以,每次使用BL指令调用其他函数之前,都要把BL指令的下一个指令的地址存储到LR寄存器。
这便是CISC(复杂指令集)处理器与RISC(精简指令集)处理器在工作模式上的区别。在拥有复杂指令集的x86体系里,操作系统可以利用栈存储返回地址。
另外,ARM模式跳转指令的寻址能力确实存在局限性。单条ARM模式的指令必须是32位/4字节,所以BL指令无法调用32位绝对地址或32位相对地址(容纳不下),它只能编入24位的偏移量。不过既然每条指令的OPCODE必须是4字节,则指令地址必须在4n处,即偏移地址的最后两位必定为零,可在OPCODE里省略。在处理ARM模式的转移指令时,处理器的将指令中的OPCODE的低24位向左移2位,形成26位偏移量,再进行跳转。由此可知,转移指令B/BL的跳转指令的目标地址,大约在当前位置的±32MB区间内。
下一条指令MOV R0,#0
将R0寄存器置零。Hello World的C代码中,主函数返回零。该指令把返回值写在R0寄存器中。
最后到了LDMFD SP!,{R4,PC}
这一条指令。它与STMFD成对出现,做的工作相反。它将栈中的数值取出,依次赋值给R4和PC,并且会调整栈指针SP。可以说这条指令与POP指令很相似。main()函数的第一条指令就是STMDF指令,它将R4寄存器和LR寄存器存储在栈中。main()函数结尾处使用LDMFD指令,其作用是把栈里存储的PC的值和R4寄存器的值恢复回来。
前面提到过,程序在调用其他函数之前,必须把返回地址保存在LR寄存器里。因为在调用printf()函数之后LR寄存器的值会发生改变,所以主函数的第一条指令就要负责保存LR寄存器里的值。在被调用的函数结束后,LR寄存器中存储的值会被赋值给PC,以便程序返回调用者函数继续运行。当C/C++的主函数main()结束之后,程序的控制权权将返回给OS loader,或者CRT中的某个指针,或者作用相似的其他地址。
数据段中的DCB是汇编语言中定义ASCII字符数组/字节数组的指令,相当于x86汇编中的DB指令。
Thumb模式下、未开启优化选项的Keil
以Thumb模式编译前面的源代码:armcc --thumb --c90 -O0 1.c
会得到如下指令:
1 | .text:00000000 main |
Thumb模式下的每条指令,都对应着2个字节/16位的OPCODE,这是Thumb模式的程序的特征。但是,Thumb模式的跳转指令BL似乎
占用了4个字节的OPCODE,实际上它是由2条指令组成的。单条16位的OPCODE传递的信息太有限,不足以向被调用函数传递PC和偏移量信息。所以,上面的BL指令分为2条16位OPCODE。第一条16位指令可以华北偏移量的高10位,第二条指令可以传递偏移量的低11位。而Thumn模式的OPCODE都是固定的2个字节长,目标地址位最后一个位必定是0(Thumb模式的OPCODE的房奴地址从头再来必须是2n),因而会被省略。在执行Thunm模式的转移指令时,处理器会将目标地址左移1位,弄成2位的偏移量。即Thumb的BL跳转指令将无法跳到奇数地址,而且跳转指令仅仅能偏移到当前地址±2MB(22位有符号整数的取值区间)附近的范围之内。
程序主函数的其他指令,PUSH和POP工作方式与STMFD/LDMFD相似。虽然表面上看不出来,但是实际上它们也会调整SP指针,ADR指令与前文的作用相同。而MOVS指令负责把返回值(R0寄存器)置零。
ARM模式下、开启优化选项的Xcode
由于Xcode 4.6.3 在不启用优先选项的时候会产生大量冗余代码,所以我们开启优化(-O3)让其生成最优的代码。
1 | __text:000028C4 _helloworld |
STMFD/LDMFD不再重复。
第一个MOV指令将字符串“hello world”的偏移量(0x1686)赋值到R0寄存器。
根据Apple ABI函数接口规范,R7寄存器担当帧指针(frame pointer)
寄存器。
MOVT R0,#0
将0写入R0寄存器的高16位地址
。的ARM模式里,常规的MOV指令只能操作寄存器的低16位地址,而单条ARM指令最多是32位/4字节。当然,寄存器之间传递数据没有这种限制。所以,对寄存器的高位(第16位到第31位)进行赋值操作的MOVT指令应运而生。然而此处的这条MOVT指令可有可无,因为在执行下一条指令MOV R0,#0x1686
时,R0寄存器的高16位本来就会被清零。这也许是编译器智能方面的缺陷。
ADD R0,PC,R0
将PC和R0进行求和,计算得出字符串的绝对地址。回想前面的位置无关代码
,我们知道程序运行之后的起始地址并不固定,此处,程序对这个地址进行了必要的修正。
然后,程序通过BL指令调用puts()函数,而没有像前面那样调用printf()函数,这是因为printf()函数与puts()函数作用基本相同,在不涉及控制符的情况下,puts()函数运行速度更快。
Thumb-2 模式下、开启优化选项的Xcode(LLVM)
默认情况下,Xcode 4.6.3会启用优化模式,并以Thumb-2模式编译源程序。
1 | __text:00002B6C _hello_world |
前面提到过,thumb模式的BLX和BL指令以2个16位指令的形式成对出现。在Thumb-2模式下,BL和BLX指令对应的伪CPCODE有明显的32位指令特征,其对应的OPCODE都以0xFX
或者0xEx
开头。
在显示Thumb和Thumb-2模式程序的OPCODE时,IDA会以两个字节为单位对调。
在显示ARM模式的指令时,IDA以字节为单位、依次逆序显示其OPCODE。这是字节序的排版差异。
简要地说,在IDA显示ARM平台指令时,其显示顺序为:
- ARM及ARM64模式的指令,OPCODE以
4-3-2-1
的顺序显示。 - Thumb模式的指令,OPCODE以
2-1
的顺序显示。 - Thumb-2模式的16位指令,其OPCODE以
2-1-4-3
的顺序显示。
在IDA中,我们可看到MOVW/MOVT.W/BLX指令都是以0xFx
开头。
MOVW R0,#0x13D8
将立即数写到R0寄存器的低16位地址,同时清除寄存器的高16位。
MOVT.W R0,#0
的作用与前面一个例子中Thumb模式的MOVT的作用相同,只不过此处是Thumb-2的指令。
在这两个例子中,最显著的区别是Thumb-2模式BLX
指令。此处的BLX与Thumb模式的BL指令有着根本的区别。它不仅将puts()函数的返回地址RA存入了LR寄存器,将控制权交给了puts()函数,而且还把处理器从Thumb/Thumb-2模式调整为ARM模式;它同时也负责在函数退出时把处理器的运行模式进行还原。总之,它同时实现了模式转换和控制权交接的功能,相当于执行了下面的ARM模式的指令:
1 | __symbolstub1:00003FEC _puts ; CODE XREF: _hello_world+E |
有些同学可能会问,此处为什么不直接调用puts()函数?
直接调用的空间开销更大。
几乎所有的程序都会用到动态链接库,详细说来Windows的程序基本上都会用到DLL文件、Linux程序差不多都会用到.SO文件、MacOSX系统的程序多数也会用到.dylib文件。常用的库函数通常都放在动态链接库里,本例用到的标准C函数——puts()函数也不例外。
可执行的二进制文件(Windows的PE可执行文件,ELF或Mach-O)都有一个输入表段(import section)
。输入表段声明了该程序需要通过模块加载的符号链接(函数名称和全局变量),并且含有外部模块的名称等信息。
在操作系统执行二进制文件的时候,它的加载程序(OS loader)会依据这个表段加载程序所需要的模块。在它加载该程序主模块的时候,对导入的符号链接进行枚举,逐一分配符号链接的地址。
在本例中,_imp_puts
是操作系统加载程序为hello world程序提供的外部函数地址,属于32位变量。程序只需要使用LDR指令取出这个变量,并且将它赋值给PC寄存器,就可以调用puts()函数。
可见,一次性地给每个符号链接分配独立的内存地址,可以大幅度地减少OS loader在加载方面的耗时。
前面指出,如果只能靠单条指令、而不借助内存的读取操作,CPU就无法把32位的数值(指针或立即数)赋值给寄存器。所以,可以建立一个以ARM模式运行的独立函数,让它专门处理动态链接库的接口问题。此后Thumb模式的代码就可以跳转到这个处理接口功能的单指令专用函数,这种专用函数称为(运行模式的)形实转换函数(thunk function)
前面有一个ARM模式的编译例子,它就使用BL指令实现相同功能的形实转换函数。但是那个程序使用的指令是BL而不是BLX,可见处理器并没有切换运行模式。
形实转换函数(thunk function)的由来
形实转换函数,是形参与实参互相转换的函数
的缩写。在编译过程中,为满足当时的过程(函数)调用约定,当形参为表达式时,编译器都会产生thunk,把返回值的地址传递给形参。
微软和IBM都对thunk
一词有定义,将从16位到32位
和从32位到16位
的转变叫作“thunk”。
ARM64
GCC
使用GCC 4.8.1 将上述代码编译为ARM64程序,可得到如下代码:
1 | 0000000000400590 <main>: |
一方面,ARM64的CPU只可能运行于ARM模式、不可运行于Thumb或Thumb-2模式,所以它必须使用32位的指令;另一方面,64位平台的寄存器数量也翻了一翻,拥有了64个X-
字头的寄存器。当然,程序还可以通过W-
字头的名称直接访问寄存器的低32位空间。
上述程序的STP(Store Pair)
指令把两个寄存器(即X29和X30)的值存储到栈里。虽然这个指令实际上可以把这对数值存储到内存中的做任意地址,但是由于该指令明确了SP寄存器,所以它就是通过栈来存储这对数值。ARM64平台的寄存器都是64位寄存器,每个寄存器可存储8字节数据,所以程序要分配16字节的空间来存储两个寄存器的值。
这条指令中的感叹号标志,意味着其标注的运算会被优先执行。即,该指令先把SP的值减去16,在此之后再把两个寄存器的值写在栈里。这属于预索引(pre-index)
指令。此外还有延迟索引(post-index)
指令与之对应。两者的区别,我们会在以后的章节中说明。
以更为易懂的x86指令来解读的话,这条指令相当于PUSH X29
和PUSH X30
两条指令。在ARM64平台上,X29寄存器是帧指针FP
,X30起着LR
的作用。所以这两个寄存器在函数的序言和尾声处成对出现。
第二条指令把 SP 的值复制给 X29,即 FP。这用来设置函数的栈帧。
ADRP
和 ADD
指令相互配合,把“Hello!”字符串的指针传递给 X0 寄存器,继而充当函数参数传递给被调用函数。受到指令方面的限制,ARM 无法通过单条指令就把一个较大的立即数赋值给寄存器。所以,编译器要组合使用数条指令进行立即数赋值。第一条 ADRP 把 4KB 页面的 地址传递给 X0,而后第二条 ADD 进行加法运算并给出最终的指针地址。在以后的章节中我们会详细讨论。
0x400000 + 0x648 = 0x400648。这个数是位于.rodata 数据段的 C 字符串“Hello!”的地址。
接下来,程序使用 BL
指令调用 puts()函数。
MOV
指令用来给 W0 寄存器置零。W0 是 X0 寄存器的低 32 位。main()函数通过 X0 寄存器来传递函数返回值 0。
在此之后,LDP (Load Pair)
指令还原 X29 和 X30 寄存器的值。此处的这条指令没有感叹号标记,这意 味着它将率先进行赋值操作,而后再把 SP 的值与 16 进行求和运算。这属于延时索引(post-index)指令。
RET
指令是 ARM64 平台的特色指令。虽然它的作用与 BX LR 相同,但是它实际上是按照寄存器的名 称进行跳转的(默认使用 X30 寄存器指向的地址),通过底层指令提示 CPU 此处为函数的返回指令、不属 于普通转移指令的返回过程。RET 指令经过了面向硬件的优化处理,它的执行效率较高。
MIPS
在 MIPS 指令里,寄存器有两种命名方式。一种是以数字命名($0~$31
),另一种则是以伪名称(pseudoname)命名($V0~VA0
,依此类推)。在 GCC 编译器生成的汇编指令中,寄存器都采用数字方式命名。
存储函数返回值的寄存器都是$2
(即$V0
)。LI
指令 是英文词组“Load Immediate(加载立即数)”的缩写。J
和JR
指令都属于跳转指令,它们把执行流递交给调用者函数,跳转到$31
即$RA 寄存器中的地址。这个寄存器相当于的 ARM 平台的 LR 寄存器。分支(转移)指令延迟槽 (Branch delay slot)
的现象。简单地说,不管分支(转移)发生与否,位于分支指 令后面的一条指令(在延时槽里的指令),总是被先于分支指令提交。这是 RISC 精简指令集的一种特例, 我们不必在此处深究。总之,转移指令后面的这条赋值指令实际上是在转移指令之前运行的。
全局指针 Global Pointer
全局指针
是 MIPS 软件系统的一个重要概念。们已经知道,每条 MIPS 指令都是 32 位的指令,所以 单条指令无法容纳 32 位地址(指针)。这种情况下 MIPS 就得传递一对指令才能使用一个完整的指针。
从另一方面来说,单条指令确实可以容纳一组由寄存器的符号、有符号的 16 位偏移量(有符号数)。 因此任何一条指令都可以构成的表达式,访问某个取值范围为“寄存器−32768”~“寄存器+32767”之 间的地址(总共 69KB)。为了简化静态数据的访问操作,MIPS 平台特地为此保留了一个专用的寄存器, 并且把常用数据分配到了一个大小为 64KB 的内存数据空间里。这种专用的寄存器就叫作“全局指针”寄存器。它的值是一个指针,指向 64KB(静态)数据空间的正中间。而这 64KB 空间通常用于存储全局变量,以及 printf()这类由外部导入的的外部函数地址。GCC 的开发团队认为:获取函数地址这类的操作,应 当由单条指令完成;双指令取址的运行效率不可接受。
在 ELF 格式的文件中,这个 64KB 的静态数据位于.sbss 和.sdata 之中。“.sbss”是 small BSS(Block Started by Symbol)的缩写,用于存储非初始化的数据。“.sdata”是 small data 的缩写,用于存储有初始化数值的数据。 根据这种数据布局编程人员可以自行决定把需要快速访问的数据放在.sdata、还是.sbss 数据段中。
有多年工作经验的人员可能会把全局指针和 MS-DOS 内存(参见本书第 49 章)、或者 MS-DOS 的 XMS/EMS 内存管理器联系起来。这些内存管理方式都把数据的内存存储空间划分为数个 64KB 区间。
全局指针并不是 MIPS 平台的专有概念。至少 PowerPC 平台也使用了这一概念。
Optimizing GCC
Optimizing GCC 4.4.5 汇编输出
1 | $LC0: |
主函数序言启动部分的指令初始化了全局指针寄存器GP寄存器的值,并且把它指向 64KB数据段的正中央。
同时,程序把RA寄存器的值存储于本地数据栈。
它同样使用puts()函数替代了printf()函数。而puts()函数的地址, 则通过LW(Load Word)
指令加载到了$25 寄存器。
此后,字符串的高 16 位地址和低 16 位地址分别由LUI(Load Upper Immediate)
和ADDIU(Add Immediate Unsigned Word)
两条指令加载到$4 寄存器。LUI中的Upper一词说明它将数据存 储于寄存器的高 16 位。与此相对应,ADDIU则把操作符地址处的低 16 位进行了求和运算。ADDIU指令位于JALR 指令之后,但是会先于后者运行
。$4 寄存器其实就是$A0 寄存器,在调用函数时传递第一个参数。JALR (Jump and Link Register)
指令跳转到$25 寄存器中的地址,即 puts()函数的启动地址,并且把下一 条 LW 指令的地址存储于 RA 寄存器。
可见,MIPS 系统调用函数的方法与 ARM 系统相似。需要注意的是, 由于分支延迟槽效应
,存储于 RA 寄存器的值并非是已经运行过的、“下一条”指令的地址,而是更后面那 条(延迟槽之后的)指令的地址。所以,在执行这条 JALR 指令的时候,写入 RA 寄存器的值是 PC+8,即 ADDIU 后面的那条 LW 指令的地址。
第 19 行的 LW (Load Word)
指令,用于把本地栈中的 RA 值恢复回来。请注意,这条指令并不位于被调用函数的函数尾声。
第 22 行的MOVE
指令把$0($ZERO)的值复制给$2($V0)。MIPS 有一个常量寄存器,它里面的值是常量 0。使用$0
寄存器提供数值0。另外一个值得注意的现象:在 MIPS 系统之中,没有在寄 存器之间复制数值的(硬件)指令。确切地说,MOVE DST, SRC
是通过加法指令ADD DST,SRC, $ZERO
变相实现的,即DST=SRC+0
,这两种操作等效。由此可见,MIPS 研发人员希望尽可能地复用 opcode,从 而精简 opcode 的总数。然而这并不代表每次运行 MOVE 指令时 CPU 都会进行实际意义上的加法运算。CPU 能够对这类伪指令进行优化处理,在运行它们的时候并不会用到 ALU(Arithmetic logic unit)。
第 24 行的J
指令会跳转到 RA 所指向的地址,完成从被调用函数返回调用者函数的操作。还是由于分支延迟槽效应
,其后的ADDIU指令会先于J指令运行,构成函数尾声。
Opimizing GCC4.4.5(IDA)
再来看看 IDA 生成的指令清单,熟悉一下各寄存器的伪名称。
1 | .text:00000000 main: |
程序中保存 puts()函数地址的寄存器叫作$T9
寄存器。这类 T-开头的寄存器叫作“临时”寄存器,用于保存代码里的临时值。调用者函数负责保存这些寄存器的数值(caller-saved),因为它有可能会被被调用的函数重写。
Non-pimizing GCC 汇编输出
1 | $LC0: |
未经优化处理的 GCC 输出要详细得多。此处,我们可以观察到程序把FP
当作栈帧的指针来用,而且它还有3个 NOP(空操作)
指令。在这3个空操作指令中,第二个、第三个指令都位于分支跳转指令之后。
笔者个人认为(虽然目前无法肯定),由于这些地方都存在分支延迟槽,所以 GCC 编译器会在分支语句之后都添加NOP指令。不过,在启用它的优化选项之后,GCC 可能就会删除这些 NOP 指令。所以,此 处仍然存在这些 NOP 指令。
Non-pimizing GCC (IDA)
1 | .text:00000000 main: |
在程序的第 15 行出现了一个比较有意思的现象——IDA 识别出了 LUI/ADDIU 指令对,把它们显示为 单条的伪指令LA(Load address)。那条伪指令占用了8个字节!这种伪指令(即“宏”)并非真正的MIPS 指令。通过这种名称替换,IDA 帮助我们这对指令的作用望文思义。
NOP 的显示方法也构成了它的另外一种特点。因为 IDA 并不会自动地把实际指令匹配为 NOP 指令, 所以位于第 22 行、第 26 行、第 41 行的指令都是OR $AT, $ZERO
。表面上看,它将保留寄存器$AT 的 值与 0 进行或运算。但是从本质上讲,这就是发送给 CPU 的 NOP 指令。MIPS 和其他的一些硬件平台的 指令集都没有单独的 NOP 指令。
栈帧
本例使用寄存器来传递文本字符串的地址。但是它同时设置了局部栈,这是为什么呢?由于程序在调用 printf()函数的时候由于程序必须保存 RA 寄存器的值和 GP 的值,故而此处出现了数据栈。如果此函数是叶函数
,它有可能不会出现函数的序言和尾声。
GDB分析
编译
使用如下命令编译我们的文件gcc hello.c -O3 -o hello
将会在同目录下生成hello文件。
使用GDB调试
- 执行命令:
gdb hello
开始调试。 - 执行命令:
b main
,在main函数处下断点。 - 执行命令:
run
,运行程序,程序会在断点处停止。 - 执行命令:
set step-mode on
,开启step-mode 模式。 - 执行命令:
disas
查看当前函数的反汇编指令。 - 执行命令:
s
,执行一条汇编指令。 - 执行命令:
x/s address
,查看某地址处的数据,并以s(tring)
格式显示。
1 | root@kali:~/Desktop# gdb hw |
使用radare2分析
- 查看程序中的字符串:
rabin2 -z hello
; - 使用
r2
命令分析:
1 | root@kali:~/Desktop# r2 hw |
作业
- 学习笔记
- 熟悉GDB和r2工具。