6.1 ARM汇编基础

对于很多iOS开发者来说,ARM汇编是一门全新的语言;如果你是计算机专业科班出身,应该已经对汇编语言有了初步的印象,只是对于很多人来说,大学期间的汇编语言课简直跟天书一样深奥,它在我们心里埋下了恐惧的种子,仿佛一提到汇编语言,它就会像紧箍咒一样勒紧我们的头,让我们疼痛不已。汇编语言真的有这么难?是,因为汇编的语法晦涩难懂;但另一方面,毕竟它只是一门语言,跟英语一样,熟能生巧。

我们一般的工作中与汇编打交道的机会并不多,如果不刻意练习,陡然面对时必然掌握不了,所以会觉得它很难。不过归根到底还是投入的时间和精力是否足够的问题——好了,iOS逆向工程给学习ARM汇编提供了一个绝佳的条件——在逆向一个功能时,往往需要分析大量ARM汇编代码,并把它们翻译成高级语言,试图重新实现这个功能;虽然暂时还不需要写汇编代码,但大量的阅读必然能加深我们对这门语言的理解。如果想在iOS逆向工程这条路上走下去,ARM汇编是必须掌握的语言,也是一定能够掌握的语言;跟英语类似,ARM汇编的基本概念相当于26个字母和音标;指令相当于单词,它们的变种相当于单词的各种形态;调用规则相当于语法,定义句子之间的联系。接下来,让我们一步步地深入。

6.1.1 基本概念

如果要完整地介绍ARM汇编,ARM公司的用户手册已经做得足够好了。笔者对ARM汇编也只是略知一二,肯定没有用户手册那么全面,但对于iOS逆向工程初学者来说,这些知识足以应对,适度就好。随着iPhone 5s的推出,苹果引入了性能强大的64位处理器,但本书前半部分介绍的大多数工具对64位处理器的支持都不太好,因此后半部分的内容仍以32位处理器为准,但思路是通用的。

1.寄存器、内存和栈

在高级语言,如Objective-C、C和C++里,操作对象是变量;在ARM汇编里,操作对象是寄存器(register)、内存和栈(stack)。其中,寄存器可以看成CPU自带的变量,它们的数量一般是很有限的;当需要更多变量时,就可以把它们存放在内存中;不过,数量上去了,质量也下来了,对内存的操作比对寄存器的操作要慢得多。

栈其实也是一片内存区域,但它具有栈的特点:先进后出。ARM的栈是满递减(Full Descending)的,向下增长,也就是开口朝下,新的变量被存放到栈底的位置;越靠近栈底,内存地址越小,如图6-1所示。

图6-1 ARM的栈

一个名为“stack pointer”(简称SP)的寄存器保存栈的栈底地址,称为栈地址;可以把一个变量给入(push)栈以保存它的值,也可以让它出(pop)栈,恢复变量的原始值。在实际操作中,栈地址会不断变化;但是在执行一块代码的前后,栈地址应该是不变的,不然程序就要出问题了。为什么?举例说明如下:


static int global_var0;
static int global_var1;
…
void foo(void)
{
    bar();
    // 其他操作;
}

在上面4行代码中,假设函数foo()用到了A、B、C、D四个寄存器;foo()内部调用了bar(),假设bar()用到了A、B、C三个寄存器。因为2个不同的函数用到了3个相同的寄存器,所以bar()在开始执行前需要将3个寄存器中原来的值入栈以保存其原始值,在结束执行前将它们出栈以恢复其原始值,保证foo()能够正常执行。用伪汇编代码表示如下:


// foo()函数
foo:
    // 将A、B、C、D入栈,保存它们的原始值  
    入栈    {A, B, C, D}  
    // 使用A ~ D 
    移动    A, #1        // A = 1  
    移动    B, #2        // B = 2  
    移动    C, #3        // 你猜猜这行是什么意思?
    调用    bar   
    移动    D, global_var0      
    // global_var1 = A + B + C + D  
    相加    A, B         // A = A + B,注意此处A的值
    相加    A, C         // A = A + C,还要注意此处A的值
    相加    A, D         // 你再猜猜这行是什么意思?
    移动    global_var1, A
    // 将A、B、C、D出栈,恢复它们的原始值
    出栈    {A-D}   
    返回  
