iOS之深入解析野指针检测的原理及实现

Posted Forever_wj

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了iOS之深入解析野指针检测的原理及实现相关的知识,希望对你有一定的参考价值。

一、异常

① 概念
  • 异常大致可以分为两类:
    • 软件异常:主要是来自 kill(),pthread_kill(),ios 中的 NSException 未捕获,absort 等;
    • 硬件异常:硬件的信号始于处理器 trap,是和平台相关的,野指针崩溃大部分是硬件异常。
  • 处理异常时,如果 Mach 异常,则 Mach 层捕获;如果 UNIX 信号异常,则 BSD 层获取。
  • iOS 中的 POSIX API 是通过 Mach 之上的 BSD 层实现的,如下图所示:
    在这里插入图片描述
  • 说明:
    • Mach 是一个受 Accent 启发而搞出的 Unix 兼容系统;
    • BSD 层是建立在 Mach 之上,是 XNU 中一个不可分割的一部分,BSD 负责提供可靠的、现代的 API;
    • POSIX 表示可移植操作系统接口(Portable Operating System Interface)。
  • 因此,Mach 异常和 UNIX 信号存在对应的关系如下:

在这里插入图片描述

  • Mach 异常和 UNIX 信号说明:
    • 硬件异常流程:硬件异常 -> Mach 异常 -> UNIX 信号;
    • 软件异常流程:软件异常 -> UNIX 信号。
② Mach 异常与 UNIX 信号的转换
  • Mach 异常与 UNIX 信号的转换代码(xnu 中的 bsd/uxkern/ux_exception.c)如下:
	switch(exception) {
	case EXC_BAD_ACCESS:
	    if (code == KERN_INVALID_ADDRESS)
	        *ux_signal = SIGSEGV;
	    else
	        *ux_signal = SIGBUS;
	    break;
	
	case EXC_BAD_INSTRUCTION:
	    *ux_signal = SIGILL;
	    break;
	
	case EXC_ARITHMETIC:
	    *ux_signal = SIGFPE;
	    break;
	
	case EXC_EMULATION:
	    *ux_signal = SIGEMT;
	    break;
	
	case EXC_SOFTWARE:
	    switch (code) {
	
	    case EXC_UNIX_BAD_SYSCALL:
	    *ux_signal = SIGSYS;
	    break;
	    case EXC_UNIX_BAD_PIPE:
	    *ux_signal = SIGPIPE;
	    break;
	    case EXC_UNIX_ABORT:
	    *ux_signal = SIGABRT;
	    break;
	    case EXC_SOFT_SIGNAL:
	    *ux_signal = SIGKILL;
	    break;
	    }
	    break;
	
	case EXC_BREAKPOINT:
	    *ux_signal = SIGTRAP;
	    break;
	}
  • Mach 异常与 UNIX 信号转换的对应关系如下表所示:
Mach异常Mach异常代码UNIX信号
EXC_BAD_ACCESS
不能访问的内存
KERN_INVALID_ADDRESSSIGSEGV:段错误
-SIGBUS:总线错误
EXC_BAD_INSTRUCTION
非法或未定义的指令或操作数
KERN_INVALID_ADDRESSSIGILL
执行了非法指令
EXC_ARITHMETIC
算术异常,iOS默认不开启
-SIGFPE
致命的算术运算
EXC_EMULATION
执行打算用于支持仿真的指令
-SIGEMT:仿真陷阱
EXC_SOFTWARE
软件生成的异常
EXC_UNIX_ABORTSIGABRT:调用abort()产生
EXC_UNIX_BAD_PIPESIGPIPE:管道破裂
EXC_UNIX_BAD_SYSCALLSIGSYS:系统调用异常
EXC_SOFT_SIGNALSIGKILL:系统终止线程
EXC_BREAKPOINT
跟踪或断点
-SIGTRAP
断点指令或者其他trap指令产生
  • Mach 异常有以下类型:
