[NSMutableAttributedString initWithData:options:documentAttributes:error] 崩溃分析

96
Zsj_Sky
1.3 2018.12.26 19:16* 字数 2111

背景

项目中使用了 [NSMutableAttributedString initWithData:options:documentAttributes:error] 来解析 HTML 字符串,但是在线上检测到了很多该方法崩溃的记录,如下图

线上崩溃堆栈

定位问题

由于在开发环境一直没办法复现,所以只能直接分析线上的崩溃记录。

范围界定

一般来说,会先查看是否是某个机型或iOS 系统版本的问题,但是该问题几乎涉及到所有的机型,版本号也是从 iOS 8 到 最新的 iOS 12,所以没办法从这 2 方面缩小范围。从上报的崩溃记录上看,崩溃的主要原因是SEGV_ACCERR,也就是常说的野指针访问。一般来说,发生在访问一个已经被释放的内存地址时,会导致该问题。

堆栈分析

从堆栈上看,都是崩溃在

libobjc.A.dylib objc_msgSend + 16 

这一个方法调用中,虽然 objc_msgSend 方法苹果没有开源,但是我们可以通过使用 symbolic breakpoint 来断点该方法,查看其汇编的调用如下(这里涉及到一些汇编知识):

libobjc.A.dylib`objc_msgSend:
->  0x1c0eccd60 <+0>:   cmp    x0, #0x0                  ; =0x0 
    0x1c0eccd64 <+4>:   b.le   0x1c0eccdcc               ; <+108>
    0x1c0eccd68 <+8>:   ldr    x13, [x0]
    0x1c0eccd6c <+12>:  and    x16, x13, #0xffffffff8
    0x1c0eccd70 <+16>:  ldp    x10, x11, [x16, #0x10]
    0x1c0eccd74 <+20>:  and    w12, w1, w11
    0x1c0eccd78 <+24>:  add    x12, x10, x12, lsl #4
    0x1c0eccd7c <+28>:  ldp    x17, x9, [x12]
    0x1c0eccd80 <+32>:  cmp    x9, x1
    0x1c0eccd84 <+36>:  b.ne   0x1c0eccd8c               ; <+44>

由于崩溃发生在偏移值为 <+16> 也就是第 5 行的位置,所以只需要分析前 5 行汇编究竟做了什么就可以了,下面我们一行一行进行分析。

0x1c0eccd60 <+0>:   cmp    x0, #0x0
0x1c0eccd64 <+4>:   b.le   0x1c0eccdcc               ; <+108>

这里涉及到 2 个汇编指令, cmpb.lecmpcompare 的缩写也就是比较的意思,而 b.leBranch if Less than or Equal 的缩写,如果上一条指令 cmp 执行的结果是小于或等于则进行跳转。 一般来说 arm64 上 x0x7 分别会存放方法的前 8 个参数,如果参数个数超过了8个,多余的参数会存在栈上,新方法会通过栈来读取。而返回值一般都在 x0 中。

所以上面 2 条汇编指令的大概意思是:将 objc_msgSend 的第一个参数和 0 进行比较,如果值小于或等于 0,则跳转到地址 0x1c0eccdcc,而 objc_msgSend 的第一个参数其实就是 self,所以这里是判断 self 是否是 nil,如果是 nil 就直接跳转到 0x1c0eccdcc, 0x1c0eccdcc 这里没有写出来,可以理解成 objc_msgSend 的结束位置(不是很恰当)。

 0x1c0eccd68 <+8>:   ldr    x13, [x0]

ldr 是读取指令,是指从存取器中读取加载到寄存器中。所以上面的指令加载寄存器 x0 指向的内容到寄存器 x13 中。如果将 x0 理解成 c 语言中的指针,上面的指令可以理解成 x13 = *x0;


0x1c0eccd6c <+12>:  and    x16, x13, #0xffffffff8

and 指令,就是 x16 = x13 & #0xffffffff8,很简单的算法指令。

0x1c0eccd70 <+16>:  ldp    x10, x11, [x16, #0x10]

该指令也就是发生崩溃的地方,也是一条加载指令 ldp,是指 从 x16 + 0x10 指向的地址里面取出 2 个 64 位的数,分别存入 x10, x11。在执行该指令的时候,出现了野指针错误,虽然没有具体的源码,但是我们反推 x16 地址的来源来缩小范围,x16 来自于 x13,而 x13 又来自于 x0,所以也就是说,是在对 objc_msgSend 第一个参数进行操作时导致产生野指针崩溃。

到这里,objc_msgSend 已经没有什么可以分析的内容了,下一步是分析

1   WebKitLegacy -[_WebSafeForwarder forwardInvocation:] + 132

同样的办法,我们在 [_WebSafeForwarder forwardInvocation:] 打一个断点,直接跳到 <+ 132 > 位置进行分析,其内容如下:

WebKitLegacy`-[_WebSafeForwarder forwardInvocation:]:
->  0x1cbed13fc <+0>:   stp    x24, x23, [sp, #-0x40]!
    0x1cbed1400 <+4>:   stp    x22, x21, [sp, #0x10]
    0x1cbed1404 <+8>:   stp    x20, x19, [sp, #0x20]
    0x1cbed1408 <+12>:  stp    x29, x30, [sp, #0x30]
    0x1cbed140c <+16>:  add    x29, sp, #0x30            ; =0x30 
    0x1cbed1410 <+20>:  mov    x19, x2
    0x1cbed1414 <+24>:  mov    x21, x0
    0x1cbed1418 <+28>:  bl     0x1caadcd1c               ; WebThreadIsCurrent
    0x1cbed141c <+32>:  cbz    w0, 0x1cbed1448           ; <+76>
    0x1cbed1420 <+36>:  adrp   x8, 145109
    0x1cbed1424 <+40>:  add    x1, x8, #0xe2a            ; =0xe2a 
    0x1cbed1428 <+44>:  mov    x0, x19
    0x1cbed142c <+48>:  bl     0x1c8d16378
    0x1cbed1430 <+52>:  mov    x0, x19
    0x1cbed1434 <+56>:  ldp    x29, x30, [sp, #0x30]
    0x1cbed1438 <+60>:  ldp    x20, x19, [sp, #0x20]
    0x1cbed143c <+64>:  ldp    x22, x21, [sp, #0x10]
    0x1cbed1440 <+68>:  ldp    x24, x23, [sp], #0x40
    0x1cbed1444 <+72>:  b      0x1caadd294               ; WebThreadCallDelegate
    0x1cbed1448 <+76>:  adrp   x8, 187655
    0x1cbed144c <+80>:  ldrsw  x24, [x8, #0x428]
    0x1cbed1450 <+84>:  ldr    x23, [x21, x24]
    0x1cbed1454 <+88>:  adrp   x8, 145010
    0x1cbed1458 <+92>:  add    x20, x8, #0x6e0           ; =0x6e0 
    0x1cbed145c <+96>:  mov    x0, x19
    0x1cbed1460 <+100>: mov    x1, x20
    0x1cbed1464 <+104>: bl     0x1c8d16378
    0x1cbed1468 <+108>: mov    x2, x0
    0x1cbed146c <+112>: adrp   x8, 145009
    0x1cbed1470 <+116>: add    x22, x8, #0x5e0           ; =0x5e0 
    0x1cbed1474 <+120>: mov    x0, x23
    0x1cbed1478 <+124>: mov    x1, x22
    0x1cbed147c <+128>: bl     0x1c8d16378
    0x1cbed1480 <+132>: cbz    w0, 0x1cbed149c           ; <+160>
    

这次我们从后往前分析,由于汇编语言的特性(这里就不详细讲解),虽然崩溃是指向<+132> ,但实际上在调用上一行汇编指令导致的崩溃,所以我们直接从 <+128> 开始分析。

 0x1cbed1474 <+120>: mov    x0, x23 
 0x1cbed1478 <+124>: mov    x1, x22
 0x1cbed147c <+128>: bl     0x1c8d16378
 

上面 3 行指令实际上是一个函数的调用过程,<+120><+124> 是将函数的入参保存到寄存器 x0x1 中, <+128> 跳转到指定的地址,也就是调用函数。该函数就是我们上面分析的 objc_msgSend 的函数,由于我们上面已经分析得出,是由于第一个参数,也就是寄存器中的值 x0 出现问题,导致了野指针错误,所以我们直接按照 objc_msgSend 分析思路,分析 x0 的来源,下面列出了 x0 相关的几条指令

WebKitLegacy`-[_WebSafeForwarder forwardInvocation:]:
->  ...
    0x1cbed1414 <+24>:  mov    x21, x0 // 将 x0 的赋值给 x21
    .....
    0x1cbed1448 <+76>:  adrp   x8, 187655 // 读取 pc + 187655 地址的内容
    0x1cbed144c <+80>:  ldrsw  x24, [x8, #0x428] // 加载 x8 + 0x428 地址的内容到 x24中
    0x1cbed1450 <+84>:  ldr    x23, [x21, x24] // 加载 x21 + x24 地址的内容到 x23 中
   
    ....
    0x1cbed1474 <+120>: mov    x0, x23 // 将 x23 的值赋值给 x0
    0x1cbed1478 <+124>: mov    x1, x22
    0x1cbed147c <+128>: bl     0x1c8d16378
    0x1cbed1480 <+132>: cbz    w0, 0x1cbed149c           ; <+160>
    

从上面的精简指令中,我们可以知道,objc_msgSend 的第一个参数来自 x23, x23 来自 x21,而 x21 又是来自 x0, 也就是 forwardInvocation: 方法的第一个参数,实际上就是 _WebSafeForwarder的实例对象。

这里我们重点分析下 <+84> 这一行指令

    0x1cbed1450 <+84>:  ldr    x23, [x21, x24] // 加载 x21 + x24 地址的内容到 x23 中
    

该指令是取 x21 + x24 地址的内容,我们已经知道 x21_WebSafeForwarder 的实例,那么取一个实例地址某一个偏移值的内容,是否可以猜测是读取实例对象中的一个变量的值,我们可以直接在该指令位置打一个断点,查看此时 x21x24 的值。结果如下图:

x21和x24寄存器的值

发现此时的 x21 的确是 _WebSafeForwarder 的一个实例对象,而 x24 的值也很像一个变量的偏移值。由于已经知道是 _WebSafeForwarder 的实例,所以我们直接打印出其内部变量,如下图:

_WebSafeForwarder内部变量

可以发现 _WebSafeForwarder 对象有 4 个变量(忽略 isa),我们一个一个查看变量的偏移值,看是否有何 x24 的值匹配的变量偏移值。

_WebSafeForwarder变量偏移值

可以看出第一个变量 target 的偏移值就是 8,和 x24 寄存器相匹配,所以 objc_msgSend 中的第一个参数,实际上就是 _WebSafeForwardertarget 变量。而objc_msgSend的第二个变量是 SEL,我们也可以打印出来,如下图:

objc_msgSend的第二个变量

综上所诉,实际是在调用 [self.target respondsToSelector:] 时发生崩溃。

这里我们可以有一个大胆的推测,由于从汇编指令中看,对于 self.target 的取值是直接读取偏移值,而且没有调用 objc_loadWeakRetained 方法,所以 _WebSafeForwardertarget 应该不是 weak变量,而是一个 assign变量,所以可猜测是否是由于 target 所指向的地址已经被释放,导致访问 target 时发生了野指针错误。

复现问题

从上面的分析中,我们可以猜测是由于 _WebSafeForwardertarget 所指向的对象已经被释放掉,而 target 又没有被设置为 nil 导致程序奔溃,为了验证这个猜想,我们人为的制造一个 crash,将堆栈信息和线上崩溃的堆栈信息进行对比,如果是一致的,就可以确认是该原因导致的,复现代码如下:


CG_INLINE void
SwizzleMethod(Class _originClass, SEL _originSelector, Class _newClass, SEL _newSelector) {
    Method oriMethod = class_getInstanceMethod(_originClass, _originSelector);
    Method newMethod = class_getInstanceMethod(_newClass, _newSelector);
    
    class_addMethod(_originClass, _newSelector, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
    BOOL isAddedMethod = class_addMethod(_originClass, _originSelector, method_getImplementation(newMethod), method_getTypeEncoding(newMethod));
    
    if (isAddedMethod) {
        class_replaceMethod(_originClass, _newSelector, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
    } else {
        method_exchangeImplementations(oriMethod, newMethod);
    }
}

@interface NSObject (EPWebSafe_Private)


@end

@implementation NSObject (EPWebSafe)


+ (void)load {

    SwizzleMethod(NSClassFromString(@"_WebSafeForwarder"),NSSelectorFromString(@"forwardInvocation:"), self ,  @selector(safe_forwardInvocation:));
    
}


- (void)safe_forwardInvocation:(NSInvocation *)arg1 {
    
        
    if ([NSStringFromSelector(arg1.selector) isEqualToString:@"webView:willCloseFrame:"] && [NSThread isMainThread]) {
        @autoreleasepool {
        
            Class delegateClass = NSClassFromString(@"NSHTMLWebDelegate");
            id  newDelegate = [delegateClass new];
            object_setIvarValue(self, "target", newDelegate);
            
        }
    }
    return [self safe_forwardInvocation:arg1];
}

@end

复现的思路是,hook_WebSafeForwarderforwardInvocation:,在 safe_forwardInvocation: 中将 target 设置成一个临时变量,采用 @autoreleasepool 是为了模拟 target 指向的对象已经被释放,但是 target 并没有被设置为 nil 的现象。if ([NSStringFromSelector(arg1.selector) isEqualToString:@"webView:willCloseFrame:"] && [NSThread isMainThread]) 这个判断条件是由于 forwardInvocation: 会被用于很多种用处,添加上面的判断条件是为了保证 forwardInvocation: 签名调用堆栈和线上的保持一致。运行结果如下图:

人为复现堆栈信息

通过对比线上的崩溃堆栈,如下图

线上的崩溃堆栈

可以发现崩溃的堆栈信息是一模一样的,所以可以基本可以确定线上的崩溃就是由于该问题引起的。

解决问题

确定问题的根源后,就很好解决了,这里的解决方法参考了同事之前实现的一个防止 iOS8 上面 UIScrollView delegate 指向内容被释放后,还被调用导致的崩溃。实现思路可以查看链接 优雅解决 iOS 8 UIScrollView delegate EXC_BAD_ACCESS

具体代码如下:



#import "NSObject+EPWebSafe.h"

#define object_getIvarValue(object, name) object_getIvar(object, class_getInstanceVariable([object class], name))

#define object_setIvarValue(object, name, value) object_setIvar(object, class_getInstanceVariable([object class], name), value)

CG_INLINE void
SwizzleMethod(Class _originClass, SEL _originSelector, Class _newClass, SEL _newSelector) {
    Method oriMethod = class_getInstanceMethod(_originClass, _originSelector);
    Method newMethod = class_getInstanceMethod(_newClass, _newSelector);
    BOOL a = class_addMethod(_originClass, _newSelector, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
    BOOL isAddedMethod = class_addMethod(_originClass, _originSelector, method_getImplementation(newMethod), method_getTypeEncoding(newMethod));
    
    if (isAddedMethod) {
        class_replaceMethod(_originClass, _newSelector, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
    } else {
        method_exchangeImplementations(oriMethod, newMethod);
    }
}

@interface HtmlReleaseDelegateCleaner : NSObject
@property (nonatomic, strong) NSPointerArray *htmlDelegates;
@end

@implementation HtmlReleaseDelegateCleaner

- (void)dealloc {
    [self cleanHtmlDelegate];
}

- (void)recordHtmlDelegate:(id)htmlDelegate {
    NSUInteger index = [self.htmlDelegates.allObjects indexOfObject:htmlDelegate];
    if (index == NSNotFound) {
        [self.htmlDelegates addPointer:(__bridge void *)(htmlDelegate)];
    }
}

- (void)removeHtmlDelegate:(id )htmlDelegate {
    NSUInteger index = [self.htmlDelegates.allObjects indexOfObject:htmlDelegate];
    if (index != NSNotFound) {
        [self.htmlDelegates removePointerAtIndex:index];
    }
}

- (void)cleanHtmlDelegate {
    [self.htmlDelegates.allObjects enumerateObjectsUsingBlock:^(id htmlDelegate, NSUInteger idx, BOOL * _Nonnull stop) {
        if ([htmlDelegate isKindOfClass:NSClassFromString(@"_WebSafeForwarder")]) {
            object_setIvarValue(htmlDelegate, "target", nil);
        }
    }];
}

- (void)setHtmlDelegates:(NSMutableSet *)htmlDelegates {
    objc_setAssociatedObject(self, @selector(htmlDelegates), htmlDelegates, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSPointerArray *)htmlDelegates {
    NSPointerArray *htmlDelegates = objc_getAssociatedObject(self, _cmd);
    if (!htmlDelegates) {
        htmlDelegates = [NSPointerArray weakObjectsPointerArray];
        [self setHtmlDelegates:htmlDelegates];
    }
    return htmlDelegates;
}

@end


@interface NSObject (EPWebSafe_Private)

@property (nonatomic, readonly) HtmlReleaseDelegateCleaner *webDelegateCleaner;

@end

@implementation NSObject (EPWebSafe)

- (HtmlReleaseDelegateCleaner *)webDelegateCleaner {
    HtmlReleaseDelegateCleaner *cleaner = objc_getAssociatedObject(self, _cmd);
    if (!cleaner) {
        cleaner = [HtmlReleaseDelegateCleaner new];
        objc_setAssociatedObject(self, _cmd, cleaner, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    return cleaner;
}


+ (void)load {
    
    
   SwizzleMethod(NSClassFromString(@"_WebSafeForwarder"),NSSelectorFromString(@"initWithTarget:defaultTarget:"), self ,@selector(safe_initWithTarget:defaultTarget:));
    
    
}


- (id)safe_initWithTarget:(id)arg1 defaultTarget:(id)arg2 {
    
    
    if ([NSStringFromClass([arg1 class]) isEqualToString:@"NSHTMLWebDelegate"]) {
        
        [[arg1 webDelegateCleaner] recordHtmlDelegate: self];
        
    }

    return [self safe_initWithTarget:arg1 defaultTarget:arg2];
}



@end


参考文献

iOS开发同学的arm64汇编入门
优雅解决 iOS 8 UIScrollView delegate EXC_BAD_ACCESS
ARM(CM3)的汇编指令
在ARM汇编中,LDR用的比较多,现总结一下它的用法:

日记本
Web note ad 1