腾讯VATeam

避免iOS崩溃:揭秘Unrecognized Selector防护挑战和实用策略

前言

最近在处理iOS crash 防护时,发现Unrecognized Selector的防护存在一些日常开发被忽略的情况。若 直接使用 业内防护方案的话,有可能存在 相对隐蔽不易发现的 功能失效 。本文就针对 Unrecogn ize d Select o r防护中遇到的问题进行分析,揭秘整个防护过程隐藏的问题。

一、问题表现

添加 Unrecogn ize d Select o r防护后, 首页触发刷新就会弹出防护警告,看内容比较疑惑,UITableView中的setContentInset: 正常情况下是有对应实现的,怎么可能会触发crash拦截呢?

二、Unrecognized Selector 防护实现

先来看下我的防护系统中Unrecognized Selector是怎么实现的,我们采用的是Hook消息转发中的 forwardingTargetForSelector方法,然后再判断是否重写forwardingTargetForSelector或者forwardInvocation方法,若重写了,则本类需要处理消息转发,就不防护了,否则就返回CrashGuardProxy对象。

心想业界的方案都是这样写的,应该可以完美防护住Unrecognized Selector了吧。 但是前面遇到问题就解释不清了, 于是乎, 只能 调试抓现 场呀。

通过打印发现此时的UITableView已经被改了,变成NSKVONotifying_UITableView,这个就是给UITableView的contentInset做了KVO的监听,那问题来了,KVO的实现不应该是类似以下伪代码实现:

- (void)setContentInset:(UIEdgeInsets)edge {
  [self willChangeValueForKey:@"contentInset"];
  [super setContentInset:edge];
  [self didChangeValueForKey:@"contentInset"];
}
同样的对UITableView的其他属性,如contentOffset、frame,进行监听是不会进入到消息转发的,那为什么contentInset属性比较特殊呢?

苹果不开源呀!这时候只能用lldb来看看具体实现了,首先进入lldb后,对addObserver:forKeyPath:options:context:进行调试

