objc - NSString Category实现+initialize导致EXC_BAD_ACCESS

Tested on iOS 15.0 ~ 16.0

本文完整的示例项目代码放在小号Github上: iCategory-NSString

背景

最近同事线上发现个BUG,某些用户打开相册后会崩溃,Backtrace定位分析到是向PHAssetCollection.localizedTitle发送消息然后在 objc_msgSend +32EXC_BAD_ACCESS 了。

Apple Developer Forums - PHCollection localizedTitle crash in iOS 14.2.1 有人说把 NSString Category 的 +initialize 换成 +load 就解决了,这样确实也解决了。但如若是第三方实现了 +initialize 而我们改不了呢?或者有没别的解决方法呢?

Crash 的原因是因为 NSString Category 实现了 +initialize 方法后,会导致 NSTaggedPointerString 不启用了,objc_msgSend 里处理 Tagged Pointer 时,访问了未允许的内存地址, 于是就 EXC_BAD_ACCESS 了。

重现

重现步骤较简单:

  1. 打开 iPhone/iPad Photos APP,创建几个名字为英文或数字的比较短(字符长度小于11)的相簿Album,比如这里创建一个名叫 'Abc' 的 Album。

  2. 写一个 NSString Category,实现 +initialize 方法

    NSString+Category.h:

    @interface NSString (Category)
    
    @end
    

    NSString+Category.m:

    @implementation NSString (Category)
    
    + (void)initialize {
    	// empty now ...
    }
     
    @end
    
  3. 写个测试代码,向 PHAssetCollection.localizedTitle发送消息:

     for (PHAssetCollection *collection in userAlbums) {
         NSString *string = collection.localizedTitle;
         NSLog(@">>>>>>>>>>>> album name: %@", [string description]);
     }
    
  4. Crash with EXC_BAD_ACCESS:
    在这里插入图片描述

分析

iOS 官方 Photos 库没理由返回个野指针过来吧,这个 0x87a4c650b3180a9e 指针地址64位占满,且最高位是1,像 Tagged Pointer 了,用objc4的相关api来打印来验证一下,确认是否是Tagged Pointer ?

  1. 项目里引入 objc-internal.h (Link),此头文件可从苹果开发者网站获取 objc4 源码 复制出来,注释掉两个宏定义的错误,项目也就可以编译过去了

  2. 因为 objc-internal.h 里较多的inline函数,inline函数在编译时就embed了,编译后是没有这些函数symbol的,lldb里是不能直接调用的,所以我们在ObjcUtil.h/m时Wrap了一层常用的inline函数,方便在lldb里执行

  3. Crash on EXC_BAD_ACCESS 触发时:

	(lldb) p isTaggedPointer(string)
	(bool) $0 = true
	
	(lldb) p [ObjcUtil objcIsTaggedPointer:string]		# call _objc_isTaggedPointer
	(BOOL) $1 = YES
  1. 确实是 Tagged Pointer String 无疑了,事实上 objc_msgSend 里也是根据地址值小 0 来判断是否为 Tagged Pointer。

  2. 那么打印一下它的值是0x0000000006362413,对照ASCII表,十六进制63代表字母c62代表字母b41代表字母A,最后的3代表长度。'Abc' 这个也是之前创建Album时输入的短名字。

	(lldb) p/x [ObjcUtil objcGetTaggedPointerValue:string]	# _objc_getTaggedPointerValue
	(long) $2 = 0x0000000006362413
	
	(lldb) p [ObjcUtil objcDecodeTaggedPointerString:string]
	(char *) $4 = "Abc"

验证

上面已经给出结论:在 iOS 15.0 - 15.5(已测试的)版本中, NSString 的任一类别实现了 +initialize,会导致 NSTaggedPointerString 不启用了。

