iOS逆向学习-004汇编中的循环选择判断

2,047 阅读7分钟

条件语句

我们直接通过编译器来学习汇编代码:

int g = 12;

void func(int a, int b){
    if (a > b) {
        g = a;
    } else {
        g = b;
    }
}

func(1, 2);

我们看下汇编代码:

image.png

我们发现了几个陌生的汇编指令,依次来学习下:

adrp

先来看下adr的解释:

这是一条小范围的地址读取指令,它将基于PC的相对偏移的地址读到目标寄存器中; 使用格式:ADR register exper

后面加了p就变成了以page的形式做偏移,一页page的的大小为0x1000,也就是4k。当然,这里的4k是一个助记符,并不能代表当前设备内存的一大小就是4k,我们在mac终端运行命令pagesize:

image.png

我们看到当前设备mac上的一页内存正好是4k,但是在手机上却是16k,可以在越狱手机上试下。

那么adrp如何计算地址呢?

  • 我们先取pc寄存器所在的内存页地址,调用adrp的命令地址为0x10472ddf4,那么当前内存页地址为0x10472d000,只要把最后3位变成0,就是当前的内存页地址。
  • 第二步以page为单位做偏移,这里偏移了8个单位,也就是8 * 0x1000
  • 最后我们把基地址加上偏移地址,就能得到命令的目标值,也就是0x10472d000 + 8 * 0x1000,最后得到0x104735000

0x104735000存入x9寄存器后,又add上了一个偏移值#0x3d0,拿到了全局变量g的地址0x1047353d0,我们验证下对不对:

我们先减去ASLR的偏移值0x0000000104728000:

image.png

得到结果:0xd3d0,我们静态文件里查看:

image.png

发现了全局变量值12,验证正确。

cmp

比较指令,本质上执行的是减法运算,并不改变被比较的值,但是会更新状态寄存器CPSR的值,为后面的b指令提供依据。

CMP 把一个寄存器的内容和另一个寄存器的内容或立即数进行比较。但不存储结果,只是正确的更改标志。 一般CMP做完判断后会进行跳转,后面通常会跟上B指令!

b指令

b本身代表着跳转,如果后面跟着其它符号,会有其它的操作:

  • bl:跳到标号处执行,并且在lr寄存器里存放下个指令的地址值,给函数返回用的
  • b.gt:比较结果是大于(greater than),执行标号,否则不跳转
  • b.ge:比较结果是大于等于(greater than or equal to),执行标号,否则不跳转
  • b.lt:比较结果是小于(less than),执行标号,否则不跳转
  • b.le:比较结果是小于等于(less than or equal to),执行标号,否则不跳转
  • b.eq:比较结果是等于,执行标号,否则不跳转
  • b.hi:比较结果是无符号大于,执行标号,否则不跳转
  • b.hs:比较结果是无符号大于等于,执行标号,否则不跳转
  • b.lo:比较结果是无符号小于,执行标号,否则不跳转
  • b.ls:比较结果是无符号小于等于,执行标号,否则不跳转

while循环

do while语句

我们写段简单的代码:

void func() {
    int sum = 0;
    int i = 0;
    do {
        sum += 1;
        i++;
    } while (i < 100);
}

看下汇编代码:

image.png

还是比较好理解的,先执行代码,然后碰到while语句的时候执行cmp指令,和0x64比较,也就是和100比较,如果条件符合的话,跳到上面重新执行代码。

这里新出现一个新的寄存器wzr,这个比较特殊,代表着0。

while语句

把上面的代码改成while结构的:

void func() {
    int sum = 0;
    int i = 0;
    while (i < 100) {
        sum += 1;
        i++;
    }
}

看下汇编代码:

image.png 这个也很容易理解,调用cmp指令,如果符合指令则跳到下面b指令的后面一行,我们代码中写的是i < 100,而cmp后的比较是b.ge,是大于等于,相当于跳出循环,而循环靠的是下面的b指令。

for循环

我们在改成for循环看一下:

void func() {
    int sum = 0;
    for (int i = 0; i < 100; i++) {
        sum += 1;
    }
}

看汇编:

image.png

基本上和while循环一样,不在多说了。

switch语句

switch的汇编代码会分成两种形式,其中一种会转化成if else实现,以下情况会转成if else实现:

  • 当case语句小于等于3个的时候,会变成if else
  • 当case语句的数值相差过大时,会变成if else。如何定义相差过大,我们姑且把这个概念成为离散程度,我记得上学的时候学数据统计,里面有个方差的求值来判断离散程度,我估计编译器的处理应该也差不多。

我们写个demo:

void func(int a) {
    switch (a) {
        case 1:
            printf("aaa");
            break;
        case 2:
            printf("bbb");
            break;
        case 3:
            printf("ccc");
            break; 
        default:
            printf("default");
            break;
    }
}

或者:

void func(int a) {
    switch (a) {
        case 1:
            printf("aaa");
            break;
        case 10:
            printf("bbb");
            break;
        case 100:
            printf("ccc");
            break;
        case 1000:
            printf("ddd");
            break;
        default:
            printf("default");
            break;
    }
}

我们看下汇编: image.png

红色的框是case语句的比较,蓝色的框跳转是default语句,橙色的框是每个分支结束跳转函数末尾。

