Method Swizzling 中一个业内没有注意过的 Case

2,999 阅读8分钟

TL;DR

  1. +load 中进行 Method Swizzling 并不是绝对安全。此时方法可能不存在。
  2. 如果是 Direct Methods 的情况,需要直接 Hook。
  3. 如果是 Dynamic Load 的情况,需要延迟 Method Swizzling 的时机。
  4. 使用更健壮的 Hook 库(如 RSSwizzle),能及时发现边界情况。

背景

只有在 +load 中进行 Method Swizzling 才是合理且安全的。

虽然这个结论在业界是公认的,但我却遇到了不同的情况。 上周接到一个需求,需要 Hook 一些系统私有方法,发现在 +load 中,类实例并不能响应这些方法。而在业务代码中,直接调用却是没有任何问题。所以这个方法它存在,但又不存在在 +load 中。

(问题及整个排查过程都会以 -[NSObject _accessibilityElements] 为例子,下文简写为目标方法,需求并未使用到此 API,只是此 API 有关键词会比较容易寻找)

调用方式更改?

因为是系统私有方法,Apple 不对外承诺这些方法的兼容性,理论上可以随意更改这些方法的名字,实现和调用方式。Direct Methods 因为其特性是首先被怀疑的对象。

在 Objective-C 中,message 与 IMP 是在执行阶段绑定的,而不是在编译期。

-[NSObject foo] 这样一个调用会在编译阶段,被编译成:

mov     rid, xxx ; argument "instance" for method _objc_msgSend
mov     rsi, qword [0x12340] ;argument "selector" for method _objc_msgSend, @selector(foo)
call    qword [_objc_msgSend]

从汇编角度来看,就是给 NSObject 这个实例,发送一条叫做 foo 的消息,至于这条消息是怎么被处理的,在编译期是无法得知的。

在执行阶段,NSObject 收到消息后,会动态查找相应方法的函数地址,再执行。正是由于这种消息派发的机制,让 Objective-C 有了极为方便的运行时修改 IMP 的能力。

如果将一个函数标记成 Direct Methods,那么其调用的形式会从原来的消息派发机制改成 C 函数的直接调用。

// NSObject+DirectMethods.m

@implementation NSObject (DirectMethods)

- (int)foo __attribute__((objc_direct)) {
    { do something ... }
    return 0;
}    

@end

int main() {
    [[NSObject new] foo];
}

编译器会将其编译成:

mov     rdi, xxx
call    -[NSObject foo]

需要执行的函数指针是在编译期就确定下来了的。但与此同时,基于消息发送机制的一系列 API 都会在该方法上失效。

使用 @selector(foo) 这种方式创建一个 SEL 时,Xcode 会直接给予一个错误而非警告。

// NSObject+DirectMethods.m

@implementation NSObject (DirectMethods)

+ (void)load {
    // Error ❌: @selector expression formed with direct selector 'foo'
    // SEL selector = @selector(foo);
    SEL selector = NSSelectorFromString(@"foo");

    if (![NSObject respondsToSelector:selector]) {
        NSLog(@"Direct Methods!");
    }
}

- (int)foo __attribute__((objc_direct)) {
    { do something ... }
    return 0;
}    

@end

更多关于 Direct Methods 的可以参考:Objective-C Direct Methods

所以 Direct Methods 非常符合我所遇到的情况,那么目标方法是否是 Direct Methods 呢?可以从调用方的汇编中找到答案,从系统库中找到一个调用方

是正儿八经的消息发送😅 。

动态加载?

不是 Direct Methods,且系统库中使用该方法的姿势就是普通的消息发送,那么它只能是在 +load 之后的某一个时机被动态加载的。

使用 image list 的命令,可以看到在 +load 时有 391 个 image 被加载;在 App 完全启动后静置一段时间暂停 App,此时会有 519 image 被加载。那么,大概率就是后面 128 个 image 中,载入了目标方法。

为了验证这一个猜想,我们添加查找当前实例所有方法的辅助方法:

// NSObject+TGIF.m

@implementation NSObject (TGIF)

+ (nonnull NSArray<NSString *> *)allMethodNames {
    unsigned int count = 0;
    Method *methods = class_copyMethodList([NSObject self], &count);
    NSMutableArray<NSString *> *result = [NSMutableArray arrayWithCapacity:count];
    for (int i = 0; i < count; i++) {
        const char *name = sel_getName(method_getName(methods[i]));
        [result addObject:[NSString stringWithUTF8String:name]];
    }

    free(methods);
    return [result copy];
}

+ (BOOL)respondToAccessibilityElements {
    for (NSString *name in [self allMethodNames]) {
        if ([name isEqualToString:@"_accessibilityElements"]) {
            return true;
        }
    }
    return false;
}

@end

+load 和启动静置后分别执行,果然 +load 不响应而启动静置后响应了该方法。至此,我们已经可以确定目标方法是被动态加载进来的。

加载时机