(lldb) breakpoint set -n '[NSObject addObserver:forKeyPath:options:context:]'
Foundation`-[NSObject(NSKeyValueObserverRegistration) addObserver:forKeyPath:options:context:]:
    ......
    0x18aafcd30 <+84>: add x0, x0, #0x3ac ; _NSKeyValueObserverRegistrationLock
    0x18aafcd34 <+88>: mov w1, #0x0
    0x18aafcd38 <+92>: bl 0x1904ce9f0
    0x18aafcd3c <+96>: mov x0, x19
    0x18aafcd40 <+100>: bl 0x1904ce8a0
    0x18aafcd44 <+104>: mov x1, x21
    0x18aafcd48 <+108>: bl 0x18ab3657c ; NSKeyValuePropertyForIsaAndKeyPath
    0x18aafcd4c <+112>: mov x3, x0
    0x18aafcd50 <+116>: mov x0, x19
    0x18aafcd54 <+120>: mov x2, x20
    0x18aafcd58 <+124>: mov x4, x22
    0x18aafcd5c <+128>: mov x5, x23
    0x18aafcd60 <+132>: bl 0x18b3c1c20 ; objc_msgSend$_addObserver:forProperty:options:context:
    0x18aafcd64 <+136>: adrp x0, 376784
    0x18aafcd68 <+140>: add x0, x0, #0x3ac ; _NSKeyValueObserverRegistrationLock
    ......
从上述代码看真正添加Observer的实现应该在_addObserver:forProperty:options:context: 方法中,可以发现替换isa指针的方法是isaForAutonotifying
(lldb) breakpoint set -n 'isaForAutonotifying'
Foundation`-[NSKeyValueUnnestedProperty isaForAutonotifying]:
    ......
    0x18aafd1f8 <+60>: ldrb w8, [x0, x22]
    0x18aafd1fc <+64>: cbz w8, 0x18aafd20c ; <+80>
    0x18aafd200 <+68>: adrp x8, 376776
    0x18aafd204 <+72>: ldrsw x23, [x8, #0x228]
    0x18aafd208 <+76>: b 0x18aafd2bc ; <+256>
    0x18aafd20c <+80>: mov x0, x19
    0x18aafd210 <+84>: bl 0x18b3c49a0 ; objc_msgSend$_isaForAutonotifying
    0x18aafd214 <+88>: adrp x8, 376776
    0x18aafd218 <+92>: add x8, x8, #0x220 ; _MergedGlobals
    0x18aafd21c <+96>: ldrsw x23, [x8, #0x8]
    ......
在该方法中发现真正替换isa的方法是_isaForAutonotifying,直接调试:
(lldb) breakpoint set -n '_isaForAutonotifying'
Foundation`-[NSKeyValueUnnestedProperty _isaForAutonotifying]:
    0x18aafd4c0 <+0>: pacibsp
    0x18aafd4c4 <+4>: stp x20, x19, [sp, #-0x20]!
    0x18aafd4c8 <+8>: stp x29, x30, [sp, #0x10]
    0x18aafd4cc <+12>: add x29, sp, #0x10
    0x18aafd4d0 <+16>: mov x19, x0
    0x18aafd4d4 <+20>: ldp x8, x2, [x0, #0x8]
    0x18aafd4d8 <+24>: ldr x0, [x8, #0x8]
    0x18aafd4dc <+28>: bl 0x18b3c99e0 ; objc_msgSend$automaticallyNotifiesObserversForKey:
    0x18aafd4e0 <+32>: cbz w0, 0x18aafd504 ; <+68>
    0x18aafd4e4 <+36>: ldr x0, [x19, #0x8]
    0x18aafd4e8 <+40>: bl 0x18ab6ad08 ; _NSKeyValueContainerClassGetNotifyingInfo
    0x18aafd4ec <+44>: cbz x0, 0x18aafd508 ; <+72>
    0x18aafd4f0 <+48>: mov x20, x0
    0x18aafd4f4 <+52>: ldr x1, [x19, #0x10]
    0x18aafd4f8 <+56>: bl 0x18aaecbe8 ; _NSKVONotifyingEnableForInfoAndKey
    0x18aafd4fc <+60>: ldr x0, [x20, #0x8]
    0x18aafd500 <+64>: b 0x18aafd508 ; <+72>
    0x18aafd504 <+68>: mov x0, #0x0
    0x18aafd508 <+72>: ldp x29, x30, [sp, #0x10]
    0x18aafd50c <+76>: ldp x20, x19, [sp], #0x20
    0x18aafd510 <+80>: retab
_NSKeyValueContainerClassGetNotifyingInfo主要是生成 NSKVONotifying_xxx 类以及创建_isKVOA、dealloc、class方法;
_NSKVONotifyingEnableForInfoAndKey就是对监听属性的处理,这个就是要找的,看看具体实现:
(lldb) breakpoint set -n '_NSKVONotifyingEnableForInfoAndKey'
_NSKVONotifyingEnableForInfoAndKey 方法汇编实现太长了,以下就直接翻译本文比较关心的部分伪代码:
......
char *argtype = method_copyArgumentType(m, 2);
IMP replacementSetter = (IMP)&_NSSetObjectValueAndNotify;
if (argtype[0] <= '?' && argtype[0] != '#') {
   NSLog(@"KVO only supports -set<Key>: methods that take id, NSNumber-supported scalar types, and some structure types. Autonotifying will not be done for invocations of -[%@ %s].", notifyingInfo->_originalClass, sel_getName(method_getName(m)));
} else {
   if (argtype[0] == '{') {
       if (strcmp(argtype, @encode(CGPoint)) == 0) {
           replacementSetter = (IMP)&_NSSetPointValueAndNotify;
       } else if (strcmp(argtype, @encode(NSRange)) == 0) {
           replacementSetter = (IMP)&_NSSetRangeValueAndNotify;
       } else if (strcmp(argtype, @encode(CGRect)) == 0) {
           replacementSetter = (IMP)&_NSSetRectValueAndNotify;
       } else if (strcmp(argtype, @encode(CGSize)) == 0) {
           replacementSetter = (IMP)&_NSSetSizeValueAndNotify;
       } else {
           replacementSetter = (IMP)&_CF_forwarding_prep_0;
       }
   } else {
       switch (argtype[0]) {
           case: 基础数据类型 {
               // 获取基础数据类型的IMP
               ......
           } break;
       }
   }
   free(argtype);
   SEL selector = method_getName(m);
   NSKVONotifyingSetMethodImplementation(notifyingInfo, selector, replacementSetter, key);
   NSKeyValueSetter *notifyingSetter = [NSObject _createValueSetterWithContainerClassID:notifyingInfo->_notifyingClass key:key];
   [notifyingSetter setMethod:class_getInstanceMethod(notifyingInfo->_notifyingClass, selector)];
   if (replacementSetter == (IMP)&_CF_forwarding_prep_0) {
     NSKVONotifyingSetMethodImplementation(notifyingInfo, @selector(forwardInvocation:), (IMP)&NSKVOForwardInvocation, nil);
       Class otherClass= notifyingInfo->_notifyingClass;
       const char *methodName = sel_getName(selector);
       int nameLength = strlen(methodName);
       const char *prefix = kOriginalImplementationMethodNamePrefix;
       char buffer[29] = {0};
       strlcpy(buffer, prefix, nameLength+strlen(prefix));
       strlcat(buffer, methodName, nameLength+strlen(prefix));
       SEL newForwardingSelector = sel_registerName(buffer);
       IMP originalIMP = method_getImplementation(m);
       const char *originalTypeEncoding = method_getTypeEncoding(m);
       class_addMethod(otherClass, newForwardingSelector, originalIMP, originalTypeEncoding);
   }
}
发现非 CGSize、CGPoint、CGRect、NSRange的结构体的IMP指向了 _CF_forwarding_prep_0 ,具体实现可以看下下面代码:
_CF_forwarding_prep_0: // top of stack is used as marg_list
    stmfd sp!, {r0-r3} // push args to marg_list
    stmfd sp!, {fp, lr} // setup stack frame: sp -= 8, marg_list @ sp+8
    .save {fp, lr}
    .setfp fp, sp, #4
    add fp, sp, #4
    .pad #8
    sub sp, sp, #8          // pad the stack: sp -= 8, marg_list @ sp+16
    add r1, sp, #16         // use marg_list as return strage pointer
    add r0, sp, #16         // load marg_list
    bl ___forwarding___     // call through
    sub sp, fp, #4          // restore stack
    ldmfd sp!, {fp, lr} // destroy stack frame
    cmp r0, #0              // check for forwarding completion
    bne LContinue           // circle back around if we're not done or failed
    ldmfd sp!, {r0-r3} // load return value registers from marg_list
    bx lr                   // return 
CF_forwarding_prep_0 中的 ___forwarding___ 的功能就是进行三次消息转发,也就是解释了UIEdgeInsets的属性添加KVO后触发更新时,先进入消息转发,再调用到原方法。
在回顾下整个KVO添加以及方法调用过程,未添加KVO时,UITableView存在setContentInset方法

添加KVO后,UITableView实例的isa指针指向NSKVONotifying_UITableView

当开发者调用UITableView的setContentInset方法时,实际就是走的NSKVONotifying_UITableView:

正常情况下forwardingTargetForSelector:是没有对应实现的,一定会进入到forwardInvocation: ,但是我们增加了crash防护是替换了forwardingTargetForSelector:方法,判断是会认为需要防护的,就会被我们的防护代码给误拦截了,也导致无法进入到KVO的消息分发了。

三、问题解决

知道问题后修改起来就容易多了,只要在拦截处判断下是否有对象方法实现,不管是正常IMP还是_CF_forwarding_prep_0都算有IMP了,至于原类怎么处理的不需要管,防护系统就不防护这类的情况。

四、参考资料

iOS开发:『Crash 防护系统』(一)Unrecognized Selector【https://cloud.tencent.com/developer/beta/article/1492750】

Foundation 【https://github.com/apportable/Foundation】