// bar()函数
bar:     
    // 将A、B、C入栈,保存它们的原始值A == 1,B == 2,C == 3
    入栈    {A-C}      
    // 使用A ~ C   
    移动     A, #2       // 还需要注释吗?
    移动     B, #5
    移动     C, A
    相加     C, B        // C = 7
    // global_var0 = A + B + C (== 2 * C)  
    相加     C, C
    移动     global_var0, C        // A = 2,B = 5,C = 14
    // 现在你知道入栈和出栈的重要意义了吗?
    出栈     {A-C} 
    返回

简单解释一下这段伪代码:foo()先将A、B、C分别设置为1、2、3,然后调用bar(),bar()改变了A、B、C的值,并将全局变量global_var0的值设置为ABC三者之和。如果把此时的A、B、C直接用于foo(),计算出的另一个全局变量global_var1的值就是错的,因此在bar()执行前先要让A、B、C入栈,保存它们的值,执行完成后再出栈,使得foo()能够得到正确的global_var1。注意一点,出于同样的目的,foo()在执行前后也对A、B、C、D执行了入栈和出栈操作,所以foo()的调用者也能够正常工作。

2.特殊用途的寄存器

ARM处理器中的部分寄存器有特殊用途,如下所示:


R0-R3           传递参数与返回值
R7              帧指针,指向母函数与被调用子函数在栈中的交界
R9              在iOS 3.0以前被系统保留
R12             内部过程调用寄存器,dynamic linker会用到它
R13             SP寄存器
R14             LR寄存器,保存函数返回地址
R15             PC寄存器

因为现在还没有开始自己写汇编代码,所以对上述知识有简单了解就足够了。

3.分支跳转与条件判断

处理器中名为“program counter”(简称PC)的寄存器用于存放下一条指令的地址。一般情况下,计算机一条接一条地顺序执行指令,处理器执行完一条指令后将PC加1,让它指向下一条指令,如图6-2所示。

处理器顺序执行指令1到指令5,稀松平常、沉闷无聊。但是如果把PC的值变一变,指令的执行顺序就完全不同了,如图6-3所示。

指令的执行顺序被打乱,变成指令1、指令5、指令4、指令2、指令3、指令6,光怪陆离、百花齐放。这种“乱序”的学名叫“分支”(branch),或者“跳转”(jump),它使循环和subroutine成为可能,例如:


// endless()函数
endless: 
    操作    操作数1, 操作数2
    分支    endless
    返回    // 死循环,执行不到这里啦!

在实际情况中,满足一定条件才得以触发的分支是最实用的,这种分支称为条件分支。if else和while都是基于条件分支实现的。在ARM汇编中,分支的条件一般有4种:

·操作结果为0(或不为0);

·操作结果为负数;

·操作结果有进位;

·运算溢出(比如两个正数相加得到的数超过了寄存器位数)。

图6-2 顺序执行指令

图6-3 乱序执行指令

这些条件的判断准则(flag)存放在程序状态寄存器(Program Status Register,PSR)中,数据处理相关指令会改变这些flag,分支指令再根据这些flag决定是否跳转。下面的伪代码展示了一个for循环:


for: 
    相加    A, #1
    比较    A, #16
    不为0则跳转到for

此循环将A和#16作比较,如果两者不相等,则将A加1,继续比较。如果两者相等,则不再循环,继续往下执行。

6.1.2 ARM/THUMB指令解读

ARM处理器用到的指令集分为ARM和THUMB两种;ARM指令长度均为32bit,THUMB指令长度均为16bit。所有指令可大致分为3类,分别是数据操作指令、内存操作指令和分支指令。

1.数据操作指令

数据操作指令有以下2条规则:

1)所有操作数均为32bit;

2)所有结果均为32bit,且只能存放在寄存器中。

总的来说,数据操作指令的基本格式是:


op{cond}{s} Rd, Rn, Op2

其中,“cond”和“s”是两个可选后缀;“cond”的作用是指定指令“op”在什么条件下执行,共有下面17种条件:


EQ              结果为0(EQual to 0)
NE              结果不为0(Not Equal to 0)
CS              有进位或借位(Carry Set)
HS              同CS(unsigned Higher or Same)
CC              没有进位或借位(Carry clear)
LO              同CC(unsigned LOwer)
MI              结果小于0(MInus)
PL              结果大于等于0(PLus)
VS              溢出(oVerflow Set)
VC              无溢出(oVerflow Clear)
HI              无符号比较大于(unsigned HIgher)
LS              无符号比较小于等于(unsigned Lower or Same)
GE              有符号比较大于等于(signed Greater than or Equal)
LT              有符号比较小于(signed Less Than)
GT              有符号比较大于(signed Greater Than)
LE              无符号比较小于等于(signed Less than or Equal)
AL              无条件(ALways,默认)

