天天看點

避免iOS崩潰:揭秘Unrecognized Selector防護挑戰和實用政策

前言

最近在處理iOS crash 防護時,發現Unrecognized Selector的防護存在一些日常開發被忽略的情況。若直接使用業内防護方案的話,有可能存在相對隐蔽不易發現的功能失效。本文就針對Unrecognized Selector防護中遇到的問題進行分析,揭秘整個防護過程隐藏的問題。

一、問題表現

添加Unrecognized Selector防護後,首頁觸發重新整理就會彈出防護警告,看内容比較疑惑,UITableView中的setContentInset: 正常情況下是有對應實作的,怎麼可能會觸發crash攔截呢?

避免iOS崩潰:揭秘Unrecognized Selector防護挑戰和實用政策

二、Unrecognized Selector 防護實作

先來看下我的防護系統中Unrecognized Selector是怎麼實作的,我們采用的是Hook消息轉發中的 forwardingTargetForSelector方法,然後再判斷是否重寫forwardingTargetForSelector或者forwardInvocation方法,若重寫了,則本類需要處理消息轉發,就不防護了,否則就傳回CrashGuardProxy對象。

避免iOS崩潰:揭秘Unrecognized Selector防護挑戰和實用政策

心想業界的方案都是這樣寫的,應該可以完美防護住Unrecognized Selector了吧。但是前面遇到問題就解釋不清了,于是乎,隻能調試抓現場呀。

避免iOS崩潰:揭秘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方法

避免iOS崩潰:揭秘Unrecognized Selector防護挑戰和實用政策

添加KVO後,UITableView執行個體的isa指針指向NSKVONotifying_UITableView

避免iOS崩潰:揭秘Unrecognized Selector防護挑戰和實用政策

當開發者調用UITableView的setContentInset方法時,實際就是走的NSKVONotifying_UITableView:

避免iOS崩潰:揭秘Unrecognized Selector防護挑戰和實用政策

正常情況下forwardingTargetForSelector:是沒有對應實作的,一定會進入到forwardInvocation: ,但是我們增加了crash防護是替換了forwardingTargetForSelector:方法,判斷是會認為需要防護的,就會被我們的防護代碼給誤攔截了,也導緻無法進入到KVO的消息分發了。

三、問題解決

知道問題後修改起來就容易多了,隻要在攔截處判斷下是否有對象方法實作,不管是正常IMP還是_CF_forwarding_prep_0都算有IMP了,至于原類怎麼處理的不需要管,防護系統就不防護這類的情況。

避免iOS崩潰:揭秘Unrecognized Selector防護挑戰和實用政策

四、參考資料

iOS開發:『Crash 防護系統』(一)Unrecognized Selector【https://cloud.tencent.com/developer/beta/article/1492750】

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

作者:binweichen

來源:微信公衆号:騰訊VATeam

出處:https://mp.weixin.qq.com/s/bHdtetOb3LQGRfRO9AFVQA

繼續閱讀