hook objc_msgSend后crash问题解决

1,346 阅读4分钟

背景

在做方法耗时监控时(基于戴铭老师分享: ming1016.github.io/2017/06/20/…),由于通过汇编hook了objc_msgSend方法。引起了,项目中try catch无法捕获异常导致了crash问题。

测试代码

- (void)abc
{
    [NSJSONSerialization JSONObjectWithData:nil options:nil error:nil];
}

- (void)testMethod
{
    @try {
        [self abc];
    } @catch (NSException *exception) {
        NSLog(@"abc");
    } @finally {
        
    }
    
    NSLog(@"def");
}

crash栈

从crash调用栈分析,我们可以看出,crash原因为:程序没有找到异常的处理代码,调用了objc_terminate方法终止了程序。那么为什么程序没有找到catch块呢?

分析iOS try catch原理

OC代码

// test.m
// class test
- (void)testMethod
{
    @try {
        NSException *excetion = [[NSException alloc] initWithName:@"AException" reason:@"exceptionReason" userInfo:nil];
        @throw excetion;
    } @catch (NSException *exception) {
        NSLog(@"捕获了异常");
    } @finally {

    }
}

编译源文件为.cpp文件

通过“clang -rewrite-objc test.m -o test.cpp”将.m文件转化成C++代码。并尝试将大括号对其:

