ios 逆向-汇编与函数

679 阅读18分钟

前言

一个APP安装在手机上面的可执行文件本质上是二进制文件,因为iPhone手机本质上执行的指令是二进制,是由手机上的CPU执行的,所以静态分析是建立在分析二进制上面。分析二进制就必须要了解汇编语言,这是静态分析逆向应用的基础。什么是汇编?汇编是一种语言,汇编指令是机器指令的助记符,同机器指令一一对应。学了汇编有啥用?对性能要求极高的程序或者代码片段,可与高级语言混合使用;病毒的分析与防治;为编写高效代码打下基础。所以越底层越单纯!真正的程序员都需要了解的一门非常重要的语言,汇编!下面以arm64架构下汇编为例,挑重点理解一下汇编与函数。

寄存器

image.png 这是APP的执行过程图,程序在磁盘里是一个可执行文件,然后加载进内存是一个镜像文件,cpu对内存进行读写,最后控制显示在终端上。而程序员最需要关心的就是cpu对内存的读写,CPU的运算速度是非常快的,为了性能CPU在内部开辟一小块临时存储区域,并在进行运算时先将数据从内存复制到这一小块临时存储区域中,运算时就在这一小快临时存储区域内进行,我们称这一小块临时存储区域为寄存器

浮点和向量寄存器

因为浮点数的存储以及其运算的特殊性,CPU中专门提供浮点数寄存器来处理浮点数

  • 浮点寄存器 64位: D0 - D31 32位: S0 - S31
  • 向量寄存器 128位:V0-V31

image.png

通用寄存器

  • 通用寄存器也称数据地址寄存器,通常用来做数据计算的临时存储、做累加、计数、地址保存等功能。定义这些寄存器的作用主要是用于在CPU指令中保存操作数,在CPU中当做一些常规变量来使用。
  • ARM64拥有有3264位的通用寄存器 x0 到 x30,以及XZR(零寄存器),这些通用寄存器有时也有特定用途,64位寄存器占8个字节
  • w0 到 w28 这些是32位的, 因为64位CPU可以兼容32位,所以可以只使用64位寄存器的低32位,比如w0就是x0的低32位 image.png 如图可以看到FP寄存器就是x29寄存器,LR寄存器就是x30寄存器,fp和lr只是它们的别名。

SP、FP寄存器、LR寄存器

  • sp寄存器在任意时刻会保存我们栈顶的地址,不是通用寄存器。
  • fp寄存器也称为x29寄存器属于通用寄存器,但是在某些时刻我们利用它保存栈底的地址!
  • lr寄存器也称为x30寄存器属于通用寄存器,下一条指令的地址放入lr(x30)寄存器,在函数的调用栈里用来保存回家的路。下面会用实例理解一下。
  • ARM64里面对的操作是16字节对齐

pc寄存器

  • 指令指针寄存器,它指示了CPU当前要读取指令的地址,区别于lr(x30)寄存器
  • 在内存或者磁盘上,指令和数据没有任何区别,都是二进制信息
  • CPU在工作的时候把有的信息看做指令,有的信息看做数据,为同样的信息赋予了不同的意义,所以pc寄存器的意义就是告知cpu这是指令而不是数据

看了这么多定义我们真机环境下写个demo断点看下pc寄存器

int test(){
    int a=10;
    int b=20;
    return a+b;
}

断点断在test函数的开头 image.png pc寄存器的值就是即将要执行的指令地址,第四行指令地址是0x1026ddeb4,第5行的指令地址是0x1026ddeb8,第6行指令地址是0x1026ddebc,每个指令地址相差4个字节,也就是说一行指令占4个字节。在断点处手动修改一下pc寄存器的值为0x1026ddebc,然后单步执行看下 image.png 当把pc设置为第6行的0x1026ddebc时,单步执行到了第7行,说明pc就是指向的即将要执行的那段汇编,单步执行就是执行了那段汇编然后跳到第7行。

状态寄存器