Mach异常说明
EXC_BAD_ACCESS不能访问的内存
EXC_BAD_INSTRUCTION非法或未定义的指令或操作数
EXC_ARITHMETIC算术异常(例如除以0)。iOS 默认是不启用的,所以我们一般不会遇到
EXC_EMULATION执行打算用于支持仿真的指令
EXC_SOFTWARE软件生成的异常,在 Crash 日志中一般不会看到这个类型,苹果的日志里会是 EXC_CRASH
EXC_BREAKPOINT跟踪或断点
EXC_SYSCALLUNIX系统调用
EXC_MACH_SYSCALLMach 系统调用
  • UNIX 信号有以下类型:
UNIX信号说明
SIGSEGV段错误。访问未分配内存、写入没有写权限的内存等
SIGBUS总线错误。比如内存地址对齐、错误的内存类型访问等
SIGILL执行了非法指令,一般是可执行文件出现了错误
SIGFPE致命的算术运算。比如数值溢出、NaN数值等
SIGABRT调用 abort() 产生,通过 pthread_kill() 发送
SIGPIPE管道破裂。通常在进程间通信产生。比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。根据苹果相关文档,可以忽略这个信号。
SIGSYS系统调用异常
SIGKILL此信号表示系统中止进程。崩溃报告会包含代表中止原因的编码。exit(), kill(9) 等函数调用。iOS 系统杀进程,如 watchDog 杀进程
SIGTRAP断点指令或者其他trap指令产生

二、野指针

① 概念
  • 野指针又叫悬挂指针(Dangling Pointer),就是当指针指向的对象已经释放或回收后,但没有对指针做任何修改(一般来说,将它指向空指针),而是仍然指向原来已经回收的地址。
  • 如果指针指向的对象已经释放,但仍然使用,那么就会导致程序 crash。
② 野指针分类

在这里插入图片描述

③ 为什么 OC 野指针的 crash 多?
  • 一般在 App 发版前,都会经过多轮的自测、内侧、灰度测试等,按照常理来说,大部分的 crash 应该都被覆盖了,但是由于野指针的随机性,使得经常在测试时不会出现 crash,而是在线上出现 crash,这对 App 体验来说是非常致命的。
  • 野指针的随机性问题大致可以分为两类:
    • 不执行出错的逻辑,执行不到出错的代码,可以通过提高测试场景覆盖率来解决;
    • 执行有问题的逻辑,但是野指针指向的地址并不一定会导致 crash,这是因为野指针其本质是一个指向已经删除的对象或受限内存区域的指针(这里说的 OC 野指针,是指 OC 对象释放后指针未置空而导致的野指针)。这里不必现的原因是因为 dealloc 执行后通知系统,内存不再使用,然后系统并没有让这片内存不能再被访问。

三、野指针的检测和定位

① Malloc Scribble
  • Malloc Scribble 的官方解释:申请内存 alloc 时在内存上设置 0xAA,释放内存 dealloc 时在内存上设置 0x55。如下所示:

在这里插入图片描述

  • 当访问到对象内存中设置的是 0xAA、0x55 时,程序就会出现异常,即申请内存 alloc 时在内存上设置 0xAA,释放内存 dealloc 在内存上设置 0x55。
  • 申请和释放内存的设置分别对应:申请时没有做初始化就直接被访问,释放时释放之后访问。因此,针对野指针,在对象释放时做数据设置 0x55 即可。
  • 根据腾讯Bugly工程师的MySampleCode的分享,野指针探测的思路如下:
    • 通过 fishhook 替换 C 函数的 free 方法为自定义的 safe_free,类似于 Method Swizzling;
    • 在 safe_free 方法中对已经释放变量的内存,设置 0x55,使已经释放变量不能访问,从而使某些野指针的 crash 从“不必现”变成“必现”。
      • 为了防止设置 0x55 的内存被新的数据内容填充,使野指针 crash 变成不必现,在这里采用的策略是,safe_free 不释放这片内存,而是自己保留着,即 safe_free 方法中不会真的调用 free;
      • 同时为了防止系统内存过快消耗(因为要保留内存),需要在保留的内存大于一定值时释放一部分,防止被系统杀死,同时,在收到系统内存警告时,也需要释放一部分内存。
    • 发生 crash 时,得到的崩溃信息有限,不利于问题排查,因此采用代理类(即继承自 NSProxy 的子类),重写消息转发的三个方法以及 NSObject 的实例方法,来获取异常信息。但是这样的话,还有一个问题,就是 NSProxy 只能做 OC 对象的代理,所以需要在 safe_free 中增加对象类型的判断。
  • 野指针的探测实现:
    • 引入 fishhook:

