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技术,用于优化NSNumberNSDateNSString等小对象的存储。这里的现象会不会跟Tagged Pointer有关系呢?

原理探究

Tagged Pointer技术背景

在arm64设备上苹果引入了Tagged Pointer技术,NSNumber等对象的值直接存储在了指针中,不必在堆上为其分配内存,节省了很多内存开销。在性能上,有着 3 倍空间效率的提升以及 106 倍创建和销毁速度的提升。

Tagged Pointer内存结构

与macOS不同,iOS系统采用 MSBMost 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,相比于分开检查nilTagged Pointer,这样就可以给objc_msgsend中的常见情况节省了一个分支条件。

  • Tag_Index:占3bit,是类标志位,可以在Runtime源码中查看NSNumberNSDateNSString等类的标志位。
  • 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_1OBJC_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内存结构的解释是:

  1. ARM有个特性是dyld会忽略指针的前8bit(这是由于ARM的Top Byte Ignore特性),因此Tag_Index作为更重要的信息不适合再放到高8bit。
  2. 这样布局数之后,图中的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,特别是NSStringNSNumberNSDate等对象,避免掉进坑里。下面附两个另外比较典型的关于

Tagged Pointer的坑:

  1. 《一次标签指针(Tagged Pointer)导致的事故》
  2. 《从一道网易面试题浅谈 Tagged Pointer》

参考&致谢

《iOS - 老生常谈内存管理(五):Tagged Pointer》by 师大小海腾

《Tagged Pointer》by 清风低语

感谢微信好友@鹅喵大魔王对NSNumer Type_Index内存结构的逆向探索和测试代码的提供。

猜你喜欢

转载自juejin.im/post/6975765788355461133