CPU内部的寄存器中,有一种特殊的寄存器(对于不同的处理器,个数和结构都可能不同).这种寄存器在ARM中,被称为状态寄存器就是CPSR(current program status register)寄存器。CPSR和其他寄存器不一样,其他寄存器是用来存放数据的,都是整个寄存器具有一个含义,而CPSR寄存器是按位起作用的,也就是说,它的每一位都有专门的含义,记录特定的信息。如下所以 image.png 而我们真正需要关心的是NZCV这四位

N(Negative)标志

它记录相关指令执行后,其结果是否为负.如果为负 N = 1,如果是非负数 N = 0,即判断负数位

Z(Zero)标志

它记录相关指令执行后,其结果是否为0.如果结果为0.那么Z = 1.如果结果不为0,那么Z = 0,即判断0位

C(Carry)标志

无符号数运算溢出标志位

  • 加法运算:当运算结果产生了进位时(无符号数溢出),C=1,否则C=0。
  • 减法运算(包括CMP):当运算时产生了借位时(无符号数溢出),C=0,否则C=1。

以加法为例,当两个数据相加的时候,有可能产生从最高有效位想更高位的进位。比如两个32位数据:0xaaaaaaaa + 0xaaaaaaaa,将产生进位。由于这个进位值在32位中无法保存,我们就只是简单的说这个进位值丢失了。其实CPU在运算的时候,并不丢弃这个进位制,而是记录在一个特殊的寄存器的某一位上。ARM下就用C位来记录这个进位值。比如,下面的指令

mov w0,#0xaaaaaaaa0xa 的二进制是 1010
adds w0,w0,w0; 执行后 相当于 1010 << 1 进位1(无符号溢出) 所以C标记 为 1
adds w0,w0,w0; 执行后 相当于 0101 << 1 进位0(无符号没溢出) 所以C标记 为 0
adds w0,w0,w0; 重复上面操作
adds w0,w0,w0

V(Overflow)溢出标志

有符号数运算溢出标志位,如果超过了机器所能标识的范围,称为溢出。

  • 正数 + 正数 为负数 溢出
  • 负数 + 负数 为正数 溢出
  • 正数 + 负数 不可能溢出

还是上一个简单的实用例子