“cond”的用法很简单,例如:


比较 R0, R1
移动 GE R2, R0
移动 LT R2, R1

比较R0和R1的值,如果R0大于等于R1,则R2=R0;否则R2=R1。

“s”的作用是指定指令“op”是否设置flag,共有下面4种flag:


N(Negative)
如果结果小于0则置1,否则置0;
Z(Zero)
如果结果是0则置1,否则置0;
C(Carry)
对于加操作(包括CMN)来说,如果产生进位则置1,否则置0;对于减操作(包括CMP)来说,Carry相当于Not-Borrow,如果产生借位则置0,否则置1;对于有移位操作的非加/减操作来说,C置移出值的最后一位;对于其他的非加/减操作来说,C的值一般不变;
V(oVerflow)
如果操作导致溢出,则置1,否则置0。

需要注意一点,C flag表示无符号数运算结果是否溢出;V flag表示有符号数运算结果是否溢出。

数据操作指令可以大致分为以下4类:

·算术操作


ADD R0, R1, R2          ; R0 = R1 + R2
ADC R0, R1, R2          ; R0 = R1 + R2 + C(arry)
SUB R0, R1, R2          ; R0 = R1 - R2
SBC R0, R1, R2          ; R0 = R1 - R2 - !C
RSB R0, R1, R2          ; R0 = R2 - R1
RSC R0, R1, R2          ; R0 = R2 - R1 - !C

算术操作中,ADD和SUB为基础操作,其他均为两者的变种。RSB是“Reverse SuB”的缩写,仅仅是把SUB的两个操作数调换了位置而已;以“C”(即Carry)结尾的变种代表有进位和借位的加减法,当产生进位或没有借位时,将Carry flag置1。

·逻辑操作


AND R0, R1, R2          ; R0 = R1 & R2
ORR R0, R1, R2          ; R0 = R1 | R2
EOR R0, R1, R2          ; R0 = R1 ^ R2
BIC R0, R1, R2          ; R0 = R1 &~ R2
MOV R0, R2              ; R0 = R2
MVN R0, R2              ; R0 = ~R2

逻辑操作指令没什么多说的,它们的作用都已经用C操作符表示出来了,大家应该很熟悉;但是C操作符里的移位操作并没有对应的逻辑操作指令,因为ARM采用了桶式移位,共有以下4种指令:


LSL          逻辑左移,见图6-4

图6-4 逻辑左移


LSR          逻辑右移,见图6-5

图6-5 逻辑右移


ASR          算术右移,见图6-6

图6-6 算术右移


ROR          循环右移,见图6-7

图6-7 循环右移

·比较操作


CMP R1, R2           ; 执行R1 - R2并依结果设置flag
CMN R1, R2           ; 执行R1 + R2并依结果设置flag
TST R1, R2           ; 执行R1 & R2并依结果设置flag
TEQ R1, R2           ; 执行R1 ^ R2并依结果设置flag

比较操作其实就是改变flag的算术操作或逻辑操作,只是操作结果不保留在寄存器里而已。

·乘法操作


MUL R4, R3, R2               ; R4 = R3 * R2
MLA R4, R3, R2, R1           ; R4 = R3 * R2 + R1

乘法操作的操作数必须来自寄存器。

2.内存操作指令

内存操作指令的基本格式是:


op{cond}{type} Rd, [Rn,?Op2]

其中Rn是基址寄存器,用于存放基地址;“cond”的作用与数据操作指令相同;“type”指定指令“op”操作的数据类型,共有4种:


B(unsigned Byte)
无符号byte(执行时扩展到32bit,以0填充);
SB(Signed Byte)
有符号byte(仅用于LDR指令;执行时扩展到32bit,以符号位填充);
H(unsigned Halfword)
无符号halfword(执行时扩展到32bit,以0填充);
SH(Signed Halfword)
有符号halfword(仅用于LDR指令;执行时扩展到32bit,以符号位填
充)。

如果不指定“type”,则默认数据类型是word。

ARM内存操作基础指令只有两个:LDR(LoaD Register)将数据从内存中读出来,存到寄存器中;STR(STore Register)将数据从寄存器中读出来,存到内存中。两个指令的使用情况如下:

·LDR