1.

  • 之前写的一篇文章 objc - Category中调回主类的同名原方法 里,我们知道 Category 的方法会被 objc runtime 处理并放在方法列表的前面

  • 在这里,App的 +[NSString(Category) initialize]在前就被调用了,而 Foundation.framework 里的 +[NSString initialize] 就会没有被调用,因为 objc runtime 在方法列表里优先找到了 Category 里的同名方法来调用

  • 那么,我们可以在 +[NSString(Category) initialize] 里主动调一下原方法,来验证 NSTaggedPointerString 是在 +[NSString initialize] 里被启用了的这个想法:

    NSString+Category.m:

    @implementation NSString (Category)
    
    + (void)initialize
    {
        // Call original +initialize method on self (NSString class)
       [ObjcUtil invokeOriginalMethod:self selector:_cmd];
    }
    
    @end
    

    ObjcUtil.m:

    @implementation ObjcUtil
    
    // call the origin method in the method list
    + (void)invokeOriginalMethod:(id)target selector:(SEL)selector {
        Class clazz = [target class];
        Class metaClazz = objc_getMetaClass(class_getName(clazz));
        NSLog(@"Category ---> class: %@, metaClass: %@", clazz, metaClazz);
        
        // Get the instance method list in class
        uint instCount;
        Method *instMethodList = class_copyMethodList(clazz, &instCount);
        for (int i = 0; i < instCount; i++) {
            NSLog(@"Category instance selector : %d %@", i, NSStringFromSelector(method_getName(instMethodList[i])));
        }
        
        // Get the class method list in meta class
        uint metaCount;
        Method *metaMethodList = class_copyMethodList(metaClazz, &metaCount);
        for (int i = 0; i < metaCount; i++) {
            NSLog(@"Category class selector : %d %@", i, NSStringFromSelector(method_getName(metaMethodList[i])));
        }
        
        NSLog(@"Category ---> instance method count: %d, class method count: %d", instCount, metaCount);
        
        // Call original instance method. Note here take the last same name method as the original method
        for ( int i = instCount - 1 ; i >= 0; i--) {
            Method method = instMethodList[i];
            SEL name = method_getName(method);
            IMP implementation = method_getImplementation(method);
            if (name == selector) {
                NSLog(@"Category instance method found & call original ~~~");
                ((void (*)(id, SEL))implementation)(target, name); // id (*IMP)(id, SEL, ...)
                break;
            }
        }
        free(instMethodList);
        
        // Call original class method. Note here take the last same name method as the original method
        for ( int i = metaCount - 1 ; i >= 0; i--) {
            Method method = metaMethodList[i];
            SEL name = method_getName(method);
            IMP implementation = method_getImplementation(method);
            if (name == selector) {
                NSLog(@"Category class method found & call original ~~~");
                ((void (*)(id, SEL))implementation)(target, name); // id (*IMP)(id, SEL, ...)
                break;
            }
        }
        free(metaMethodList);
    }
    
    @end
    

方法 +[ObjcUtil invokeOriginalMethod:selector:] 代码长了点,但只是倒序来遍历方法列表,来拿到原同名方法的函数指针来调用。

这里提一下, +initialize 是类方法,那么它对应的原同名方法,是在元类的方法列表中被找到。

此时,再跑一次上面的测试代码,Crash 消失了。

2.

再写了个测试代码更直观的看一下输出:

NSMutableString *mutableString = [NSMutableString stringWithString:@"1"];
for(int i = 0; i < 16; i++){
	NSString *str = [NSString stringWithString:mutableString];
	NSLog(@"%@, %p, length: %ld", [str class], str, str.length);
	[mutableString appendString:@"1"];
}

A. 实现了 +initialize 时,输出:

__NSCFString, 0x2837b4a60, length: 1
__NSCFString, 0x2837b4a60, length: 2
__NSCFString, 0x2837b4a60, length: 3
__NSCFString, 0x2837b4a60, length: 4
__NSCFString, 0x2837b4a60, length: 5
__NSCFString, 0x2837b4a60, length: 6
__NSCFString, 0x2837b4a60, length: 7
__NSCFString, 0x2837b4a60, length: 8
__NSCFString, 0x2837b4a60, length: 9
__NSCFString, 0x2837b4a60, length: 10
__NSCFString, 0x2837b4a60, length: 11
__NSCFString, 0x2837b4a60, length: 12
__NSCFString, 0x2837b4a60, length: 13
__NSCFString, 0x2837b4a60, length: 14
__NSCFString, 0x28398a460, length: 15
__NSCFString, 0x28398a460, length: 16

B. 没实现了 +initialize(把它名字改一下就可以了:P),或者实现了+initialize方法,但在里面调用原同名方法,输出:

NSTaggedPointerString, 0xad8f321d31a11442, length: 1
NSTaggedPointerString, 0xad8f321d31b9945a, length: 2
NSTaggedPointerString, 0xad8f321d29399452, length: 3
NSTaggedPointerString, 0xad8f3205a939946a, length: 4
NSTaggedPointerString, 0xad8f2a85a9399462, length: 5
NSTaggedPointerString, 0xad97aa85a939947a, length: 6
NSTaggedPointerString, 0xb517aa85a9399472, length: 7
NSTaggedPointerString, 0xadb3c1d20d52c38a, length: 8
NSTaggedPointerString, 0xa2b3c1d20d52c382, length: 9
NSTaggedPointerString, 0xac6049c3c61ce39a, length: 10
NSTaggedPointerString, 0x906049c3c61ce392, length: 11
__NSCFString, 0x2819fa280, length: 12
__NSCFString, 0x2819fa280, length: 13
__NSCFString, 0x2819fa280, length: 14
__NSCFString, 0x2817ba580, length: 15
__NSCFString, 0x2817ba580, length: 16

长度为11及以下的字符串,都会是 NSTaggedPointerString。结果是显而易见的,当我们的 NSString 类别实现了 +initialize 方法,会导致 NSTaggedPointerString 不启用了。

3.