确定了动态加载之后,我们需要一个目标函数被加载的时机。可以使用 _dyld_register_func_for_add_image 来订阅 image 加载的通知(得益于 Apple 开源的 dyld,我们也可以了解到,注册后会马上通知订阅 block 所有在注册之前已经被加载过的 image,所以注册的时机并非需要十分精准)。

添加和 image 相关的输出:

static void _print_image(const struct mach_header *mh) {
    Dl_info image_info;
    int result = dladdr(mh, &image_info);
    if (result == 0) {
        printf("Could not print info for mach_header: %p\n\n", mh);
        return;
    }

    const char *image_name = image_info.dli_fname;
    char image_uuid[37];
    const uuid_t *image_uuid_bytes = _image_retrieve_uuid(mh);
    uuid_unparse(*image_uuid_bytes, image_uuid);
    printf("Image added: %s <%s>\n\n",  image_name, image_uuid);
}

static uint32_t _image_header_size(const struct mach_header *mh) {
    bool is_header_64_bit = (mh->magic == MH_MAGIC_64 || mh->magic == MH_CIGAM_64);
    return (is_header_64_bit ? sizeof(struct mach_header_64) : sizeof(struct mach_header));
}

static void _image_visit_load_commands(const struct mach_header *mh, void (^visitor)(struct load_command *lc, bool *stop)) {
    assert(visitor != NULL);
    uintptr_t lc_cursor = (uintptr_t)mh + _image_header_size(mh);
    for (uint32_t idx = 0; idx < mh->ncmds; idx++) {
        struct load_command *lc = (struct load_command *)lc_cursor;

        bool stop = false;
        visitor(lc, &stop);
        if (stop) {
            return;
        }
        lc_cursor += lc->cmdsize;
    }
}

static const uuid_t *_image_retrieve_uuid(const struct mach_header *mh) {
    __block const struct uuid_command *uuid_cmd = NULL;
    _image_visit_load_commands(mh, ^ (struct load_command *lc, bool *stop) {
        if (lc->cmdsize == 0) {
            return;
        }
        if (lc->cmd == LC_UUID) {
            uuid_cmd = (const struct uuid_command *)lc;
            *stop = true;
        }
    });
    if (uuid_cmd == NULL) {
        return NULL;
    }
    return &uuid_cmd->uuid;
}

调用 _print_image 即可输出下列格式的 log,会极大地丰富我们的信息。

Image added: /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/libBacktraceRecording.dylib <CABB187A-C670-3932-BA9D-3C80B5B4E116>

完成前期准备后,在 +load 中订阅通知,并添加响应目标函数的断点

@implementation NSObject (TGIF)

+ (void)load {
    _dyld_register_func_for_add_image(&image_added);
}
​
static void image_added(const struct mach_header *mh, intptr_t slide) {
    _print_image(mh);
    if ([NSObject respondToAccessibilityElements]) {
        NSLog(@""); // Add breakpoint 
    }
}
@end

第一个命中断点的是 libcmark-gfm.dylib 这个 image, 去 hopper 查阅,里面并没有发现相关方法,调用栈也没发现任何可疑的地方。所以该方法并非是在此加载的。排除该 image 后,前一个 image NotesSupport 也没有相关方法,但如果我们在这个 image 下一个断点的话,会发现调用栈充满了信息:

第九帧汇编

翻译成伪代码是

- (void)_accessibilityBundlePrincipalClass {
    NSString *path = AXSCopyPathForAccessibilityBundle(@"UIKit");
    NSBundle *bundle = [NSBundle bundleWithPath:path];
    NSError *err = nil;
    [bundle loadAndReturnError:&err];
    ...
}

这就显得非常可疑,在 loadAndReturnError: 这条 message 前后打断点,获得该 message 加载的动态库,出于对 Apple 开发人员水平的信任,优先排查了含 Accessibility 关键字的 image。

UIAccessibility 中发现了目标方法:

UIAccessibility.framework

真相大白。

一个更优化的方案

虽然通过前面的方式大大缩减了搜寻范围,但载入的镜像也有数十个之多,手动去一个个镜像查找似乎不是一个优雅的方式。

我们需要一个更为精确的时机

class_addMethod 是日常开发中用来动态添加方法的 API,载入镜像这种大规模的添加函数的操作通常不会使用如此低效率的 API,但它的实现细节中可能含有线索。一筹莫展的时候从熟悉的 Public API 入手可能会是一个突破点

该函数经过加锁,类型包装,最终会进到

// objc-runtime-new.mm
static void addMethods_finish(Class cls, method_list_t *newlist) {
    auto rwe = cls->data()->extAllocIfNeeded();
    if (newlist->count > 1) {
        method_t::SortBySELAddress sorter;
        std::stable_sort(&newlist->begin()->big(), &newlist->end()->big(), sorter);
    }
    prepareMethodLists(cls, &newlist, 1, NO, NO, __func__);
    rwe->methods.attachLists(&newlist, 1);
    flushCaches(cls, __func__, [](Class c){
        return !c->cache.isConstantOptimizedCache();
    });
}