LDR Rt, [Rn {, #offset}]        ; Rt = *(Rn {+ offset}),{}代表可选
LDR Rt, [Rn, #offset]!          ; Rt = *(Rn + offset); Rn = Rn + offset
LDR Rt, [Rn], #offset           ; Rt = *Rn; Rn = Rn + offset

·STR


STR Rt, [Rn {, #offset}]      ; *(Rn {+ offset}) = Rt
STR Rt, [Rn, #offset]!        ; *(Rn {+ offset}) = Rt; Rn = Rn + offset
STR Rt, [Rn], #offset         ; *Rn = Rt; Rn = Rn + offset

此外,LDR和STR的变种LDRD和STRD还可以操作双字(Doubleword),即一次性操作2个寄存器,其基本格式如下:


op{cond} Rt, Rt2, [Rn {, #offset}]

其用法与原型类似,如下:

·STRD


STRD R4, R5, [R9,#offset]        ;  *(R9 + offset)= R4; *(R9 + offset + 4)= R5

·LDRD


LDRD R4, R5, [R9,#offset]       ;  R4 = *(R9 + offset); R5 = *(R9 + offset + 4)

除了LDR和STR外,还可以通过LDM(LoaD Multiple)和STM(STore Multiple)进行块传输,一次性操作多个寄存器。块传输指令的基本格式是:


op{cond}{mode} Rd{!}, reglist

其中Rd是基址寄存器,可选的“!”指定Rd变化后的值是否写回Rd;reglist是一系列寄存器,用大括号括起来,它们之间可以用“,”分隔,也可以用“-”表示一个范围,比如,{R4–R6,R8}表示寄存器R4、R5、R6、R8;这些寄存器的顺序是按照自身的编号由小到大排列的,与大括号内的排列顺序无关。

需要特别注意的是,LDM和STM的操作方向与LDR和STR完全相反:LDM是把从Rd开始,地址连续的内存数据存入reglist中,STM是把reglist中的值存入从Rd开始,地址连续的内存中。此处特别容易混淆,大家一定要注意!

“cond”的作用与数据操作指令相同。“mode”指定Rd值的4种变化规律,如下所示:


IA(Increment After)
每次传输后增加Rd的值;
IB(Increment Before)
每次传输前增加Rd的值;
DA(Decrement After)
每次传输后减少Rd的值;
DB(Decrement Before)
每次传输前减少Rd的值。

这是什么意思呢?下面以LDM为代表,举一个简单的例子,相信大家一看就明白了。在图6-8中,R0指向的值是5。

图6-8 块传输指令模拟环境

执行以下命令后,R4、R5、R6的值分别变成:


foo():
    LDMIA R0, {R4 – R6}                 ; R4 = 5, R5 = 6, R6 = 7
    LDMIB R0, {R4 – R6}                 ; R4 = 6, R5 = 7, R6 = 8
    LDMDA R0, {R4 – R6}                 ; R4 = 5, R5 = 4, R6 = 3
    LDMDB R0, {R4 – R6}                 ; R4 = 4, R5 = 3, R6 = 2

STM指令的作用方式与此类似,不再赘述。再次提醒,LDM和STM的操作方向与LDR和STR完全相反,切记切记!

3.分支指令

分支指令可以分为无条件分支和条件分支两种。

·无条件分支


B Label         ; PC = Label
BL Label        ; LR = PC – 4; PC = Label
BX Rd           ; PC = Rd并切换指令集

无条件分支很简单,举下面一个小例子就会了解:


foo():
        B Label                 ; 跳转到Label处往下执行
        ......                  ; 得不到执行
Label:
        ......

·条件分支

条件分支的cond是依照6.2.1节提到的4种flag来判断的,它们的对应关系如下:


cond            flag
EQ              Z = 1
NE              Z = 0
CS              C = 1
HS              C = 1
CC              C = 0
LO              C = 0
MI              N = 1
PL              N = 0
VS              V = 1
VC              V = 0
HI              C = 1 & Z = 0
LS              C = 0 | Z = 1
GE              N = V
LT              N !=  V
GT              Z = 0 & N = V
LE              Z = 1 | N != V

在条件分支指令前会有一条数据操作指令来设置flag,分支指令根据flag的值来决定代码走向,举例如下:


Label:
    LDR R0, [R1], #4
    CMP R0, 0           ; 如果R0 == 0,Z = 1;否则Z = 0
    BNE Label          ; Z == 0则跳转

4.THUMB指令

THUMB指令集是ARM指令集的一个子集,每条THUMB指令均为16bit;因此THUMB指令比ARM指令更节省空间,且在16位数据总线上的传输效率更高。有得必有失,除了“b”之外,所有THUMB指令均无法条件执行;桶式移位无法结合其他指令执行;大多数THUMB指令只能使用R0~R7这8个寄存器等。相对于ARM指令,THUMB指令的特点如下:

·指令数量减少

既然THUMB只是一个子集,指令数量必然会减少。例如,乘法指令中只有MUL保留了下来,其他的都被精简了。

·没有条件执行

除分支指令外,其他指令无法条件执行。

·所有指令默认附带“s”

即所有THUMB指令都会设置flag。

·桶式移位无法结合其他指令执行

移位指令只能单独执行,无法与其他指令结合执行。即,可以:


LSL R0 #2

而不可:


ADD R0, R1, LSL #2

·寄存器使用受限

除非显式声明,否则THUMB指令只能使用R0~R7寄存器;但也有例外:ADD、MOV和CMP指令可以将R8~R15作为操作数使用;LDR和STR可以使用PC或SP寄存器;PUSH可以使用LR,POP可以使用PC;BX可以使用所有寄存器。

·立即数和第二操作数使用受限

大多数THUMB数据操作指令的形式是“op Rd,Rm”,只有移位指令、ADD、SUB、MOV和CMP是例外。

·不支持数据写回

除了LDMIA和STMIA外,其他THUMB指令均不支持数据写回,即“!”不可用。

我们在iOS逆向工程初级阶段经常会碰到以上指令,如果对前两节的内容还是一知半解,没关系,自己动手分析两个程序就熟悉了。这一节的内容只是一个引子,在实际操作中如果对指令作用不清楚,ARM的官方文档http://infocenter.arm.com 永远是最好的教科书,http://bbs.iosre.com 上的讨论也很有参考价值。

6.1.3 ARM调用规则

了解了常用的ARM指令后,相信大家已经能够基本读懂一个函数的汇编代码了。当一个函数调用另一个函数时,常常需要传递参数和返回值;如何传递这些数据,称为ARM汇编的调用规则。

1.前言与后记

在6.1.1节提到,“在执行一块代码时,其前后栈地址应该是不变的”,这个操作是通过被执行代码块的前言(prologs)和后记(epilogs)完成的。前言所做的工作主要有:

·将LR入栈;

·将R7入栈;

·R7=SP;

·将需要保留的寄存器原始值入栈;

·为本地变量开辟空间。

后记所做的主要工作跟前言正好相反:

·释放本地变量占用的空间;

·将需要保留的寄存器原始值出栈;

·将R7出栈;

·将LR出栈,PC=LR。

前言和后记中的这些工作并不是必须的,如果这块代码压根儿就没有用到栈,就不需要“保留寄存器原始值”这一步了。在逆向工程中,前言与后记的影响主要体现在SP的变化上,此处稍作了解即可,第10章的例子中会有详细的解答。

2.传递参数与返回值

如果想详细了解参数传递规则,可以通读http://infocenter.arm.com/help/topic/com.arm.doc.ihi0042e/IHI0042E_aapcs.pdf 。一般情况下,记住最重要的一个金句就好:

“函数的前4个参数存放在R0到R3中,其他参数存放在栈中;返回值放在R0中。”

这句话的意思很好理解,为了加深印象,下面看一个例子:


// clang -arch armv7 -isysroot `xcrun --sdk iphoneos --show-
sdk-path` -o MainBinary main.m
#include <stdio.h>
int main(int argc, char **argv)
{
    printf("%d, %d, %d, %d, %d", 1, 2, 3, 4, 5);
    return 6;
}

把这段代码存成名为main.m的文件,用注释里的那句话编译它,然后把MainBinary拖进IDA,生成的main汇编代码如图6-9所示。

“BLX_printf”执行printf函数,它的6个参数分别存放在R0、R1、R2、R3、[SP,#0x20+var_20]和[SP,#0x20+var_1C]中,返回值存放在R0里,其中var_20=-0x20,var_1C=-0x1C,因此栈上的2个参数分别位于[SP]和[SP,#0x4]。

还需要更多解释吗?

“函数的前4个参数存放在R0到R3中,其他参数存放在栈中;返回值放在R0中。”

一定要牢记上面这句话!

本节只是把iOS逆向工程用到的最基本的ARM汇编知识过了一遍,难免有遗漏,但说白了,只要记住刚才的“金句”,配合ARM官方网站,就已经可以开始分析程序了。接下来,就来实际动手,看看如何把刚刚学到的知识运用到iOS逆向工程中。

图6-9 main的汇编代码