在这里插入图片描述

    • 实现 NSProxy 的代理子类:
	// MIZombieProxy.h
	@interface MIZombieProxy : NSProxy
	
	@property (nonatomic, assign) Class originClass;
	
	@end
	
	// MIZombieProxy.m
	#import "MIZombieProxy.h"
	
	@implementation MIZombieProxy
	
	- (BOOL)respondsToSelector:(SEL)aSelector{
	    return [self.originClass instancesRespondToSelector:aSelector];
	}
	
	- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel{
	    return [self.originClass instanceMethodSignatureForSelector:sel];
	}
	
	- (void)forwardInvocation: (NSInvocation *)invocation
	{
	    [self _throwMessageSentExceptionWithSelector: invocation.selector];
	}
	
	#define MIZombieThrowMesssageSentException() [self _throwMessageSentExceptionWithSelector: _cmd]
	- (Class)class{
	    MIZombieThrowMesssageSentException();
	    return nil;
	}
	- (BOOL)isEqual:(id)object{
	    MIZombieThrowMesssageSentException();
	    return NO;
	}
	- (NSUInteger)hash{
	    MIZombieThrowMesssageSentException();
	    return 0;
	}
	- (id)self{
	    MIZombieThrowMesssageSentException();
	    return nil;
	}
	- (BOOL)isKindOfClass:(Class)aClass{
	    MIZombieThrowMesssageSentException();
	    return NO;
	}
	- (BOOL)isMemberOfClass:(Class)aClass{
	    MIZombieThrowMesssageSentException();
	    return NO;
	}
	- (BOOL)conformsToProtocol:(Protocol *)aProtocol{
	    MIZombieThrowMesssageSentException();
	    return NO;
	}
	- (BOOL)isProxy{
	    MIZombieThrowMesssageSentException();
	    return NO;
	}
	
	- (NSString *)description{
	    MIZombieThrowMesssageSentException();
	    return nil;
	}
	
	#pragma mark - MRC
	- (instancetype)retain{
	    MIZombieThrowMesssageSentException();
	    return  nil;
	}
	- (oneway void)release{
	    MIZombieThrowMesssageSentException();
	}
	- (void)dealloc
	{
	    MIZombieThrowMesssageSentException();
	    [super dealloc];
	}
	- (NSUInteger)retainCount{
	    MIZombieThrowMesssageSentException();
	    return 0;
	}
	- (struct _NSZone *)zone{
	    MIZombieThrowMesssageSentException();
	    return  nil;
	}
	
	
	#pragma mark - private
	- (void)_throwMessageSentExceptionWithSelector:(SEL)selector{
	    @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:[NSString stringWithFormat:@"(-[%@ %@]) was sent to a zombie object at address: %p", NSStringFromClass(self.originClass),NSStringFromSelector(selector), self] userInfo:nil];
	}
	@end
    • hook free 方法的实现:
	// MISafeFree.h
	@interface MISafeFree : NSObject
	
	//系统警告时,用函数释放一些内存
	void free_safe_mem(size_t freeNum);
	
	@end
	
	// MISafeFree.m
	#import "MISafeFree.h"
	#import "queue.h"
	#import "fishhook.h"
	#import "MIZombieProxy.h"
	
	#import <dlfcn.h>
	#import <objc/runtime.h>
	#import <malloc/malloc.h>
	
	//用于保存zombie类
	static Class kMIZombieIsa;
	//用于保存zombie类的实例变量大小
	static size_t kMIZombieSize;
	
	//用于表示调用free函数
	static void(* orig_free)(void *p);
	//用于保存已注册的类的集合
	static CFMutableSetRef registeredClasses = nil;
	/*
	 用来保存自己保留的内存
	 - 1、队列要线程安全或者自己加锁
	 - 2、这个队列内部应该尽量少申请和释放堆内存
	 */
	struct DSQueue *_unfreeQueue = NULL;
	//用来记录自己保存的内存的大小
	int unfreeSize = 0;
	
	//最多存储的内存,大于这个值就释放一部分
	#define MAX_STEAL_MEM_SIZE 1024*1024*100
	//最多保留的指针个数,超过就释放一部分
	#define MAX_STEAL_MEM_NUM 1024*1024*10
	//每次释放时释放的指针数量
	#define BATCH_FREE_NUM 100
	
	@implementation MISafeFree
	
	#pragma mark - Public Method
	//系统警告时,用函数释放一些内存
	void free_safe_mem(size_t freeNum){
	#ifdef DEBUG
	    //获取队列的长度
	    size_t count = ds_queue_length(_unfreeQueue);
	    //需要释放的内存大小
	    freeNum = freeNum > count ? count : freeNum;
	    //遍历并释放
	    for (int i = 0; i < freeNum; i++) {
	        //获取未释放的内存块
	        void *unfreePoint = ds_queue_get(_unfreeQueue);
	        //创建内存块申请的大小
	        size_t memSize = malloc_size(unfreePoint);
	        //原子减操作,多线程对全局变量进行自减
	        __sync_fetch_and_sub(&unfreeSize, (int)memSize);
	        //释放
	        orig_free(unfreePoint);
	    }
	#endif
	}
	
	#pragma mark - Life Circle
	
	+ (void)load{
	#ifdef DEBUG
	    loadZombieProxyClass();
	    init_safe_free();
	#endif
	}
	
	#pragma mark - Private Method
	void safe_free(void* p){
	    
	    //获取自己保留的内存的大小
	    int unFreeCount = ds_queue_length(_unfreeQueue);
	    //保留的内存大于一定值时就释放一部分
	    if (unFreeCount > MAX_STEAL_MEM_NUM*0.9 || unfreeSize>MAX_STEAL_MEM_SIZE) {
	        free_safe_mem(BATCH_FREE_NUM);
	    }else{
	        //创建p申请的内存大小
	        size_t memSize = malloc_size(p);
	        //有足够的空间才覆盖
	        if (memSize > kMIZombieSize) {
	            //指针强转为id对象
	            id obj = (id)p;
	            //获取指针原本的类
	            Class origClass = object_getClass(obj);
	            //判断是不是objc对象
	            char *type = @encode(typeof(obj));
	            /*
	             - strcmp 字符串比较
	             - CFSetContainsValue 查看已注册类中是否有origClass这个类
	             
	             如果都满足,则将这块内存填充0x55
	             */
	            if (strcmp("@", type) == 0 && CFSetContainsValue(registeredClasses, origClass)) {
	                //内存上填充0x55
	                memset(obj, 0x55, memSize);
	                //将自己类的isa复制过去
	                memcpy(obj, &kMIZombieIsa, sizeof(void*));
	                //为obj设置指定的类
	                object_setClass(obj, [MIZombieProxy class]);
	                //保留obj原本的类
	                ((MIZombieProxy*)obj).originClass = origClass;
	                //多线程下int的原子加操作,多线程对全局变量进行自加,不用理会线程锁了
	                __sync_fetch_and_add(&unfreeSize, (int)memSize);
	                //入队
	                ds_queue_put(_unfreeQueue, p);
	            }else{
	                orig_free(p);
	            }
	        }else{
	            orig_free(p);
	        }
	    }
	}
	
	// 加载野指针自定义类
	void loadZombieProxyClass(){
	    registeredClasses = CFSetCreateMutable(NULL, 0, NULL);
	    
	    //用于保存已注册类的个数
	    unsigned int count = 0;
	    //获取所有已注册的类
	    Class *classes = objc_copyClassList(&count);
	    //遍历,并保存到registeredClasses中
	    for (int i = 0; i < count; i++) {
	        CFSetAddValue(registeredClasses, (__bridge const void *)(classes[i]));
	    }
	    //释放临时变量内存
	    free(classes);
	    classes = NULL;
	    
	    kMIZombieIsa = objc_getClass("MIZombieProxy");
	    kMIZombieSize = class_getInstanceSize(kMIZombieIsa);
	}
	
	//初始化以及free符号重绑定
	bool init_safe_free(){
	    //初始化用于保存内存的队列
	    _unfreeQueue = ds_queue_create(MAX_STEAL_MEM_NUM);
	    //dlsym 在打开的库中查找符号的值,即动态调用free函数
	    orig_free = (void(*)(void*))dlsym(RTLD_DEFAULT, "free");
	    /*
	     rebind_symbols:符号重绑定
	     - 参数1:rebindings 是一个rebinding数组,其定义如下
	         struct rebinding {
	           const char *name;  // 目标符号名
	           void *replacement; // 要替换的符号值(地址值)
	           void **replaced;   // 用来存放原来的符号值(地址值)
	         };
	     - 参数2:rebindings_nel 描述数组的长度
	     */
	    //重绑定free符号,让它指向自定义的safe_free函数
	    rebind_symbols((struct rebinding[]){{"free", (void*)safe_free}}, 1);
	    return true;
	}
	
	@end
    • 使用示例:
	@property (nonatomic, assign) id assignObj;
	
	- (void)viewDidLoad {
	    [super viewDidLoad];
	    
	    id obj = [[NSObject alloc] init];
	    self.assignObj = obj;
	    
	//    [MIZombieSniffer installSniffer];
	}
	- (IBAction)mallocScribbleAction:(id)sender {
	    
	    UIView *testObj = [[UIView alloc] init];
	    [testObj release];
	    for (int i = 0; i < 10; i++) {
	        UIView *testView = [[UIView alloc] initWithFrame:CGRectMake(0, 200, CGRectGetWidth(self.view.bounds), 60)];
	        [self.view addSubview:testView];
	    }
	    [testObj setNeedsLayout];
	}
    • 结果如下:
	- ([UIView setNeedsLayout]) was send to a zombie object at address:0x7f8b...
