阅读 168

iOS进阶 -- 方法慢速查找流程分析

前言

探索环境:

硬件 Mac book pro

系统 OS 11.2.2

源码地址

源码调试配置

在探索方法的快速查找流程中,我们知道了objc_msgSend如何通过查找缓存的方法,从而快速查找方法。但是如果方法没有缓存,该如何呢?在上节探索的最后,我们发现汇编最后调用了 __objc_msgSend_uncached,即缓存未命中,在源码中搜索可发现如下代码:

Xnip2021-06-30_08-38-20.jpg

进一步搜索 MethodTableLookup,发现其代码如下图:

Xnip2021-06-30_08-39-09.jpg

通过MethodTableLookup的源码,我们发现作为返回值的 x0 寄存器,并未被赋值,而是通过 bl 指令调用了 _lookUpImpOrForward,全局搜索该函数,发现在 arm64.s 文件中并未定义,于是猜想这是否是一个 C/C++ 函数,于是继续全局搜索 lookUpImpOrForward,还真的在 objc-runtime-new.mm 中找到了其函数定义。本篇就继续探索下 lookUpImpOrForward 函数的调用流程,自此也进入方法的慢速查找过程,这里的慢一方面指方法没有缓存,另一方面相比于汇编,C/C++效率更慢一些。

一、源码分析前的环境

lookUpImpOrForward 是在 objc_msgSend 快速查找,但是在缓存中没有找到后调用的,此时内存中一定已经有了一个上下文环境,这个上下文环境一定包含传入 lookUpImpOrForward 的参数等信息。

我们先看下 lookUpImpOrForward 需要哪些参数,其函数定义如下:

Xnip2021-06-30_12-58-08.jpg

由前言中 lookUpImpOrForward 调用处源码分析可以得知,此时各个参数的情况如下:

  • id inst: 第一个参数为 x0 , 此时为消息的接收者
  • SEL sel: 第二个参数为 x1 , 此时为方法名称 sel
  • Class cls: 第三个参数为 x2, 由 x16 赋值,此时 x16 为快速查找流程中找到的类信息
  • int behavior: 第四个参数为 x3, 此时值为 3,这个参数的作用后面也会会介绍到

二、方法查找前的准备工作

在进行正式的方法查找之前,苹果做了几步操作,其中包括一些容错处理和检查,如下图所示为这几步操作的流程图:

Xnip2021-07-01_23-32-14.png

下面依次介绍下这几步操作的目的:

  • 检查传入的cls是否初始化过,如果未初始化则 behavior |= LOOKUP_NOCACHE,即 behavior 会被改为 11,定义 LOOKUP_NOCACHE 的枚举如下
/* method lookup */
enum {
    LOOKUP_INITIALIZE = 1,
    LOOKUP_RESOLVER = 2,   
    LOOKUP_NIL = 4,
    LOOKUP_NOCACHE = 8,
};
复制代码
  • checkIsKnownClass(cls),确保传入的类是合法的,也就是苹果认可的,这么做是为了提升安全性,防止 CFI攻击

    • CFI攻击 又叫控制流攻击,假定没有这一步检测,攻击者从外部传入一个不合法的类,而这个类会做一些恶意的操作,如改变程序控制权,或者栈溢出操作等,对与苹果的安全性就是一个威胁
    • 苹果的所允许的三个函数为 objc_duplicateClass, objc_initializeClassPair or objc_allocateClassPair
    • 如果这一步检测到确实传入的值非法,则程序会抛出异常,中断执行,代码如下:

Xnip2021-07-01_23-48-25.png

  • realizeAndInitializeIfNeeded_locked只在 lookupImpOrForward 函数中有调用,其代码实现如下图所示:

Xnip2021-07-01_23-54-42.png

像图中一样打上断点,然后运行源码发现,我们自己新建的类基本不会进入这里,能够触发断点的很多都是 OS_dispatch_data、OS_xpc_string 这样的类。

继续跟进分支中的函数,我们最终可以找到一个叫realizeClassWithoutSwift 的函数,该函数在最初进行 ro、rw 的赋值操作后,有几步操作可以十分有意思,代码如下:

supercls = realizeClassWithoutSwift(remapClass(cls->getSuperclass()), nil); // 递归调用,完成父类的realized
metacls = realizeClassWithoutSwift(remapClass(cls->ISA()), nil); // 递归调用,完成元类的realized

cls->setSuperclass(supercls); // 设置父类
cls->initClassIsa(metacls);   // 初始化isa
复制代码

