一、背景
全民 K 歌在一个业务需求中,接入了一个第三方的 SDK,在接入 SDK 后启动 APP 就出现 crash,在后续的定位排查中,发现这是由一段关于对系统类簇添加保护代码引发的。本文记录了 crash 的原因排查过程及需要关注的一些细节。
二、问题初步排查
在程序启动后,APP 会卡死一段时间,然后出现闪退,从闪退的堆栈上不难看出,这里是方法发生了循环调用:
这里的方法是对NSString方法的 - (NSString *)stringByAppendingString:(NSString *)aString 方法进行了 MethodSwizzle,目的在于防护因传入空字符串参数导致的 crash,而在方法交换时,由于某种原因,方法一直在循环调用自己本身,最终导致爆栈。
尝试在这个方法交换中打个断点,看看在触发方法时发生了什么,发现这个方法被触发了多次,其中一次触发的堆栈很奇怪:
可以看到这个方法被第三方的 SDK 触发过一次,而这个 SDK 就是本次业务新增的,这里可以获取到一个信息,就是第三方 SDK 内存在一个跟 K 歌的 NSString MethodSwizzle 相同方法名的方法,在调用过程中方法被K 歌的方法实现覆盖了,导致此方法被 SDK 调用到了。
根据这个方向,我们只需要修改一下 K 歌本地的 swizzle 方法名,增加一个业务前缀,避免被第三方 SDK 调用到就行了。尝试了一下,APP 启动后确实不闪退了,本文完结撒花!
三、 问题真正的原因
虽然启动闪退的问题解决了,但方法 swizzle 不应该引发最终的循环调用,最多应该是多走了两个防御性代码,所以这里应该有更深层次的问题没有被发现。于是我们对这里进行进一步的排查。
前面我们发现 MethodSwizzle 方法是被触发了多次的,这其中:第三方 SDK 触发了两次,分别是针对 NSString 和 NSMutableString 进行了 swizzle, K歌本地触发了一次,则是对 __NSCFString 进行 swizzle。__NSCFString 应该是 Object-C 的一个私有的类,为什么会对它进行了一次 MethodSwizzle 呢?顺着堆栈找到了 K 歌的 MethodSwizzle 调用
可以发现 K 歌这里并不是直接对 NSString 和 NSMutableString进行的方法替换,而是直接往其类簇的真正实现类 __NSCFString 和 __NSCFConstantString 进行处理,至于为什么只触发了 __NSCFString 的方法交换,是因为这里还将 __NSCFConstantString 拼写错了。
我们知道,Object-C 语言有一个类簇(class cluster)的概念,指的是由一个抽象的父类以及一组私有化的具体子类组成的一个类实现,我们只能通过父类对外提供的接口来进行调用,在程序运行的底层,系统会根据具体的对象来使用对应的实现方法,从而达到优化内存及方法运行速度等目的。我们熟知的 NSString ,NSNumber ,NSArray,NSDictionary 这些都是类簇。
我们这里可以尝试构建一个 __NSCFString 对象,看看其与 NSString 的继承关系。在 NSObject 中,存在一些私有方法
@interface NSObject (Private)
-(id)_ivarDescription;
-(id)_shortMethodDescription;
-(id)_methodDescription;
@end
其中 _shortMethodDesctiption 列举了所有实例和类方法;_methodDescription大致和_shortMethodDesctiption一样,但是更加详细还包含了超类的方法;_ivarDescription列举了所有的实例变量、类型和值。
我们可以通过在 LLVM 中 po 一下对象的 _ivarDescription 来观察__NSCFString 的继承信息
可以看到 __NSCFString 确实是继承于 NSString ,属于 NSString 类簇的具体子类。
由于 NSString 是一个类簇,那么这里如果像微信小程序一样,只针对NSString 和 NSMutableString 对方法进行 Swizzle,那么可能出现实际的子类 Override 了这个方法,从而导致 Swizzle 失败,所以这里 K 歌更进一步的对子类进行了 MethodSwizzle,看上去是无可厚非的。
那么目前已知,在未上线第三方 SDK 的时候,K 歌的这个方法是能正常运行的,接入 SDK 之后就出现了异常,而异常的直接原因在于MethodSwizzle 方法的多次调用,这里 MethodSwizzle 方法就是我们接下来重点排查的关键。
重新看一下 MethodSwizzle 这里的实现逻辑:
+ (void)swizzleStringByAppendingString:(Class)cls {
// 用于交换的方法 IMP
IMP newImp = imp_implementationWithBlock(^id(id self, NSString *aString) {
if (aString != nil) {
return [self ksSafe_StringByAppendingString:aString];
} else {
return self;
}
});
class_addMethod(cls, @selector(ksSafe_StringByAppendingString:), newImp, "@@:@");
[cls swizzleInstanceMethodWithOriginSEL:@selector(stringByAppendingString:) swizzledSEL:@selector(ksSafe_StringByAppendingString:)];
}
这里只是创建了一个交换后的方法实现,并调用了一下 swizzleInstanceMethodWithOriginSEL:swizzledSEL:,继续深入方法排查
/// 交换实例方法
/// @param originSEL 原方法 selector
/// @param swizzledSEL 交换后的方法 selector
+ (void)swizzleInstanceMethodWithOriginSEL:(SEL)originSEL swizzledSEL:(SEL)swizzledSEL
{
Method originMethod = class_getInstanceMethod(self, originSEL);
Method swizzleMethod = class_getInstanceMethod(self, swizzledSEL);
[self swizzleMethodWithOriginSEL:originSEL originMethod:originMethod swizzledSEL:swizzledSEL swizzleMethod:swizzleMethod class:self];
}
+ (void)swizzleMethodWithOriginSEL:(SEL)originSEL
originMethod:(Method)originMethod
swizzledSEL:(SEL)swizzledSEL
swizzleMethod:(Method)swizzleMethod
class:(Class)cls
{
// 尝试将需要交换的方法添加到类中
BOOL didAddMethod = class_addMethod(cls, originSEL, method_getImplementation(swizzleMethod), method_getTypeEncoding(swizzleMethod));
if (didAddMethod) {
// 如果添加成功,则做方法的 replace 操作
class_replaceMethod(cls, swizzledSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod));
} else {
// 添加失败,说明方法已存在,做 swizzle 操作
method_exchangeImplementations(originMethod, swizzleMethod);
}
}
在底层的 swizzle 操作中,我们发现这里并不是简单的执行一下 runtime 的 method_exchangeImplementations() 方法,而是先尝试把想要交换的方法添加到类中,然后判断是否添加成功,若不成功,说明类原先存在此方法;若成功,说明不存在,先添加,然后用 swizzleInstanceMethodWithOriginSEL:swizzledSEL: 方法获取到的 originMethod 来进行交换。
在这里存在一个问题,假设在添加方法成功的场景下,说明类本身是不存在此方法的,那么他替换掉的是什么呢?是父类的方法IMP,通过这种手段,在子类中补齐了方法实现,并实现了一套完整的 MethodSwizzle,且不影响父类。
现在我们需要判断,__NSCFString 到底是走了 MethodSwizzle 中的哪一个判断逻辑呢?问题的关键在于 __NSCFString 是否存在stringByAppendingString: 方法,我们可以通过 _shortMethodDescription 来验证一下:
(lldb) po [a _shortMethodDescription]
<__NSCFString: 0x282f111a0>:
in __NSCFString:
Class Methods:
+ (id) allocWithZone:(struct _NSZone*)arg1; (0x1905705d4)
+ (BOOL) automaticallyNotifiesObserversForKey:(id)arg1; (0x1905705f8)
Instance Methods:
- (id) retain; (0x19045bf14)
- (unsigned long) smallestEncoding; (0x1905708c4)
- (unsigned long) cStringLength; (0x1905706ac)
- (void) replaceCharactersInRange:(struct _NSRange)arg1 withString:(id)arg2; (0x190495728)
- (BOOL) isNSString__; (0x1904532ac)
- (BOOL) _tryRetain; (0x190504240)
- (const char*) _fastCStringContents:(BOOL)arg1; (0x1905389b4)
- (BOOL) _isDeallocating; (0x190520560)
- (void) appendCharacters:(const unsigned short*)arg1 length:(unsigned long)arg2; (0x1905709b8)
- (BOOL) isEqualToString:(id)arg1; (0x190450244)
- (void) insertString:(id)arg1 atIndex:(unsigned long)arg2; (0x1904d3b7c)
- (oneway void) release; (0x190458dc8)
- (id) substringWithRange:(struct _NSRange)arg1; (0x19046dcb8)
- (unsigned long) replaceOccurrencesOfString:(id)arg1 withString:(id)arg2 options:(unsigned long)arg3 range:(struct _NSRange)arg4; (0x1904f62f8)
- (BOOL) isEqual:(id)arg1; (0x190455270)
- (unsigned long) length; (0x1904530b0)
- (void) deleteCharactersInRange:(struct _NSRange)arg1; (0x1904d5abc)
- (BOOL) _isCString; (0x1905340d8)
- (void) appendString:(id)arg1; (0x19046c640)
- (void) setString:(id)arg1; (0x1904d1138)
- (unsigned long) fastestEncoding; (0x190453b5c)
- (const char*) UTF8String; (0x190453a18)
- (Class) classForCoder; (0x1904acc2c)
- (void) getCharacters:(unsigned short*)arg1 range:(struct _NSRange)arg2; (0x1904635e8)
- (unsigned long) hash; (0x190455db0)
- (BOOL) hasSuffix:(id)arg1; (0x190464ef8)
- (unsigned short) characterAtIndex:(unsigned long)arg1; (0x1904843a8)
- (id) _newSubstringWithRange:(struct _NSRange)arg1 zone:(struct _NSZone*)arg2; (0x1904b61e8)
- (id) mutableCopyWithZone:(struct _NSZone*)arg1; (0x1905708ac)
- (id) copyWithZone:(struct _NSZone*)arg1; (0x190475edc)
- (id) copy; (0x19053ae88)
- (unsigned long) retainCount; (0x1905705f4)
- (const unsigned short*) _fastCharacterContents; (0x190570600)
- (const char*) cString; (0x190570604)
- (void) appendFormat:(id)arg1; (0x19046ca78)
- (const char*) cStringUsingEncoding:(unsigned long)arg1; (0x19047d11c)
- (BOOL) getCString:(char*)arg1 maxLength:(unsigned long)arg2 encoding:(unsigned long)arg3; (0x190476698)
- (void) getLineStart:(unsigned long*)arg1 end:(unsigned long*)arg2 contentsEnd:(unsigned long*)arg3 forRange:(struct _NSRange)arg4; (0x190570784)
- (BOOL) hasPrefix:(id)arg1; (0x19044f968)
(NSMutableString ...)
可以看到,__NSCFString 是不存在 stringByAppendingString: 方法的。我们从头梳理一下这里 MethodSwizzle 的流程,就可以发现问题所在:
1. 第三方 SDK 为 NSString 添加了一个 MethodSwizzle 方法,由于命名重合,走到了 K 歌侧逻辑,所以添加了方法名为 ksSafe_StringByAppendingString:的安全实现,我们记为IMP B,将其与OC 原生的 stringByAppendingString: 实现(记为IMP A)进行了实现交换,当前场景下NSString 的方法映射关系是:
<NSString>:
- stringByAppendingString:(IMP B)
- ksSafe_StringByAppendingString:(IMP A)
2. K 歌针对 __NSCFString 做了MethodSwizzle,首先取一下自己的 stringByAppendingString:方法实现,由于自己并没有这个方法,会向前取父类的实现,同时由于父类的方法被交换了,所以取到了IMP B
3. __NSCFString 尝试给自己添加一个 stringByAppendingString:的安全实现(记为IMP C),由于__NSCFString 本身没有这个实现,所以方法添加成功。在方法添加成功的前提下,方法交换的逻辑变更为将步骤2中取得的父类 stringByAppendingString 实现(即 IMP B )添加到自己的 ksSafe_StringByAppendingString: 方法中,这样就能完成子类的方法交换, 此时 __NSCFString 的方法映射关系是:
<__NSCFString>:
- stringByAppendingString:(IMP C)
- ksSafe_StringByAppendingString:(IMP B)
4. 至此, __NSCFString 完成了自己的MethodSwizzle。这里应该注意的是,IMP B 和 IMP C 本质上都是K 歌为 NSString 添加的 ksSafe_StringByAppendingString: 的安全实现,其底层是同一套代码实现,当传入的参数不为空时,他们都会尝试调用 ksSafe_StringByAppendingString: 这个 SEL。
所以当用户调用到 __NSCFString 的 stringByAppendingString 方法时,调用链路变成了:
__NSCFString
-> stringByAppendingString
-> IMP C
-> ksSafe_StringByAppendingString
-> IMP B
-> ksSafe_StringByAppendingString
-> IMP B ...
最终变成了一个死循环,导致爆栈
方法交替过程如下动画所示:
四、解决方案
首先这里最直接的原因肯定还是由于 MethodSwizzle方法名重复,导致第三方 SDK方法调用到了 K 歌的实现,导致添加的 swizzleMethod 的方法名也出现了重复导致的。
但同时,__NSCFString 作为系统的私有方法,本身并不存在 stringByAppendingString: 方法的实现,是靠父类方法来实现的,在这种情况下也不应该针对其做 MethodSwizzle。
最终的解决方案:
1. 修改K 歌侧 MethodSwizzle 方法名,新增一个 KS 前缀
2. 去掉针对__NSCFString 的stringByAppendingString方法的 MethodSwizzle
五、 复盘及总结
这次的问题排查,暴露了团队在一些开发上的问题。
首先是针对系统类做 Category 新增方法时,需要养成给方法添加方法前缀的习惯,来避免方法命名重复导致的方法实现被覆盖的问题。
其次,针对Object-C 原生类实现代码防护,是一个非常危险的事情,由于Object-C 的相对闭源,我们很容易在实现的过程中,根据经验来判断代码的编写逻辑,但是在一些细节的地方产生遗漏,K 歌在前阵子也有一例类似的案例:。如果确实要对系统类做代码防护,需要额外的关注这个类是否是类簇,其具体实现的子类是否包含了想要做 MethodSwizzle 的方法,是否适合 swizzle,从而避免类似的问题。
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...