把 Foundation.framework/Foundation 拖进 IDA,等 IDA 分析完,看一下 +[NSString initialize] 的汇编代码,发现这里有 enable/disable string tagged pointer 相关描述及逻辑。

  • iOS 13.3 版本的 Foundation.framework

在这里插入图片描述

  • iOS 15.5 版本的 Foundation.framework

在这里插入图片描述

另外提一下,在 $HOME/Library/Developer/Xcode/iOS\ DeviceSupport/[iOS_Version]/Symbols/System/Library/Frameworks/目录下已经有Xcode通过 dsc_extractor 工具 extract dyld_shared_cache出来的 frameworks 和 dylibs,不用自己从真机拉下来,直接拉进 IDA 反编就好。

4.

IDA 只是静态分析看出个大概,具体点还是得 lldb 打 breakpoint 不断 n 来动态分析,先跟踪 +[NSString initialize]
+[ObjcUtil invokeOriginalMethod:selector:] 调用前打个断点

	br set -s Foundation -name "+[NSString initialize]"
  • lldb 触发断点后,不断 next ,注意看寄存器的变化,可以看出,这里先查看了系统环境变量有没禁用 String Tagged:
getenv("NSStringDisableTagged");
  • 接着调用 <objc/runtime.h> 的api class_setSuperclass,设置 NSTaggedPointerString 的 superclass 为 NSString:
class_setSuperclass(NSTaggedPointerString.class, NSString.class);

在这里插入图片描述

  • 接着 next,发现最后调用
CoreFoundation`+[NSTaggedPointerString _setAsTaggedStringClass]

在这里插入图片描述

  • CoreFoundation 库拖时 IDA,看看 +[NSTaggedPointerString _setAsTaggedStringClass] 做些什么:

在这里插入图片描述

  • 可以看出,先调用了 _objc_taggedPointersEnabled(void) (source link) 检查 Tagged Pointer 有没有enable了,若是的话,则调用 _objc_registerTaggedPointerClass(objc_tag_index_t tag, Class _Nonnull cls) (source link) 来注册 Tagged Pointer Class NSTaggedPointerString

  • 知道原理后,那么就可在不回调原方法来启用 NSTaggedPointerString 了,也不怕第三方库的 NSString Category实现了 +initialize 了 😛,主动调下面代码就可以了:

Class clazzTagged = NSClassFromString(@"NSTaggedPointerString");
class_setSuperclass(clazzTagged, NSString.class);
[clazzTagged performSelector:NSSelectorFromString(@"_setAsTaggedStringClass")];

OK,来到最后一个问题

为什么就在 objc_msgSend +32 处就 EXC_BAD_ACCESS 了呢,在偏移+32处的指令做了些什么,断点:

(lldb) b objc_msgSend
Breakpoint 2: where = libobjc.A.dylib`objc_msgSend, address = 0x00000001996d3f20
(lldb) c
Process 92474 resuming
(lldb) 
  • 正常情况下,x16 寄存器放的是指向 NSTaggedPointerString 这个类的地址:

在这里插入图片描述

  • 根据上面分析,由于 _objc_registerTaggedPointerClass 没有被调用,以至于 _objc_getClassForTag (source link) 不能根据 tag 来算出对应的 Class 地址,当然,objc_msgSend 里面是用汇编实现计算获取 。此时,x16NULL

在这里插入图片描述
当执行 ldr 指令时

ldr    x11, [x16, #0x10]

对应的源码 objc-msg-arm64.s#L358,已经是去到 CacheLookup 快速查找 NSTaggedPointerString 的方法了。

	ENTRY _objc_msgSend
	...
	GetClassFromIsa_p16 p13, 1, x0	// p16 = class
LGetIsaDone:
	// calls imp or objc_msgSend_uncached
	CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached
	...
	ldr	p11, [x16, #CACHE]				// p11 = mask|buckets

CACHE值为0x10,两个指针长度,看看类的结构:

typedef struct objc_class *Class;

union isa_t {
	uintptr_t bits;
	Class cls;
}

struct objc_object {
    isa_t isa;
}

struct objc_class : objc_object {
    Class superclass;
    cache_t cache; 
    class_data_bits_t bits; 
}

扁平化一下类的结构就是:

objc_class {
	isa_t isa;				// 【偏移0x00】一个指针长度,这里即 8 字节 64位
	Class superclass; 		// 【偏移0x08】一个指针长度,这里即 8 字节 64位
	cache_t cache; 			// 【偏移0x10】
	class_data_bits_t bits;
}

所以指令 ldr x11, [x16, #0x10] 是为了拿出类的方法缓存cache。但此时x16寄存器为NULL,那么就是ldr x11, [0x10],加载内存地址为0x10的内容,这个地址就不是普通进程有能力访问的了。

最后

注意示例项目 iCategory-NSString 需运行在 iOS 15.0 及以上,若想运行在低版本的iOS上,要拿个低版本的 objc-internal.h 替换掉。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值