iOS逆向之深入解析函数本质·函数调用栈与相关指令

Posted Forever_wj

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了iOS逆向之深入解析函数本质·函数调用栈与相关指令相关的知识,希望对你有一定的参考价值。

一、栈与寄存器

① 栈

  • 栈:是一种具有特殊的访问方式的存储空间(即先进后出 Last In First Out, LIFO):

  • 高地址往低地址存数据(存:高–>低);
  • 栈空间开辟:往低地址开辟(开辟:高–>低)。

② SP 和 FP 寄存器

  • SP 寄存器:在任意时刻会保存栈顶的地址;
  • FP 寄存器(也称为 x29 寄存器):属于通用寄存器,但是在某些时刻(例如函数嵌套调用时)可以利用它保存栈底的地址;
  • arm64 开始,取消了 32 位的 LDM、STM、PUSH、POP 指令,取而代之的是 ldr/ldp、str/stp(r 和 p 的区别在于处理的寄存器个数,r 表示处理 1 个寄存器,p 表示处理两个寄存器);
  • arm64 中,对栈的操作是 16 字节对齐。
  • arm64 之前和 arm64 之后栈的对比:
    • 在 arm64 之前,栈顶指针是压栈时一个数据移动一个单元;
    • 在 arm64 开始,首先是从高地址往低地址开辟一段栈空间(由编译器决定),然后再放入数据,所以不存在 push、pop 操作,这种情况可以通过内存读写指令(ldr/ldp、str/stp)对其进行操作。

③ x30 寄存器

  • x30 寄存器存放的是函数的返回地址,当 ret 指令执行时刻,会寻找 x30 寄存器保存的地址值;
  • 在函数嵌套调用时,需要将 x30 入栈;
  • lr 是 x30 的别名;
  • sp 栈里面的操作必须是 16 字节对齐。