通过递归调用,分别从 isa链 和 继承链,由点而面的完成的一个类及其相关父类和元类的 realized,由此保证了即使遇到了未初始化或relized的类,也可以在这一步完成这些操作。过程分析可参照下图: isa流程图.png

到这一步,方法查找前的准备工作完成了,可以确保此时查找的类是一个合法,且完成初始化的类,具备进行方法查找的条件。这里不得不赞叹苹果的严谨之处,每一个方法在正式流程开始前,苹果都做了充足有效的容错处理,代码的健壮性很高。

三、方法查找流程

3.1 循环查找imp总述

在准备工作做好后就要开始进行方法查找了,这个过程是通过一个 for循环来做的,总体的代码如下图所示:

Xnip2021-07-03_09-45-25.png

通过代码可以发现,这个 for循环并未设置循环终止条件,而是在循环体内根据条件判断循环的终止。通过这样的一个循环可以迭代的调用查找的函数,最终找到方法的imp,或者找到根类也未能找到imp。这过程的分析可以用如下的流程图表示:

方法查找循环流程.png

通过这个流程图可以发现,在查找imp时,有两个流程:

  • isConstantOptimizedCache 开启的流程,这一流程下直接会调用 cache_getImp,这个函数在后面我们会探索到,这里先按下不表。 这一流程是在共享缓存开启时才会进入,由于本次探索使用的 Mac OS 工程,因此不会进入这个分支。 CONFIG_USE_PREOPT_CACHESisConstantOptimizedCache 的定义如下所示:
      #if defined(__arm64__) && TARGET_OS_IOS && !TARGET_OS_SIMULATOR &&   !TARGET_OS_MACCATALYST
      #define CONFIG_USE_PREOPT_CACHES 1
      #else
      #define CONFIG_USE_PREOPT_CACHES 0
      #endif
      
      // isConstantOptimizedCache 其实在 objc-cache.mm 中有详细的实现,
      // 不过这里仅根据定义就可以看到,当 CONFIG_USE_PREOPT_CACHES 为 0 时,该函数直接返回 false
      #if CONFIG_USE_PREOPT_CACHES
          bool isConstantOptimizedCache(bool strict = false, uintptr_t empty_addr = (uintptr_t)&_objc_empty_cache) const;
      #else
          inline bool isConstantOptimizedCache(bool strict = false, uintptr_t empty_addr = 0) const { return false; }}
      #endif
      
    复制代码
  • isConstantOptimizedCache 未开启的流程,
    • 在这个流程下会先调用 getMethodNoSuper_nolock 函数来查找当前 curClass 是否包含要查找的 imp
    • 如果包含,则走 goto done流程,同时循环中止
    • 如果不包含,则判断 curClass 的父类是否为空,即当前类是否为NSObject根类,如果是根类则表明没有找到imp,否则调用 cache_getImp,此时curClass已经变成了父类
    • 如果得到的imp不存在则再次以父类身份进行循环,直到找到imp,或确定找不到imp为止

通过对这一个循环体的整体流程分析,我们发现一个关键函数,即在类中查找对应imp的函数 getMethodNoSuper_nolock,下面我们继续分析下这个函数。

3.2 getMethodNoSuper_nolock 函数分析

点进 getMethodNoSuper_nolock 函数,可以发现其函数实现如下:

Xnip2021-07-03_12-58-01.png

该函数通过便利 mehtodlist来查找对应的imp,未找到返回nil。继续跟进查找imp的函数 search_method_list_inline,发现其代码如下:

Xnip2021-07-03_13-04-21.png

在该函数中有两个查找imp的方法,分别是查找 无序的mehtodList有序的methodList,继续分析这两个函数

  • 无序的查找函数 findMethodInUnsortedMethodList,其实就是将 methodlist 遍历一遍,代码如下

Xnip2021-07-03_13-06-41.png

  • 有序的查找函数 findMethodInSortedMethodList,其代码实现如下:

Xnip2021-07-03_13-10-13.png

该函数通过二分查找来实现imp的查找,我们通过一个例子来推演一下这个过程。假定有一个长度为 methodList 为 [2、3、4、5、6、7、8、9],先以查找 8 为例。

在推演开始前,先明确两点

1、count >> 1 有类似除以二的效果,例如 1000 >> 1 = 100,即 8 >> 1 = 4

