[关闭]
@qidiandasheng 2022-08-29T09:21:36.000000Z 字数 2766 阅读 644

Hook objc_msgSend(😁)

iOS运行时


函数调用 - 栈空间详解

如果你之前了解过函数调用时栈空间的栈分配情况,你应该见过类似下面的栈图:

stackFrame.png-73.7kB

在这里,我们需要明确两块内存空间:一块是“栈空间”,上图中蓝色框中的内存属于这里,fp、sp 之中存储的通常是这个空间的地址;一块是“代码空间”,是用于存放我们所编写的函数的汇编指令的,上图中的 IMP A/B/C 存放在这里。你可以这样理解他们之间的关系:我们写的代码被编译成汇编指令后,被 runtime 加载进“代码空间”并开始执行,汇编执行过程中,所产生的中间变量(函数传参、值类型局部变量等),被存储在“栈空间”。当然,为了实现函数跳转、返回、堆栈捕获等功能,栈空间还额外存储了 fp 值以及 lr 值(注意是值!)。

例子

  1. @implementation AppDelegate
  2. - (void)simple
  3. {
  4. [self count:1];
  5. }
  6. - (int)count:(int)i
  7. {
  8. return i;
  9. }
  10. @end

查看 -[AppDelegate simple] 的汇编:

simpleAssembly.png-272.3kB

  1. w2 代表只用 2 号寄存器的 32 位。
  2. blblr 指令在跳转的同时,会将 lr 寄存器的值设置为当前 pc+4byte

从上面的汇编结合一点点思考,我们得出了一些 非常非常重要 的结论:

在上面的代码中,我们看到 simple 的汇编代码将参数值 1 放进了 2 号寄存器中,那 count: 如何知道我将参数放在了这里呢?我们再看看 count: 方法的汇编代码:

countAssembly.png-143.3kB

count: 的汇编,我们得出了另外一个重要结论:

函数参数的传递并不是口耳相传,而是依赖强类型约束和汇编规范的硬编码!不但寄存器的顺序有严格规定,实际的读写操作也和数据类型强相关(int 类型,寄存器选择用 w 而非 x,存储到栈空间偏移量也不是 8 的整数倍)。但是寄存器的数量终归是有限的,当函数内部需要调用多参方法的时候,调用者的会扩大其栈帧的大小,将寄存器塞不下的参数按一定顺序放置在栈帧中。而被调用方,同样是通过硬编码 offset 的方式,一个一个的从前一个函数的栈帧中读取出来。

hook代码

点击此文件查看完成代码

  1. #define call(b, value) \
  2. __asm volatile ("stp x8, x9, [sp, #-16]!\n"); \
  3. __asm volatile ("mov x12, %0\n" :: "r"(value)); \
  4. __asm volatile ("ldp x8, x9, [sp], #16\n"); \
  5. __asm volatile (#b " x12\n");
  6. #define save() \
  7. __asm volatile ( \
  8. "stp x8, x9, [sp, #-16]!\n" \
  9. "stp x6, x7, [sp, #-16]!\n" \
  10. "stp x4, x5, [sp, #-16]!\n" \
  11. "stp x2, x3, [sp, #-16]!\n" \
  12. "stp x0, x1, [sp, #-16]!\n");
  13. #define load() \
  14. __asm volatile ( \
  15. "ldp x0, x1, [sp], #16\n" \
  16. "ldp x2, x3, [sp], #16\n" \
  17. "ldp x4, x5, [sp], #16\n" \
  18. "ldp x6, x7, [sp], #16\n" \
  19. "ldp x8, x9, [sp], #16\n" );
  20. __attribute__((__naked__)) static void hook_Objc_msgSend() {
  21. // Save parameters.
  22. save()
  23. __asm volatile("mov x2, lr\n");
  24. __asm volatile("mov x3, x4\n");
  25. // Call our before_objc_msgSend.
  26. call(blr, &before_objc_msgSend)
  27. // Load parameters.
  28. load()
  29. // Call through to the original objc_msgSend.
  30. call(blr, orig_objc_msgSend)
  31. // Save original objc_msgSend return value.
  32. save()
  33. // Call our after_objc_msgSend.
  34. call(blr, &after_objc_msgSend)
  35. // restore lr
  36. __asm volatile("mov lr, x0\n");
  37. // Load original objc_msgSend return value.
  38. load()
  39. // return
  40. ret()
  41. }

hook调用流程:
1. 和传统函数不同,函数起头没有声明属于自己的栈空间:fp、sp 和上一栈帧一致。
2. 把所有寄存器的值存在低址,并下移 sp,保证后续函数调用不会修改到这部分栈空间。
3. 把 lr 寄存器的值当做参数传递给 before_objc_msgSend,后者将其存至内存中(放到堆区的结构体里面了)
4. 在调用 orig_objc_msgSend 之前,恢复寄存器状态以及 sp 指向,使栈空间的状态恢复到与第一步一致,此时调用 orig_objc_msgSend,所有寄存器、栈帧内容均和原始调用方执行跳转时一致。
5. after_objc_msgSend 将原有 lr 的值作为返回值返回,并设置至 lr 寄存器之中,确保能正确返回。

整套方案最巧妙的点在于,“复用”了调用方的整个栈帧,站在栈空间的角度来看,hook 方法对于调用和被调用方都是透明的;而为了达到 100% 还原栈帧,lr 的值便只能传递出去存至堆区了。

在几个关键点,堆栈和寄存器状态如图:
StackCallProcedure.png-73.3kB

可以看到,在调用原实现时,唯一的不同点就是 lr 寄存器指向了 hook 方法,使原方法执行完后回到 hook 方法中。在汇编指令执行时,位于当前 sp 寄存器下面(低址)的内存属于“无人认领”的内存,可以任意使用。这就是为什么第二步我们需要下移 sp,而第三步我们也不需要清理低址残留的寄存器值。

参考

Hook objc_msgSend -- 从 0.5 到 1

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注