static void _I_test_testMethod(test * self, SEL _cmd) {
    { 
      id volatile _rethrow = 0;
      try {
          try {
              NSException *excetion = ((NSException *(*)(id, SEL, NSExceptionName _Nonnull, NSString * _Nullable, NSDictionary * _Nullable))(void *)objc_msgSend)((id)((NSException *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("NSException"), sel_registerName("alloc")), sel_registerName("initWithName:reason:userInfo:"), (NSString *)&__NSConstantStringImpl__var_folders_62_g3vh8tcj4lg265_31zsqyp7w0000gn_T_test_96af62_mi_0, (NSString *)&__NSConstantStringImpl__var_folders_62_g3vh8tcj4lg265_31zsqyp7w0000gn_T_test_96af62_mi_1, (NSDictionary * _Nullable)__null);
              objc_exception_throw( excetion);
          } catch (_objc_exc_NSException *_exception) { 
              NSException *exception = (NSException*)_exception; 
              NSLog((NSString *)&__NSConstantStringImpl__var_folders_62_g3vh8tcj4lg265_31zsqyp7w0000gn_T_test_96af62_mi_2);
          } 
      } catch (id e) {_
          rethrow = e;
      }
       
      {
         struct _FIN { _FIN(id reth) : rethrow(reth) {}
      	 ~_FIN() { if (rethrow) objc_exception_throw(rethrow); }
      	 id rethrow;
      } 

      _fin_force_rethow(_rethrow);
    }
}

我们发现代码被编译为objc_exception_throw函数,在Xcode搜索发现objc-exception.h文件中为OC处理异常的一系列方法。

为objc_exception_throw函数打符号断点,查看内部实现

通过汇编窗口,可以看到objc_exception_throw方法调用了__cxa_allocate_exception方法。

结论

iOS的异常处理,使用了一套objc_exception处理函数,函数内部调用了stdlibc++的一套异常处理函数。

libstdc++库中用于处理异常的代码

// 为异常分配空间,存储在线程局部存储中
__cxa_allocate_excetpion
__cxa_throw
__cxa_begin_catch
__cxa_end_catch
// 不能处理,栈恢复
_unwind_resume

解决try catch crash问题

一顿操作猛如虎,上面的原理分析并不能帮助我们解决try catch崩溃的问题。

通过搜索,我们发现有一套CFI伪指令用于描述线程调用栈。而异常处理正是用的CFI来进行stack unwinding(栈展开),查找Landing Pad(在异常处理程序,应该跳转到的位置)。crash的原因找到了!

CFI指令

CFI:call frame information。 通常用在stack unwinding(栈展开)中

CFA: canonical frame address

Landing Pad: 在异常处理程序,应该跳转到的位置

crash解决

需要用到如下的伪指令:

.cfi_startproc
// ...
.cfi_def_cfa w29, 16        // call指令前,SP的值
.cfi_offset w30, -8         // LR寄存器存储在栈中的位置,相对于cfa的偏移
.cfi_offset w29, -16        // FP寄存器村粗在栈中的位置,相对于cfa的偏移
// ...
.cfi_endproc

在hook objc_msgSend的汇编代码中加入上诉伪指令

添加了cfi伪指令后依然crash

问题

添加伪指令后crash栈:

这下更方了。

不过通过打断点可以看到异常已经被成功捕获到了,crash是发生在异常捕获后。

我们知道异常被捕获后,代码会从catch块后继续执行。

我们上面的测试代码:testMethod(try catch)->abc->JSONObjectWithData(内部throw异常),在throw异常后,程序跳转到testMethod的catch块继续执行,abc和JSONObjectWithData后续的代码都不会被执行。abc和JSONObjectWithData方法只有入栈没有出栈。

而我们的汇编代码是通过栈来保存LR寄存器,异常被捕获后入栈和出栈不一致,子程序返回发生错乱,导致某些指令执行失败crash。

static inline uintptr_t popLRRegister()
{
    lr_stack *lrStack = pthread_getspecific(s_thread_key_lr);
    uintptr_t lr = lrStack->lrs[lrStack->index--];
    
    return lr;
}

void hook_objc_msgSend_before(id self, SEL sel, uintptr_t lr)
{
    if (pthread_main_np()) {
        push_call_record(object_getClass(self), sel);
    }

    pushLRRegister(lr);
}

uintptr_t hook_objc_msgSend_after(BOOL is_super)
{
    if (pthread_main_np()) {
        pop_call_record(is_super);
    }
    
    return popLRRegister();
}
.macro CALL_HOOK_BEFORE
    BACKUP_REGISTERS
    mov x2, lr
    bl _hook_objc_msgSend_before
    RESTORE_REGISTERS
.endmacro

.macro CALL_HOOK_AFTER
    BACKUP_REGISTERS
    mov x0, #0x0
    bl _hook_objc_msgSend_after
    mov lr, x0
    RESTORE_REGISTERS
.endmacro

修复

既然我们在hook objc_msgSend的时候已经开辟了栈帧,那我们就没有必要使用线程私有存储来进行LR寄存器的保存和恢复。直接在释放栈帧的时候使用ldr lr, [fp, #0x08]恢复LR寄存器便可。至此try catch崩溃问题被解决掉。

crash解决后,方法耗时监测工具失效

问题

把try catch崩溃问题解决掉后,发现方法耗时监测工具失效,调试发现我们用来记录方法调用的队列(方法调用之前,入队列,方法调用完成之后,出队列)永远不会为空导致监测工具失效。

原因:

其实这个问题的原因和上面提到的异常被捕获后,程序的调用和调出不匹配导致。

解决:

最初想法:

最初是想给方法的调用打tag,在hook_objc_msgSend_before时生成个tag并返回给汇编代码,调用hook_objc_msgSend_after时传递tag进行匹配。但是仔细分析后发现无法通过汇编来进行tag传递,会破坏线程调用栈空间的布局。

换个思路解决:

hook obj_end_catch方法,在异常处理结束后,对我们记录的调用队列和调用栈进行清空。在异常发生,异常被捕获后,仍然可以监测代码执行的方法耗时。

void hook_end_catch(void)
{
    origin_objc_end_catch();
    if (pthread_main_np()) {
        clean();
    }
}

void clean(void)
{
    if (_mainThreadStack) {
        if (_mainThreadStack->stack) {
            free(_mainThreadStack->stack);
        }
        free(_mainThreadStack);
        _mainThreadStack = NULL;
    }

    if (_mainThreadQueue) {
        if (_mainThreadQueue->records) {
            free(_mainThreadQueue->records);
        }
        free(_mainThreadQueue);
        _mainThreadQueue = NULL;
    }

    ignore_call_num = 0;
}

参考链接

zhuanlan.zhihu.com/p/406894769

blog.gocy.tech/2019/07/08/…

llvm.org/docs/Except…

www.imperialviolet.org/2017/01/18/…