② Zombie Objects
  • Zombie Objects官方解释:一个对象已经解除了它的引用,已经被释放掉,但是此时仍然是可以接受消息,这个对象就叫做 Zombie Objects(僵尸对象),即为将释放的对象,全都转为僵尸对象。如下:
	Once an Objective-C or Swift object no longer has any strong references to it, the object is deallocated. Attempting to further send messages to the object as if it were still a valid object is a “use after free” issue, with the deallocated object still receiving messages called a zombie object.
  • Zombie Objects可以用来检测内存错误(EXC_BAD_ACCESS)的对象,它可以捕获任何尝试访问坏内存的调用。
  • 如果给僵尸对象发送消息的话,它仍然是可以响应的,然后会发生崩溃,并输出错误日志来显示野指针对象调用的类名和方法。
  • 开启 Zombie Object 检测:在 Xcode 中设置 Edit Scheme -> Diagnostics -> Zombie Objects。
  • 开启 Zombie Object 检测后,对象调用 dealloc 方法会发生变化:
    • 新建一个终端工程(Command Line Tool),具体代码如下:
	void printClassInfo(id obj) {
	    Class cls = object_getClass(obj);
	    Class superCls = class_getSuperclass(cls);
	    NSLog(@"self:%s - superClass:%s", class_getName(cls), class_getName(superCls));
	}
	
	int main(int argc, const char * argv[]) {
	    @autoreleasepool {
	        
	        People *aPeople = [People new];
	        NSLog(@"before release!");
	        printClassInfo(aPeople);
	        [aPeople release];
	        
	        NSLog(@"after release!");
	        printClassInfo(aPeople);
	    }
	    return 0;
	}
    • 开启Zombie Objects,运行程序,查看打印信息。从打印信息可以看到开启僵尸对象检测后,People 释放后所属类变成了 _NSZombie_People,可得对象释放后会变成僵尸对象,保存当前释放对象的内存地址,防止被系统回收。
	ZombieObjectDemo[1357:84410] before release!
	ZombieObjectDemo[1357:84410] self:People - superClass:NSObject
	ZombieObjectDemo[1357:84410] after release!
	Zom

以上是关于iOS之深入解析野指针检测的原理及实现的主要内容,如果未能解决你的问题,请参考以下文章

iOS之深入解析malloc的底层原理

iOS之深入解析编译过程的原理与应用

iOS之深入解析内存管理的引用计数retainCount的底层原理

C之内存操作经典问题解析(三十七)

iOS之深入解析单例的实现和销毁的底层原理

iOS之深入解析KVC的底层原理和自定义KVC的实现