一、栈与寄存器
① 栈
- 栈:是一种具有特殊的访问方式的存储空间(即先进后出 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 ; 拉伸0x40(64字节)空间
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();
}
- 运行可以发现:死循环会崩溃,会导致堆栈溢出:
- 堆栈溢出是说堆区和栈区的溢出,二者同属于缓冲区溢出。一旦程序确定,堆栈内存空间的大小就是固定的,当数据已经把堆栈的空间占满时,再往里面存放数据就会超出容量,发生上溢;当堆栈中的已经没有数据时,再取数据就无法取到了,发生下溢。需要注意的是,栈分为顺序栈和链栈,链栈不会发生溢出,顺序栈会发生溢出。
- 这样,就解决了iOS逆向之初识汇编的基础理论最后遗留问题的原因分析。
三、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 字节对齐,崩溃是在栈的操作时发生):