二、函数调用栈

  • 常见的函数调用开辟(sub)以及恢复栈空间(add)的汇编代码:
	// 开辟栈空间
	sub    sp, sp, #0x40             ; 拉伸0x4064字节)空间
	stp    x29, x30, [sp, #0x30]     ; x29\\x30 寄存器入栈保护
	add    x29, sp, #0x30            ; x29指向栈帧的底部
	... 
	ldp    x29, x30, [sp, #0x30]     ; 恢复x29/x30 寄存器的值
	// 恢复栈空间
	add    sp, sp, #0x40             ; 栈平衡
	ret

① 内存读写指令

  • str(store register)指令(能和内存和寄存器交互的专门的指令):将数据从寄存器中读出来,存到内存中 (即一个寄存器是 8 字节 - 64 位);
  • ldr(load register)指令:将数据从内存中读出来,存到寄存器中;
  • ldr 和 str 的变种 ldp 和 stp 还可以操作 2 个寄存器(即 128 位 - 16 字节)。
  • 注意:
    • 读/写数据都是往高地址读/写;
    • 写数据:先拉伸栈空间,再拿 sp 进行写数据,即先申请空间再写数据。
  • 使用 32 个字节空间作为如下程序的栈空间,然后利用栈将 x0 和 x1 的值进行交换:
	sub sp, sp, #0x20       ;拉伸栈空间32个字节
	stp x0, x1, [sp, #0x10] ;sp往上加16个字节,存放x0和x1
	ldp x1, x0, [sp, #0x10] ;将sp偏移16个字节的值取出来,放入x1和x0,内存是temp(寄存器里面的值进行交换)
	add sp, sp, #0x20       ;栈平衡
	ret                     ;返回
  • 栈的操作如下图所示:

② 调试查看栈

  • 重写 x0、x1 的值:

  • register read sp:查看栈的存储情况 debug - debug workflow - view Memory:

  • 然后单步往下执行,发现 x0、x1 已经变成写入的值:
	int A();
	int B();
	
	int test() {
	    int cTemp = 0x1FFFFFFFF;
	    return cTemp;
	}
	
	- (void)viewDidLoad {
	    [super viewDidLoad];
	    printf("A");
	    A();
	    printf("B");
	}
  • 查看内存变化,发现 sp 拉伸了 32 字节:

  • stp x0, x1, [sp, #0x10]:将 x0、x1 写入 fp 偏移 0x10 的位置,继续往下执行一步:


  • 此时 sp 的值并没有变化,还是指向 40:

  • ldp x1, x0, [sp, #0x10]:读取 x0,x1 的数据并交换,继续往下执行一步,此时内存并没有变化:

  • 再来看 sp 是否有变化?从结果来看,也没有变化,因此这里只是读出来进行的交换,并不会导致内存变化:

  • add sp, sp, #0x20:继续执行一步,走到栈平衡,即 sp 恢复,此时的 a 和 b 仍然在内存中,等待着下一轮栈拉伸后数据的写入覆盖。如果此时读取,读取到的是垃圾数据:

  • 栈空间不断开辟,死循环,会不会崩溃?通过一个汇编代码来演示:
	// asm.s
	.text
	.global _B
	
	_B:
	    sub sp,sp,#0x20
	    stp x0,x1,[sp,#0x10]
	    ldp x1,x0,[sp,#0x10];寄存器里面的值进行交换
	    bl _B
	    add sp,sp,#0x20
	    ret
	    
	// 调用
	int B();
	
	int main(int argc, char * argv[]) {
	    B();
	}
  • 运行可以发现:死循环会崩溃,会导致堆栈溢出:

  • 堆栈溢出是说堆区和栈区的溢出,二者同属于缓冲区溢出。一旦程序确定,堆栈内存空间的大小就是固定的,当数据已经把堆栈的空间占满时,再往里面存放数据就会超出容量,发生上溢;当堆栈中的已经没有数据时,再取数据就无法取到了,发生下溢。需要注意的是,栈分为顺序栈和链栈,链栈不会发生溢出,顺序栈会发生溢出。

三、bl 与 ret 指令

① 概念

  • bl 标号:
    • 将下一条指令的地址放入 lr(x30)寄存器(lr 保存的是回家的路)(即l);
    • 转到标号处执行指令(即 b)。
  • ret:
    • 默认使用 lr(x30)寄存器的值,通过底层指令提示 CPU 此处作为下条指令地址;
    • arm64 平台的特色指令,它面向硬件做了优化处理。

② 实战演练

  • 现有如下的 bl、ret 相关的汇编指令:
	.text
	.global _A, _B
	
	_A:
	    mov x0. #0xaaaa
	    bl _B
	    mov x0, #0xaaaa
	    ret
	
	_B:
	    mov x0, #0xbbbb
	    ret
  • 断点执行:
	int A();
	int B();
	
	int test() {
	    int cTemp = 0x1FFFFFFFF;
	    return cTemp;
	}
	
	- (void)viewDidLoad {
	    [super viewDidLoad];
	    printf("A");
	    A();
	    printf("B");
	}
  • 可以看到,A() 和 print 之间还有几个汇编操作,这是什么意思呢?

  • 执行 mov x0. #0xaaaa:x0 变成 aaaa,此时此刻 lr 寄存器保存的是 5f34:

  • 验证 lr 是否保存的是 5f34,通过查看寄存器,可以发现结果与预期是一致的:

  • 继续执行 bl _B,跳转到 B,此时的 lr 会变成 A 中 bl 的下一条指令的地址 5eb8:

  • 执行完 B 中的 mov x0, #0xbbbb,x0 变成 bbbb:

  • 执行 B 中的 ret,会回到 A 中 5eb8:

  • 继续执行 A 中的 ret,会再次回到 5eb8:

  • 执行到这里,发现死循环了,主要是因为 lr 一直是 5eb8,ret 只会看 lr,其中 pc 是指接下来要执行的内存地址,ret 是指让 CPU 将 lr 作为接下来执行的地址(相当于将 lr 赋值给 pc):

  • 此时 B 回到 A 没问题,那么 A 回到 viewDidload 该怎么处理呢?这就需要在 A 的 bl 之前保存 lr 寄存器。但是不可以保存到其他寄存器上,这是因为不安全,不确定这个寄存器会在什么时候被别人使用,正常应该保存到栈区域。
  • 系统中函数嵌套是如何返回?来看下系统是如何操作的,例如:d -> c -> viewDidLoad:
	void d() {
	}
	void c() {
	    d();
	    return;
	}
	- (void)viewDidLoad {
	    [super viewDidLoad];
	    printf("A");
	    c();
	    printf("B");
	}
  • 查看汇编,断点断在 c 函数:

  • 进入 c 函数的汇编:
    • stp x29,x30,[sp,#-0x10]!:边开辟栈,边写入,其中 x29 就是 fp,x30 是 lr,! 表示将这里算出来的结果,赋值给 sp;
    • lsp x29,x30,[sp],#0x10:读取 sp 指向地址的数据,放入 x29、x30,然后 #0x10 表示将 sp+0x10,赋值给 sp。

  • 当有函数嵌套调用时,将上一个函数的地址通过 x30(即 lr)放在栈中保存,保证可以找到回家的路,如下图所示:

  • 自定义汇编代码完善:_A中保存“回家的路”,根据系统的函数嵌套操作,最终在 _A 中增加了如下汇编代码,用于保存“回家的路”:
	// 导致死循环的汇编代码
	_A:
	    mov x0. #0xaaaa
	    bl _B
	    mov x0, #0xaaaa
	    ret
	    
	// 增加lr保存:可以找到回家的路
	_A:
	    sub sp, sp, #0x10  // 拉伸
	    str x30, [sp]      // 存
	    mov x0, #0xaaaa
	    // 保护lr寄存器,存储到栈区域
	    bl _B
	    mov x0, #0xaaa
	    ldr x30, [sp]      // 修改lr,用于A找到回家的路
	    add sp, sp, #0x10  // 栈平衡
	    ret
  • 修改 _A、_B:改成简写形式,其中 lr 是 x30 的一个别名:
	_A:
	    sub sp, sp, #0x10  // 拉伸
	    str x30, [sp]      // 存
	    mov x0, #0xaaaa
	    // 保护lr寄存器,存储到栈区域
	    bl _B
	    mov x0, #0xaaa
	    ldr x30, [sp]      // 修改lr,用于A找到回家的路
	    add sp, sp, #0x10  // 栈平衡
	    ret
	
	_B:
	    mov x0, #0xbbbb
	    ret
	    
	// 改成简写形式
	_A:
	    //sub sp, sp, #0x10  // 拉伸
	    //str x30, [sp]      // 存
	    str x30, [sp, #-0x10]
	    mov x0, #0xaaaa
	    // 保护lr寄存器,存储到栈区域
	    bl _B
	    mov x0, #0xaaa
	    // ldr x30, [sp]      // 修改lr,用于A找到回家的路
	    // add sp, sp, #0x10  // 栈平衡
	    ldr x30, [sp], #0x10  // 将sp的值读取出来,给到x30,然后sp += 0x10
	    ret
	
	_B:
	    mov x0, #0xbbbb
	    ret
  • 查看此时 sp 寄存器的地址:

  • 执行 str x30, [sp, #-0x10],继续查看 sp,发现 sp 发生了变化,但是此时 lr 没变:

  • 查看 0x16f5a1c50 的 memory,此时放入的是 lr 的值 861f2c,即 ViewDidLoad 中的 bl 下一条指令的地址,目前只存放 8 个字节(1 个寄存器):

  • 执行 A 中的 mov x0, #0xaaaa:x0 变成 aaaa:

  • 执行 B 的 ret:从 B 回到 A,此时 lr 还是 1e94:

  • 执行 A 中的 ldr x30, [sp], #0x10:

  • 发现此时 sp 也变了,从 0x16f5a1c50->0x16f5a1c60。从这里可以看出,A 找到了“回家的路”:

  • 为什么是拉伸 16 字节,而不是 8 字节呢?通过手动尝试,写入没问题,读取时会崩溃:因为 sp 中,对栈的操作必须是 16 字节对齐的,所以会在做栈的操作时就会崩溃(sp 栈里面的操作必须是 16 字节对齐,崩溃是在栈的操作时发生):

以上是关于iOS逆向之深入解析函数本质·函数调用栈与相关指令的主要内容,如果未能解决你的问题,请参考以下文章

iOS逆向:函数本质(上)

逆向分析-之深入理解函数

ARM指令集应该也算得上是iOS逆向工程的基础

PWN菜鸡入门之函数调用栈与栈溢出的联系

IOS逆向学习之命令行工具

iOS逆向:函数本质02(下)