void func(){
    int a = 1;
    int b = 2;
    if (a == b) {
        printf("a == b");
    }else{
        printf("error");
    }

分析:理论上这里应该打印"error",我们断点调试一下 image.png

  • cmp判断执行之前,cpsr寄存器值为0x6,即0110
  • cmp判断执行之后,cpsr寄存器的值变为0x8,即1000,cmp执行的本质就是两个数相减,1000标志位结合上面的概念,第一位1:结果负数 第二位0:结果不是0,第三位0:无符号借位溢出 第四位0:无符号没溢出。实例中a-b,为负数确实也溢出了,所以应该打印"error"
  • 手动把cpsr值变成0x4,即0100,也就是让a-b的结果为非负数并且结果为0,那么就会打印“a==b”

寄存器就是用来读写内存的,所以我们先了解一下ios的内存结构以及一些简单的汇编指令。

未命名文件-4.png

内存布局整体如图,栈区开口方向是向下的,由高地址到低地址区开口方向是向上的,由低地址到高地址,为什么要这么设计?所谓的堆栈溢出,简单理解就是栈地址从上往下和堆地址从下往上如果碰头了说明存满了即溢出。还有一点需要重点注意的,虽然栈区开口是向下的,但是数据的读写都是往高地址的,下面会用例子去体会。我们的重点研究的是栈区,因为堆区的内存是调用系统api开辟的堆空间,但是指针都是存在栈区的,我们需要关注是这个指向堆空间的指针。

常见汇编指令

  • str:将数据从寄存器中读出来,存到内存中。比如 str x0 [sp],把x0寄存器中的数据取出来,存到sp地址对应的内存中。str存储一个寄存器,stp可以存储2个寄存器。
  • ldr:将数据从内存中读出来,存到寄存器中。比如 ldr x0 [sp],将sp地址内存中的值取出来存到寄存器中。同样ldr是操作一个寄存器,ldp是操作两个寄存器
  • bl: 将下一条指令的地址放入lr(x30)寄存器,并且转到标号处执行指令。
  • ret默认使用lr(x30)寄存器的值,通过底层指令提示CPU此处作为下条指令地址!x30寄存器存放的是函数的返回地址,当ret指令执行时刻,会寻找x30寄存器保存的地址值!在函数嵌套调用的时候,需要将x30入栈
  • adrp:address page,跟add指令合起来用,用来获取全局变量或常量的地址,是一个内存分页寻址指令。比如adrp x0,1 add x0,#0x183:先将pc寄存器低12位清零,将1的值左移12位,两个结果相加后获取到页,然后页再偏移#0x183找到具体的全局变量或常量。
  • sub:减指令,用于改变寄存器的值或者用来拉伸栈空间(subs同时还会改变状态寄存器的值)
  • add:加指令,用于改变寄存器的值或者用来平衡栈空间(adds同时还会改变状态寄存器的值)
  • cmp:CMP 把一个寄存器的内容和另一个寄存器的内容或立即数进行比较。但不存储结果,只是正确的更改标志位即标志寄存器的位,本质是一个相减的命令 一般CMP做完判断后会进行跳转,后面通常会跟上B指令!
  • BL 标号:跳转到标号处执行
  • B.GT 标号:比较结果是大于(greater than),执行标号,否则不跳转
  • B.GE 标号:比较结果是大于等于(greater than or equal to),执行标号,否则不跳转
  • B.EQ 标号:比较结果是等于,执行标号,否则不跳转
  • B.NE 标号:比较结果是不等于,执行标号,否则不跳转
  • B.HI 标号:比较结果是无符号大于,执行标号,否则不跳转
  • B.LT 标号:比较结果是小于(less than) ,执行标号,否则不跳转
  • B.LE 标号:比较结果是小于等于(less than or equal to) ,执行标号,否则不 跳转

函数调用栈

上面说了那么多概念没有感觉,先看一个函数调用栈示例

sub    sp, sp, #0x40             ; 拉伸0x4064字节)空间
stp    x0, x1, [sp, #0x30]     ;x0\x1 寄存器入栈
... 
ldp    x1, x0, [sp, #0x30]     ;交换x0 x1的值
add    sp, sp, #0x40             ; 栈平衡
ret
  • sub(减)sp即拉伸栈空间,栈区开口方向是向下的,从高地址到地地址,拉伸0x40 16个字节栈空间,sp指向的是栈顶的位置,即sp=sp地址-0x40;
  • stpx0、x1寄存器的值取出来存入sp地址+0x30内存处,注意数据的读写都是往高地址,也就是说x0在低地址,x1在高地址。
  • ldp指令从sp地址+0x30处取出内存中的值即x0刚刚存的值,把该值存入寄存器x1中,通过内存平移把x1刚刚存的值取出存入x0中,实现了x0和x1值的交换。这个要好好理解下,注意x0和x1的先后顺序。
  • add(加)sp,栈平衡释放栈空间。栈有拉伸就必须得有释放。

图解辅助理解如下: 未命名文件-5.png

函数的嵌套调用

写个函数嵌套调用的汇编如下

.text
.global _test,_test2
_test:
   sub sp,sp,#0x10  //拉伸栈空间
   mov x0,#0x10
   mov x1,#0x30
   add x1,x0,#0xa0
   mov x0,x1
   bl _test2
   mov x0,#0x0
   add sp,sp,#0x10 //栈平衡
  ret

_test2:
    add x0,x1,#0x10
   ret

汇编断点跟进test()函数

image.png 如图在第7行处下断点,没执行第7行bl指令前,lr(x30)寄存器的值是0x000000100ad5f34lr寄存器保存的是下一条指令的地址(回家的路),bl执行后lr寄存器的地址应该是第8行的地址0x10ad6518,断点过掉跟进test()中看一下

image.png 果然bl指令进入test2()函数后,lr寄存器保存着bl指令下面第8行的地址0x10ad6518,这样test2()函数执行ret指令后,就会执行lr寄存中保存的地址,lr可以保证test()函数里调用完test2()后能正确返回到test()函数中继续下面执行。那么test()函数继续往下执行看看

image.png 函数死循环了,test()函数执行ret后找到lr寄存器地址也就是第8行执行,如此反复?造成该现象的原因是,lr寄存器没有保存上一个调用test()函数的地址,这样test()函数就回不去了。所以我们需要保存上一个调用test()的地址进栈保护起来,所以优化一下上面代码如下

.text
.global _test,_test2
_test:
   sub sp,sp,#0x10  //拉伸栈空间
   stp x30,[sp]  //保存lr寄存器的值入栈,保护起来
   mov x0,#0x10
   mov x1,#0x30
   add x1,x0,#0xa0
   mov x0,x1
   bl _test2
   mov x0,#0x0
   ldr x30,[sp]  //从栈内存中取出更新lr,这样ret就能回去了
   add sp,sp,#0x10 //栈平衡
  ret

_test2:
    add x0,x1,#0x10
   ret

分析:我们要保存上一个调用test()的地址,就必须要保存在函数栈里,因为函数的栈空间是属于函数自己的,每个函数都有独立的栈空间,而寄存器是共用的,所以这个地址不能保存在寄存器中,应该是进入test()时,就把这个地址存在这个函数栈内存中,然后要返回的时候再从栈内存中取出这个地址更新lr,这样就能正常返回了。上面的汇编会被系统优化成如下,我们逆向分析的时候也都是优化过的汇编

.text
.global _test,_test2
_test:
   //sub sp,sp,#0x10  //拉伸栈空间
  // stp x30,[sp]  //保存lr存器的值入栈,保护起来
   stp x30,[sp,#0x10]! //先将x30值存到sp-0x10位置,然后sp=sp-0x10
   mov x0,#0x10
   mov x1,#0x30
   add x1,x0,#0xa0
   mov x0,x1
   bl _test2
   mov x0,#0x0
  // ldr x30,[sp]  //从栈内存中取出更新lr,这样ret就能回去了
 //  add sp,sp,#0x10 //栈平衡
   ldr x30,[sp],#0x10 //sp取出存进x30,sp=sp+0x10
  ret

_test2:
    add x0,x1,#0x10
   ret

函数的参数

写个demo,然后学习一下系统怎么给我们编译的

int sum(int a,int b){
    return a+b;
}
sum(10, 20);

汇编如下 image.png 在调用sum()函数前,先把参数10(0xa)存进w0,参数20(0x14)存进w1,注意w0只是x1的低地址寄存器。再看下sum()汇编 image.png 简单的拉伸栈空间就不解释了,注意add w0,w8,w9这行汇编,w0=w8+w9,说明w0是作为函数的返回值寄存器w0/x0一直都是作为返回值寄存器吗?下面看这个例子

struct test{
    int a;
    int b;
    int c;
    int d;
    int e;
    int f;
    int g;
    int h;
    int i;
};

struct test getstruct(int a,int b,int c,int d,int e,int f,int g,int h,int i){
    struct test test1;
    test1.a=a;
    test1.b=b;
    test1.c=c;
    test1.d=d;
    test1.e=e;
    test1.f=f;
    test1.g=g;
    test1.h=h;
    test1.i=i;
    return test1;
}
- (void)viewDidLoad {
    [super viewDidLoad];
    getstruct(1,2,3,4,5,6,7,8,9);
    }

写个返回test结构体的方法getstruct(),方法中传入9个参数,断点在getstruct处看一下汇编 image.png 可以看到w0-w7(x0寄存器的低地址寄存器)这8个寄存器分别存入参数值1-8,但是第9个参数值9是如何存入的呢?

  1. mov x9,sp:把viewDidLoad函数栈的栈顶sp寄存器的指针地址存入x9寄存器
  2. mov w10,#0x9:把9存入到w10寄存器
  3. str w10,[x9]:把w10寄存器的值也就是9存入到x9寄存器的地址处,即栈顶sp寄存器的指针地址,所以x9寄存器指针地址是sp栈顶,值是9。参数值9存入的是viewDidLoad函数调用栈里,并且是栈顶的位置,也就是所说的参数入栈了。

提醒:x8寄存器地址为sp寄存器地址+#0xc?为什么要这样?

单步进入getstruct函数里看一下汇编 image.png 分析:

  • 标红的第3行,ldr w9,[sp,#0x30]:上个函数栈顶的的值存入w9,上个函数栈顶的值我们存入了第9个参数,所以这里是把参数9从上个函数栈内存取出来传进getstruct函数栈内。
  • 标红的第14行,str w9,[x8]:把w9寄存器的值存入x8寄存器地址处,x8寄存器的地址我们上面特别提醒了为上一个栈的sp地址+0xc,也就是说这个x8在上一个栈空间内,14行后面就是不断把参数值存入到以x8地址为基地址入栈,注意存入的是上一个函数的栈。
  • 标红31行,x0没有作为返回值寄存器?因为返回的是test结构体,这个结构体大小为36个字节x0寄存器只能存8字节以内的。

总结

  • ARM64下,函数的参数是存放在X0到X7(W0到W7)这8个寄存器里面的,如果超过8个参数,就会入栈,入栈就会影响效率,所以在定义oc方法时,最多传6个参数,因为还有两个隐藏参数id和SEL
  • 函数返回值一般是放在X0寄存器里面的,但是当返回值大于8个字节(寄存器是8字节)时就会入栈,写入上一个调用栈的内部,x8寄存器作为参照。所以说如果存在函数A调用函数B,在B调用完毕之后,B的参数不一定就释放了,因为如果B的参数超过8个,多余的参数会存储在A函数的栈里。

if/switch

先写个switch demo看看会编译成什么

void funcA(int a){
    switch (a) {
        case 1:
            printf("打坐");
            break;
        case 2:
            printf("加红");
            break;
        case 3:
            printf("加蓝");
            break;
        default:
            printf("啥都不干");

            break;

    }
}
int main(int argc, char * argv[]) {
    funcA(1);
    return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));

}

汇编如下 image.png 通过测试demo发现,当case低于3个时,生成的汇编和if语句判断一样,当大于3个时汇编会变成如下

image.png 一般看到ubfx和ldrsw指令就代表生成了switch表内存空间,ubfx x9,x9 #0,#32:x9高32清零。ldrsw x10,[x8,x11,lsl #2]:x11左移2位 然后加上x8地址,取值存入x8,这句代码的含义就是从switch内存表中取出对应的case偏移值。怎么理解swithc内存表呢?当case大于3个时,会在函数调用栈下面开辟一个内存用于存储这个case键值,这个键值是一个具体跳转指令的偏移值,利用空间换去时间效率,因为if条件判断更耗时。

再把demo的case间隔调大一点看看呢?

void funcA(int a){
    switch (a) {//
        case 1:
            printf("打坐");
            break;
        case 2:
            printf("加红");
            break;
        case 300:
            printf("加蓝");
            break;
        case 400:
            printf("加蓝2");
            break;
        default:
            printf("啥都不干");
            break;
    }
}

汇编如下 image.png 虽然case大于3个,但是最终汇编指令还是跟if语句一样,所以说会不会使用空间换时间的方式生成switch表是由编译器决定的,如果大于3个case,并且case之前的差值尽可能的小就会使用空间换时间的方式,如果差值过大,生成的switch表过于浪费内存,那么就还是会使用cmp比较的方式。

总结

  • 使用switch时要尽可能的减小case之间的差值
  • 超过3个case才有可能使用空间换时间,否则等同if语句

总结

汇编真是一门看着头疼,学着有趣的语言。