Tagged Pointer 对象安全气垫为何会失效
↓推荐关注↓
安全气垫的功能和原理的介绍可以参考这篇:《大白健康系统--iOS APP运行时Crash自动修复系统》,其中OC方法查找不到Unrecognized Selector
是线上最容易出现的一类错误。自安全气垫上线之后这一类错误可以被安全气垫兜住,不会触发用户崩溃,同时也支持将报错的异常调用栈上报到公司稳定性监控后台,方便在新版本修复。但是某一天一位同学反馈了一个Unrecognized Selector
类型的崩溃并没有被兜住造成了比较大面积的Crash。报错信息如下:
NSException -[__NSCFNumber lengthOfBytesUsingEncoding:]: unrecognized selector sent to instance 0xbc58921bca740bf4
检查该产品安全气垫的开关,线上版本一直是开启的状态。因此这个问题确定没有被兜住逃逸为Crash,不符合预期,需要查明原因并解决。
排查过程
通过崩溃调用栈可以看到找不到方法的对象是一个NSNumber对象。在APM SDK Example中模拟构建一个NSNumber对象并且调用一个其不存在的方法,果然安全气垫没有生效进而触发了崩溃!打断点调试发现了问题:
- (id)hook_forwardingTargetForSelector:(SEL)aSelector {
id forwardObject = [self hook_forwardingTargetForSelector: aSelector];
if (forwardObject) {
return forwardObject;
}
if ([self methodSignatureForSelector:aSelector]) {
return forwardObject;
}
// Check address of 'self' and 'aSelector' are valid
if (!check_valid_address(self, aSelector)) {
return nil;
}
//notify protect happened
//...
return USELForwarder.class;
}
打断点调试发现原来这段代码check_valid_address
方法校验没有通过,那么这个判断条件的意义是什么呢?
经了解,老版本出现过某个对象变成僵尸对象之后,再执行安全气垫防护的流程会挂在中间某个步骤,因为该对象已经释放,所以内存的结构会是一个极其不确定的状态。因此从APM SDK某个版本开始,新增了判断对象本身和selector地址是否合法的判断,如果地址非法的话则直接返回,不作防护。再看check_valid_address
这个方法的具体实现:
static bool check_valid_address(id _Nonnull objc, SEL _Nonnull aSelector) {
vm_offset_t data;
mach_msg_type_number_t dataSize;
kern_return_t kt = vm_read(current_task(), (vm_address_t)objc, sizeof(uintptr_t), &data, &dataSize);
if (kt != KERN_SUCCESS) {
return false;
}
bool valid = true;
kt = vm_read(current_task(), (vm_address_t)sel_getName(aSelector), sizeof(uintptr_t), &data, &dataSize);
if (kt != KERN_SUCCESS) {
valid = false;
}
return valid;
}
可以看到其实实现比较简单,通过vm_read
判断对象地址的可读性。单步调试后发现测试case居然进入到了第6行条件的内部,直接返回了。打印出objc的地址居然是一个超大的地址0xffb3bbad03eeba55
!查看vm_read
api解释,返回值为1的含义是:Specified address is not currently valid.
通过苹果的XNU内核源码可以看到苹果arm64架构设备的虚拟内存地址的上限是0xfc0000000。
很显然,这里NSNumber的地址是一个非法的地址。那么为什么NSNumer对象的地址会如此大呢?突然想到arm64设备发布之后,苹果引入了Tagged Pointer
技术,用于优化NSNumber
、NSDate
、NSString
等小对象的存储。这里的现象会不会跟Tagged Pointer
有关系呢?
原理探究
Tagged Pointer技术背景
在arm64设备上苹果引入了Tagged Pointer
技术,NSNumber等对象的值直接存储在了指针中,不必在堆上为其分配内存,节省了很多内存开销。在性能上,有着 3 倍空间效率的提升以及 106 倍创建和销毁速度的提升。
Tagged Pointer内存结构
与macOS不同,iOS系统采用 MSB
(Most Significant Bit
,即最高有效位)为Tagged Pointer
标志位。
从iOS14系统版本开始苹果对Tagged Pointer
的内存结构有调整。以上文中的NSNumber对象为例,参考系统源码,一些技术博客,结合自己实验的结果,NSNumber类型Tagged Pointer
的内存结构分析如下,其中扩展标志位因为比较少见,这里分析时暂时忽略。
iOS14系统以下
无扩展标志位:
有扩展标志位:
iOS14系统以上
无扩展标志位:
有扩展标志位:
各bit含义解释
_OBJC_TAG_MASK:占1bit,是 Tagged Pointer
标志位,1意味着该地址是Tagged Pointer
,0则不是。WWDC 2020的一个session中解释了,arm64架构采用MSB
也就是最高位作为Tagged Pointer
标志位的原因就是为了优化性能,objc_msgsend
在正常的对象方法查找之前会首先排除nil和Tagged Pointer
,相比于分开检查nil
和Tagged Pointer
,这样就可以给objc_msgsend
中的常见情况节省了一个分支条件。
Tag_Index:占3bit,是类标志位,可以在 Runtime
源码中查看NSNumber
、NSDate
、NSString
等类的标志位。Extended_Tag_Index:占8bit,只有当Tag_Index=7的时候才存在,表示这是一个用于扩展的标志位,会额外占用8位来存储扩展的Tag Index。类标识的基本类型和扩展类型我们可以在 Runtime
源码中的objc_tag_index_t
查到:
// objc_tag_index_t
{
// 60-bit payloads
OBJC_TAG_NSAtom = 0,
OBJC_TAG_1 = 1,
OBJC_TAG_NSString = 2,
OBJC_TAG_NSNumber = 3,
OBJC_TAG_NSIndexPath = 4,
OBJC_TAG_NSManagedObjectID = 5,
OBJC_TAG_NSDate = 6,
// 保留位
OBJC_TAG_RESERVED_7 = 7,
// 52-bit payloads
OBJC_TAG_Photos_1 = 8,
OBJC_TAG_Photos_2 = 9,
OBJC_TAG_Photos_3 = 10,
OBJC_TAG_Photos_4 = 11,
OBJC_TAG_XPC_1 = 12,
OBJC_TAG_XPC_2 = 13,
OBJC_TAG_XPC_3 = 14,
OBJC_TAG_XPC_4 = 15,
OBJC_TAG_NSColor = 16,
OBJC_TAG_UIColor = 17,
OBJC_TAG_CGColor = 18,
OBJC_TAG_NSIndexSet = 19,
// 前60位负载内容
OBJC_TAG_First60BitPayload = 0,
// 后60位负载内容
OBJC_TAG_Last60BitPayload = 6,
// 前52位负载内容
OBJC_TAG_First52BitPayload = 8,
// 后52位负载内容
OBJC_TAG_Last52BitPayload = 263,
// 保留位
OBJC_TAG_RESERVED_264 = 264
}
可见,当类标识为 0-6 时,负载数据容量为60 bits;当类标识为 7 时(对应二进制为 0b111),负载数据容量为 52bits。这里要注意的是NSNumber还会额外占用4bit用来存储数据类型。如果 tag index 是 0b111(7), Tagged Pointer
对象将使用扩展来标记类型。类标识的扩展类型为上面 OBJC_TAG_Photos_1
~OBJC_TAG_NSIndexSet
。
Payload:对NSNumber而言,最多占56bit,最少占48bit(取决于Tag Index是否为extended tag index),存储具体的数值。 Type_Index: 占4bit,代表NSNumber具体的数据类型,具体的对应关系:
Type_Index | 对应数据类型 |
---|---|
0 | char |
1 | usigned char, short |
2 | unsigned short,int |
3 | unsigned int,NSInteger,NSUInteger,long,unsigned long,long long,unsigned long long |
4 | float |
5 | double |
这与CoreFoundation库中CFNumber.h中的一处枚举也是可以对应上的,唯一的区别是Type_Index从0开始而CFNumberType从1开始:
typedef CF_ENUM(CFIndex, CFNumberType) {
/* Fixed-width types */
kCFNumberSInt8Type = 1,
kCFNumberSInt16Type = 2,
kCFNumberSInt32Type = 3,
kCFNumberSInt64Type = 4,
kCFNumberFloat32Type = 5,
kCFNumberFloat64Type = 6, /* 64-bit IEEE 754 */
/* Basic C types */
kCFNumberCharType = 7,
kCFNumberShortType = 8,
kCFNumberIntType = 9,
kCFNumberLongType = 10,
kCFNumberLongLongType = 11,
kCFNumberFloatType = 12,
kCFNumberDoubleType = 13,
/* Other */
kCFNumberCFIndexType = 14,
kCFNumberNSIntegerType API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 15,
kCFNumberCGFloatType API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 16,
kCFNumberMaxType = 16
};
苹果在iOS14系统之后对于修改Tagged Pointer
内存结构的解释是:
ARM有个特性是dyld会忽略指针的前8bit(这是由于ARM的 Top Byte Ignore
特性),因此Tag_Index作为更重要的信息不适合再放到高8bit。这样布局数之后,图中的payload就跟普通指针的payload是一模一样了,也就是 Tagged Pointer
的payload(有效负载位)有包含一个正常的指针的能力;这使得Tagged Pointer
具备了引用二进制文件中的常量数据的能力,例如字符串或其他数据结构,可以减少dirty memory的使用。
混淆与反混淆
通过上面内存结构的分析,Tagged Pointer
指针上存储的数据我们完全可以自己计算出来的,这个时候数据暴露出来是有比较大风险的,苹果为了防止非法的伪造Tagged Pointer
等数据安全问题,自iOS12之后设计了数据混淆机制,这也解释了为什么文章一开头那个NSNumber对象地址那么大。
查阅objc runtime源码,混淆策略如下:
/** 随机初始化 TaggedPointer 混淆器变量 objc_debug_taggedpointer_obfuscator
* @discussion 混淆器变量 objc_debug_taggedpointer_obfuscator 用于数据保护;在首次使用时充满随机性;
* 在设置或检索 TaggedPointer 上的净负荷值时,混淆器与标记指针进行异或,因此该指针被加密;
* 此时,别人无法通过指针获取 TaggedPointer 上存储的值,有效的进行了数据保护;
* @note 如果程序的环境变量 OBJC_DISABLE_TAG_OBFUSCATION 设置为 YES ,则禁止使用 TaggedPointer 混淆器
*/
static void initializeTaggedPointerObfuscator(void) {
///编译处理 if (!DisableTaggedPointerObfuscation && dyld_program_sdk_at_least(dyld_fall_2018_os_versions)) {
if (!DisableTaggedPointerObfuscation && (dyld_get_program_sdk_version() >= dyld_fall_2018_os_versions)) {
/// 将随机数据放入变量中,然后移走所有非净负荷位
arc4random_buf(&objc_debug_taggedpointer_obfuscator, sizeof(objc_debug_taggedpointer_obfuscator));
objc_debug_taggedpointer_obfuscator &= ~_OBJC_TAG_MASK;
#if OBJC_SPLIT_TAGGED_POINTERS
// The obfuscator doesn't apply to any of the extended tag mask or the no-obfuscation bit.
objc_debug_taggedpointer_obfuscator &= ~(_OBJC_TAG_EXT_MASK | _OBJC_TAG_NO_OBFUSCATION_MASK);
// 打断class tag index的固定顺序.
int max = 7;
for (int i = max - 1; i >= 0; i--) {
int target = arc4random_uniform(i + 1);
swap(objc_debug_tag60_permutations[i],
objc_debug_tag60_permutations[target]);
}
#endif
} else {
/// 对于链接到旧sdk的应用程序,如果它们依赖于tagged pointer表示,将混淆器设置为0,
objc_debug_taggedpointer_obfuscator = 0;
}
}
混淆原理:使用一个随机数objc_debug_taggedpointer_obfuscator
对真正的内存地址异或操作。根据异或运算的特性,a^b^b=a,因此只需要将混淆后的地址再与objc_debug_taggedpointer_obfuscator
异或一次就能够完成反混淆。
阅读源码后可知objc_debug_taggedpointer_obfuscator
是一个全局变量,因此只需要在当前文件extern
声明一下就可以轻松的实现一个反混淆方法:
extern uintptr_t objc_debug_taggedpointer_obfuscator;
uintptr_t _objc_decodeTaggedPointer_(id ptr){ // 这是苹果源码中的解码函数
return (uintptr_t)ptr ^ objc_debug_taggedpointer_obfuscator;
}
验证
测试环境:iPhone XS Max,iOS14.6。
测试代码:
extern int64_t objc_debug_taggedpointer_obfuscator;
intptr_t _objc_decodeTaggedPointer(id ptr){ // 这是苹果源码中的解码函数
return (uintptr_t)ptr ^ objc_debug_taggedpointer_obfuscator;
}
- (void)printTaggedNumber:(NSNumber *)number description:(NSString *)desc {
intptr_t maybeTagged = (intptr_t)number;
if (maybeTagged >= 0LL) {
NSLog(@"-- %@ - not tagged", desc);
return;
}
intptr_t decoded = _objc_decodeTaggedPointer(number);
NSLog(@"-- %@ - 0x%016lx", desc, decoded);
}
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
#define PRINT_NUMBER(x) \
[self printTaggedNumber:x description:@#x];
PRINT_NUMBER([NSNumber numberWithChar:1]);
PRINT_NUMBER([NSNumber numberWithUnsignedChar:1]);
PRINT_NUMBER([NSNumber numberWithShort:1]);
PRINT_NUMBER([NSNumber numberWithUnsignedShort:1]);
PRINT_NUMBER([NSNumber numberWithInt:1]);
PRINT_NUMBER([NSNumber numberWithUnsignedInt:1]);
PRINT_NUMBER([NSNumber numberWithInteger:1]);
PRINT_NUMBER([NSNumber numberWithUnsignedInteger:1]);
PRINT_NUMBER([NSNumber numberWithLong:1]);
PRINT_NUMBER([NSNumber numberWithUnsignedLong:1]);
PRINT_NUMBER([NSNumber numberWithLongLong:1]);
PRINT_NUMBER([NSNumber numberWithUnsignedLongLong:1]);
PRINT_NUMBER([NSNumber numberWithFloat:1]);
PRINT_NUMBER([NSNumber numberWithDouble:1]);
}
测试结果:
2021-06-15 22:19:13.688015+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithChar:1] - 0x8000000000000084
2021-06-15 22:19:13.688039+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithUnsignedChar:1] - 0x800000000000008c
2021-06-15 22:19:13.688057+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithShort:1] - 0x800000000000008c
2021-06-15 22:19:13.688084+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithUnsignedShort:1] - 0x8000000000000094
2021-06-15 22:19:13.688105+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithInt:1] - 0x8000000000000094
2021-06-15 22:19:13.688119+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithUnsignedInt:1] - 0x800000000000009c
2021-06-15 22:19:13.688133+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithInteger:1] - 0x800000000000009c
2021-06-15 22:19:13.688352+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithUnsignedInteger:1] - 0x800000000000009c
2021-06-15 22:19:13.688497+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithLong:1] - 0x800000000000009c
2021-06-15 22:19:13.688673+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithUnsignedLong:1] - 0x800000000000009c
2021-06-15 22:19:13.688838+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithLongLong:1] - 0x800000000000009c
2021-06-15 22:19:13.688966+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithUnsignedLongLong:1] - 0x800000000000009c
2021-06-15 22:19:13.689178+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithFloat:1] - 0x80000000000000a4
2021-06-15 22:19:13.689333+0800 NSNumberTest[2804:508458] -- [NSNumber numberWithDouble:1] - 0x80000000000000ac
将不同类型的Tagged Pointer
地址转成二进制整理如下:
//char
1000000000000000000000000000000000000000000000000000000010000100
//usigned char, short
1000000000000000000000000000000000000000000000000000000010001100
//unsigned short,int
1000000000000000000000000000000000000000000000000000000010010100
//unsigned int,NSInteger,NSUInteger,long,unsigned long,long long,unsigned long long
1000000000000000000000000000000000000000000000000000000010011100
//float
1000000000000000000000000000000000000000000000000000000010100100
//double
1000000000000000000000000000000000000000000000000000000010101100
以char类型为例,执行返混淆之后打印出原始的Tagged Pointer
地址为0x8000000000000083,参照iOS14系统以上章节的内存结构分析对照如下:
高4位0x8转成二进制也就是1000,也就是最高位是1,代表 Tagged Pointer
标志位,意味着该指针就是Tagged Pointer
。低3位0x4换成十进制也是4,代表Tag_Index,注意这里没有和NSNumber对应上的主要原因是iOS14系统以上苹果对于Tag_Index的映射关系也做了混淆,并不是静态的,每次启动都可能会发生互换。 低4-7位换成十进制是0,代表Type_Index,刚好和char类型的索引是能对上的。 从低8位开始剩下的数字就代表payload,十六进制表示是0x1,也就是1,刚好和代码中的数字完全对上。
Tagged Pointer可表示的数字范围
从上面章节的分析中可以得出结论,在不考虑extended tag的前提下,payload最多占56bit,最高位预留用来表示数字的正负。那么55bit理论上能表示的最大数字范围就是-2^55~2^55-1这么大。转成十进制就是-36028797018963968~36028797018963967。然后我们以表示范围最大的long long类型为例再用上面的测试环境再次验证一下:
首先验证上限部分:
PRINT_NUMBER([NSNumber numberWithLongLong:36028797018963967]);
PRINT_NUMBER([NSNumber numberWithLongLong:36028797018963968]);
输出结果:
2021-06-17 13:06:36.663636+0800 NSNumberTest[8324:1617444] -- [NSNumber numberWithLongLong:36028797018963967] - 0xbfffffffffffff98
2021-06-17 13:06:36.663664+0800 NSNumberTest[8324:1617444] -- [NSNumber numberWithLongLong:36028797018963968] - not tagged
可见结果符合我们的猜想。
再来验证下限部分:
PRINT_NUMBER([NSNumber numberWithLongLong:-36028797018963968]);
PRINT_NUMBER([NSNumber numberWithLongLong:-36028797018963969]);
输出结果:
2021-06-17 13:09:41.657371+0800 NSNumberTest[8330:1618554] -- [NSNumber numberWithLongLong:-36028797018963968] - not tagged
2021-06-17 13:09:41.657410+0800 NSNumberTest[8330:1618554] -- [NSNumber numberWithLongLong:-36028797018963969] - not tagged
可见-2^55居然已经不能用Tagged Pointer
来表示!然后我们再+1缩小范围:
PRINT_NUMBER([NSNumber numberWithLongLong:-36028797018963967]);
输出结果:
2021-06-17 13:12:06.286077+0800 NSNumberTest[8333:1619493] -- [NSNumber numberWithLongLong:-36028797018963967] - 0xc000000000000099
可见不知为何NSNumber将Tagged Pointer
理论上能表示的数字最大范围的下限修正为-2^55+1。
结论:Tagged Pointer
可表示的数字范围是-2^55+1 ~ 2^55-1,对于超出这个范围的数字,NSNumber会自动转换为普通的内存分配在堆上的OC对象。
如何判断指针是否为Tagged Pointer
在 objc runtime源码中找到了 _objc_isTaggedPointer()
的实现:
static inline bool _objc_isTaggedPointer(const void * _Nullable ptr){
//将一个指针地址和 _OBJC_TAG_MASK 常量做 & 运算:判断该指针的最高位或者最低位为 1,那么这个指针就是 Tagged Pointer。
return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}
_OBJC_TAG_MASK
的定义:
#if OBJC_MSB_TAGGED_POINTERS //MSB 高位优先
# define _OBJC_TAG_MASK (1UL<<63) //Tagged Pointer 指针
#else //LSB 低位优先
# define _OBJC_TAG_MASK 1UL //Tagged Pointer 指针
#endif
因此 ptr & _OBJC_TAG_MASK
按位与运算之后如果判断标志位为1则该指针是Tagged Pointer
。
问题修复
搞清楚原理之后,问题也就比较容易修复了。只需要在调用vm_read
方法判断指针地址是否可读之前,首先判断是否为Tagged Pointer
,如果是的话则忽略,直接返回true。
tatic inline bool protect_objc_isTaggedPointer(const void *ptr) {
bool result = ((intptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
return result;
}
static bool check_valid_address(id _Nonnull objc, SEL _Nonnull aSelector) {
vm_offset_t data;
mach_msg_type_number_t dataSize;
vm_address_t address = (vm_address_t)objc;
//reference to https://blog.timac.org/2016/1124-testing-if-an-arbitrary-pointer-is-a-valid-objective-c-object/
//vm_read for TaggedPointer may return KERN_INVALID_ADDRESS
#if defined(__LP64__)
if (protect_objc_isTaggedPointer((void *)address)) {
return true;
}
#endif
kern_return_t kt = vm_read(current_task(), address, sizeof(uintptr_t), &data, &dataSize);
if (kt != KERN_SUCCESS) {
return false;
}
bool valid = true;
kt = vm_read(current_task(), (vm_address_t)sel_getName(aSelector), sizeof(uintptr_t), &data, &dataSize);
if (kt != KERN_SUCCESS) {
valid = false;
}
return valid;
}
最终改动上线之后确认问题修复生效。
启发
对于OC的对象,除了分配在堆中的普通OC对象之后,要时刻留意有一些特殊的Tagged Pointer
,特别是NSString
,NSNumber
,NSDate
等对象,避免掉进坑里。下面附两个另外比较典型的关于
Tagged Pointer
的坑:
《一次标签指针(Tagged Pointer)导致的事故》 《从一道网易面试题浅谈 Tagged Pointer》
参考
《iOS - 老生常谈内存管理(五):Tagged Pointer》by 师大小海腾
《Tagged Pointer》by 清风低语
转自:掘金 东野浪子
https://juejin.cn/post/6975765788355461133
- EOF -
看完本文有收获?请分享给更多人
关注「 iOS大全 」加星标,关注 iOS 动态
点赞和在看就是最大的支持❤️