这里面的 attachLists 是往 class_rw_ext_tmethod_array_t 中添加一个 method_list_t,如果 method_array_t 的修改都经过此函数的话,那我们也能定位到镜像载入时添加 methods 的时机。

attachLists 符号断点

第二帧 attachCategories 正是我们所需要的函数!其中 1418 行取出了一个 method_list_t *,猜测我们所要找的 method 就在其中。

// objc-runtime-new.mm
static void
attachCategories(Class cls, const locstamped_category_t *cats_list, uint32_t cats_count,
                 int flags)
{
    { ... }
    for (uint32_t i = 0; i < cats_count; i++) {
        auto& entry = cats_list[i];
        method_list_t *mlist = entry.cat->methodsForMeta(isMeta); // objc4-818.2 line 1418
        { ... }
    }
    { ... }
}

如果需要输出 method_list_t 有两种形式,第一种是将 Objective-C runtime 切换至 debug 模式,直接源码输出;另外一种是在汇编中找到这个指针,然后进行输出。这里主要介绍第二种方式。

由于 method_list_t 和其内部的 method_t 都不是 public 的,无法在代码中或者 lldb 中直接输出结构,所以需要在 Demo 中手动构造这个二进制兼容的结构再输出。

首先构造 method_t。通过查阅源码可知,method_t 的内存结构有两种形式 struct bigstruct small,big 这种形式就是我们日常认知的使用三个指针存储 SEL, types, IMP ,而在镜像中存储的形式是 small,其存储的是 3 个 32 位的 offset,需要加上当前地址后,才能得到真正的指针。

template <typename T>
struct RelativePointer: nocopy_t {
    int32_t offset;
    T get() const {
        if (offset == 0)
            return nullptr;
        uintptr_t base = (uintptr_t)&offset;
        uintptr_t signExtendedOffset = (uintptr_t)(intptr_t)offset;
        uintptr_t pointer = base + signExtendedOffset;
        return (T)pointer;
    }
};

struct p_method_t {
    static const uint32_t smallMethodListFlag = 0x80000000;
    struct small {
        RelativePointer<const void *> name;
        RelativePointer<const char *> types;
        RelativePointer<IMP> imp;
    };
    small &small() const {
        return *(struct small *)((uintptr_t)this & ~(uintptr_t)1);
    }
    SEL getSmallNameAsSELRef() const {
        return *(SEL *)small().name.get();
    }
    struct pointer_modifier {
        template <typename ListType>
        static p_method_t *modify(const ListType &list, p_method_t *ptr) {
            if (list.flags() & smallMethodListFlag)
                return (p_method_t *)((uintptr_t)ptr | 1);
            return ptr;
        }
    };
};

再构造 method_list_t,这可以理解为存储 method_t 的不定长数组,头部为固定的 entsizeAndFlagscountmethod_t 平铺在其之后。源码中 method_list_t 使用了模版,但我们这里只需要一个二进制兼容的结构,就直接将模版变量搬运过来即可,迭代器也是可以省略的。

uint32_t FlagMask = 0xffff0003;
struct p_method_list_t {
    uint32_t entsizeAndFlags;
    uint32_t count;
    uint32_t entsize() const {
        return entsizeAndFlags & ~FlagMask;
    }
    uint32_t flags() const {
        return entsizeAndFlags & FlagMask;
    }
    p_method_t& get(uint32_t i) const {
        return *p_method_t::pointer_modifier::modify(*this, (p_method_t *)((uint8_t *)this + sizeof(*this) + i*entsize()));
    }
};

有了结构之后,我们的输出就非常简单了,由于 C++ (上述结构都是在 Objective-Cpp 中定义的)是有函数签名的,需要接接收一个 unsigned long 的参数,然后转成 ptr.

void PrintMethodList(unsigned long ptr) {
    p_method_list_t *mlist = (p_method_list_t *)(ptr);
    for (int i=0; i<mlist->count; i++) {
        p_method_t& method = mlist->get(i);
        printf("%s \n", (char *) method.getSmallNameAsSELRef());
    }
};

对照着attachCategories的源码阅读汇编,定位到取出 method_list_t 的位置,打上条件断点,每次触发该断点之后输出触发次数和该 method_list_t

启动 demo,(推荐在 _accessibilityBundlePrincipalClass 之后打开再打开断点,否则信息太多太慢),可以看到目标方法是在第 245 次命中断点时的 method_list_t 中。

再次启动 app,还是同样的方式,只是这次,忽略前 244 次。

把 Method Swizzling 放在这个时间点之后就可以下班收工啦。

结论

  1. 目标方法是一个普通的 Objective-C Method,并非 Direct Method
  2. 目标方法在 +[NSObject load] 的时候并未被加载,此时进行 Method Swizzling 会找不到目标方法
  3. 目标方法是动态加载的,其是在 UIApplicationMain 函数中载入了 Bundle 被加载的,而 UIApplicationMain 的时机显然是要晚于 +load 的。
  4. _dyld_register_func_for_add_image 的时机在目标方法载入之前,该通知回调时,当前镜像的方法并没有被载入。