2、probe > first && keyValue == (uintptr_t)getName((probe - 1)) 是为了判断分类中是否有同名方法,如果有就取分类的方法,在后续中直接表述为 查找分类同名方法。下面还是推演步骤:

  • 查找目标较大,以目标 imp 为 8 举例,在该 list 中查找
    • 第一次循环: first = 2、base = 2、count = 8、probe = base + (count >> 1) = 6;
      • 获取到 probe 的名称,并与 keyValue对比,如果相等则说明找到了,再查找有没有分类同名方法,有就使用分类方法即可;
      • 如果不匹配,则找出 keyValue 可能在的区域,如果 keyValue 大于 probeValue,表明在后半段,此时base值更改为 7,count自减,第一次循环结束 count = count >> 1 = 7 >> 1 = 3
    • 第二次循环: base = 7、count = 3、probe = base + (count >> 1) = 7 + 1 = 8 ,正好与对应的keyValue相等,即找到目标
  • 查找目标较小,以 3 为例,在 list 中查找
    • 第一次循环: first = 2、base = 2、count = 8、probe = base + (count >> 1) = 6;
      • 获取到 probe 的名称,与目标不匹配
      • keyValue < probeValue,说明 keyValue 在前半段,base不需要改变,count = count >> 1 = 8 >> 1 = 4
    • 第二次循环: base = 2、count = 4、probe = base + (count >> 1) = 4;,此时依然是 keyValue < probeValue,base仍然不需要改变,但是count = count >> 1 = 4 >> 1 = 2
    • 第三次循环: base = 2、count = 2、probe = base + (count >> 1) = 3; 此时 probeValue 与 keyValue 相等,找到目标

在实际的查找过程中,methodList存储的肯定不是这种简单的数字,但是这种二分查找的思想是相通的,实际上也比直接遍历效率要高很多。

如果通过上述步骤未能找到imp,且父类不为nil,则会调用 cache_getImp。我们想要跟进去查看函数实现,但是发现无法找到,不过通过全局搜索去发现这是一个汇编函数,其代码如下:

Xnip2021-07-03_14-23-48.png

找到 CacheLookup 的 Mode 为 GETIMP 的实现如下:

Xnip2021-07-03_14-41-36.png

这里 cbz p0, 9f 如果找到了 imp 则进行签名,然后返回imp;如果找到的 imp 为 nil 则直接返回。

如果在执行 CacheLookup 是没有找到 imp ,则执行 LGetImpMissDynamic,这里直接将 #0 赋值给了 p0,即返回值为nil。

3.3 循环之后的处理

在经过循环后,如果找到了imp,则执行goto donedone 的代码如下:

 done:
    if (fastpath((behavior & LOOKUP_NOCACHE) == 0)) {
#if CONFIG_USE_PREOPT_CACHES
        while (cls->cache.isConstantOptimizedCache(/* strict */true)) {
            cls = cls->cache.preoptFallbackClass();
        }
#endif
        log_and_fill_cache(cls, imp, sel, inst, curClass);
    }
    
    static void
log_and_fill_cache(Class cls, IMP imp, SEL sel, id receiver, Class implementer)
{
#if SUPPORT_MESSAGE_LOGGING
    if (slowpath(objcMsgLogEnabled && implementer)) {
        bool cacheIt = logMessageSend(implementer->isMetaClass(), 
                                      cls->nameForLogging(),
                                      implementer->nameForLogging(), 
                                      sel);
        if (!cacheIt) return;
    }
#endif
    cls->cache.insert(sel, imp, receiver); // 调用cache_t的insert函数,缓存慢速查找找到的方法
}
复制代码

通过这部分代码可以发现,在慢速流程中找到imp后,会再次将该方法缓存起来。

如果在循环后还未找到 imp, 在执行如下判断:

    if (slowpath(behavior & LOOKUP_RESOLVER)) {
        behavior ^= LOOKUP_RESOLVER;
        return resolveMethod_locked(inst, sel, cls, behavior);
    }
复制代码

这个分支中的代码一般不会执行,而且就算执行也只会只会执行一次,这里将会进入一个新的阶段,我们在下一篇会继续探索。

总结

本篇主要探索了在快速查找流程之后进行的慢速查找,这一过程主要是通过循环查找类及父类中的methodList,来查找imp的过程,到此方法的查找流程就结束了。下一阶段会继续探索,如果方法找不到,苹果是怎样处理的呢,即方法决议流程,欢迎大家继续关注,也希望得到大家对不足的地方给予指正。

关于 CFI 部分参考了以下文章:

使用最新的代码重用攻击绕过执行流保护(一)

CFI/CFG 安全防护原理详解

gdb | 简单的控制流劫持

文章分类
iOS
文章标签