TL;DR
- +load 中进行 Method Swizzling 并不是绝对安全。此时方法可能不存在。
- 如果是 Direct Methods 的情况,需要直接 Hook。
- 如果是 Dynamic Load 的情况,需要延迟 Method Swizzling 的时机。
- 使用更健壮的 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
中发现了目标方法:
真相大白。
一个更优化的方案
虽然通过前面的方式大大缩减了搜寻范围,但载入的镜像也有数十个之多,手动去一个个镜像查找似乎不是一个优雅的方式。
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_t
的 method_array_t
中添加一个 method_list_t
,如果 method_array_t
的修改都经过此函数的话,那我们也能定位到镜像载入时添加 methods 的时机。
第二帧 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 big
和 struct 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
的不定长数组,头部为固定的 entsizeAndFlags
和 count
,method_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 放在这个时间点之后就可以下班收工啦。
结论
- 目标方法是一个普通的 Objective-C Method,并非 Direct Method
- 目标方法在 +[NSObject load] 的时候并未被加载,此时进行 Method Swizzling 会找不到目标方法
- 目标方法是动态加载的,其是在 UIApplicationMain 函数中载入了 Bundle 被加载的,而 UIApplicationMain 的时机显然是要晚于 +load 的。
- _dyld_register_func_for_add_image 的时机在目标方法载入之前,该通知回调时,当前镜像的方法并没有被载入。