本质上,我们不难发现是用if else实现的,上面两个demo都是这样的跳转方式。

接下来我们看另一种汇编实现形式:

void func(int a) {
    switch (a) {
        case 1:
            printf("aaa");
            break;
        case 4:
            printf("bbb");
            break;
        case 3:
            printf("ccc");
            break;
        case 6:
            printf("ddd");
            break;
        default:
            printf("default");
            break;
    }
}

image.png

我们通过汇编调用printf函数的情况,发现几个case的实现都连在一起了(红框),跳转的地方一共发现两处,一个是b.hi,一个是br,其中b.hi明显是default的跳转,而br跳转的地方是寄存器的值,也就是x9中存放的地址,这个地址是多少呢?

先讲原理会好理解一点:我们看到这个switch中case的最小值是1,最大值是6,那么系统会建一张表,为1-6每个值存放一个汇编的实现地址,就是刚截图的红框。因为每次启动的ASLR的地址会不同,所以表里放的是相对与表头的偏移地。

我们从上方的汇编查看下表头的位置,这里有两个陌生的指令:

  • ubfx x9, x9, #0, #32:这个是把x9寄存器的0-32位取出来赋值给x9寄存器,其余位置补0。
  • ldrsw x10, [x8, x11, lsl #2]:lsl是逻辑向左位移,所以这边是x11向左位移2位,加上x8,取出该地址的值放入x10。

我们逐步分析下:

    //把参数w0放入[x29, #-0x4]栈中,并且存入w8
    0x100251d50 <+12>:  stur   w0, [x29, #-0x4]
    0x100251d54 <+16>:  ldur   w8, [x29, #-0x4]
    //把我们的参数值减去1,这个1是case的最小数,这样w8表示的就是表格的第几位index了
    0x100251d58 <+20>:  subs   w8, w8, #0x1              ; =0x1 
    //index值取低32位赋值给x9
    0x100251d5c <+24>:  mov    x9, x8
    0x100251d60 <+28>:  ubfx   x9, x9, #0, #32
    //把index和5比较,这个5是case的最大值和最小值的差,看看index在不在0-5的区间内,如果不在,直接跳转default逻辑。这里很有意思,如果index值比5大的话,那么自然跳转default逻辑,那如果index的值比0小的话,说明是负数,但是负数用无符号比较的话(b.hi),首位bit位是f,数字又变的奇大无比,又变成了大于5了,所以这边只要判断无符号大于5就行了,设计的很巧妙
    0x100251d64 <+32>:  cmp    x9, #0x5                  ; =0x5 
    //把index值存入栈中[sp]
    0x100251d68 <+36>:  str    x9, [sp]
    //判断无符号大于跳转default分支
    0x100251d6c <+40>:  b.hi   0x100251dc8               ; <+132> at ViewController.m
    //这里就是拿到表头的地址了,算出来是0x100251de0,仔细看下这个值,很有意思,正好在这个函数的后面。所以,如果switch设计成表结构的话,那么这张表紧跟着调用switch函数的后面
    0x100251d70 <+44>:  adrp   x8, 0
    0x100251d74 <+48>:  add    x8, x8, #0xde0            ; =0xde0 
    //把前面算出来的index取出放入x11
    0x100251d78 <+52>:  ldr    x11, [sp]
    //lsl #2,是把x11向左位移2位,也就是index * 4了,不难看出表格的单位大小是4个字节,表头地址加上index * 4后,就能拿到对应分支的实现的相对偏移地址了,赋值给了x10了
    0x100251d7c <+56>:  ldrsw  x10, [x8, x11, lsl #2]
    //上一步的x10的相对位移地址,是相对于表头的,所以这边加上表头的地址,就能拿到分支实现的汇编地址了
    0x100251d80 <+60>:  add    x9, x8, x10
    //跳转的实现的分支地址
    0x100251d84 <+64>:  br     x9
    ...
    0x100251ddc <+152>: ret  

我们拿到表头的地址,看下具体长什么样:

image.png

表里一共放了6个负数,正好对应了0-5这6个数,我们可以算下(这里第一格是0xffffffa8,是低地址)。

image.png

正好可以找到上方分支的实现。

我们比较一下switch的两种实现思路,第一种是化身成if else,从第一个比较到最后一个,很明显,算法复杂度是O(n)的。而用第二种方法建表,只要把你要比较的值减去case的最小值,就能拿到对应的分支,算法的复杂度为O(1)的。很明显,如果n很大的话,第二种的效率远远大于第一种,但是第二种空间复杂度就高了,以空间换取时间。

所以我们回头看下系统如何平衡这两种方法的,当n比较小,这里是小于等于3个的时候,用的方法1,因为数量太小了,第二种算法几乎没有快多少,用方法一还能节省点空间。还有种情况也是调用方法1,那是因为case值的离散程度很大,那么开辟的空间很大,只是为了算法快一点而得不偿失。

这里顺带提一下swift的enum,swift中的enum底层存的值就是从0开始的数,而所谓的rawValue是计算属性,是swift自动帮我们生成的属性,所以rawValue不管设置的是整数类型、字符串类型,还是浮点型,底层存的还是0、1、2、3、4、5......,这样设计的话,如果我们switch判断swift的enum,汇编生成方法大概率是方法2了。