BlockHook and Memory Safety
摘要:在 BlockHook with Private Data 这篇文章里我曾经介绍过一种『骨骼惊奇』的 Block,不能直接替换 invoke 函数指针来 Hook。当时这么做的原因我也在文章里写了,GCD 源码中会检查 Block 的 invoke 指针是否为 _dispatch_block_special_invoke ,以此判断 Block 是否包含 Private Data。
BlockHook 最近修复了一些内存安全方面的问题,记录下这些问题的解决思路:
- 微信项目使用 BlockHook 时的 MRC 兼容问题
- GlobalBlock 在某些场景下的 VM Protection 没有写权限
- 如何检测带有 Private Data 的 block
修复 BlockHook 在 MRC 上的问题
ARC 下将 StackBlock 赋值时,会自动 copy 成 MallocBlock。不过这个编译器帮我们做的隐式行为的前提是代码里显示声明为 Block 类型。而 BlockHook
为了能够传入各种签名的 aspectBlock
,恰恰用的是 id
:
- (nullable BHToken *)block_hookWithMode:(BlockHookMode)mode usingBlock:(id)aspectBlock;
如果调用方用的是 MRC,即便 BlockHook
是用 ARC 实现的,那么拿到的 aspectBlock
依然是 StackBlock。当被 Hook 的 Block 异步执行时, aspectBlock
也需要异步执行,但它早已经在栈上被释放,进而由于野指针而 crash。
这就是在微信项目里使用 BlockHook 时遇到的问题。当劳动节的下午我正出门去吃饭路上,微信的同事在企业微信上找到了我反馈了这个 bug。我由于路上匆忙没仔细看手机,一开始以为是我另一个同事找我。看问题截图上 Xcode 工程名我还以为他逆向调试微信用了 BlockHook 干啥坏事嘞,于是回了一句『你是真的牛逼』。再定神一看我擦是微信巨佬,虽然贼尴尬但只好装作没事一样继续看问题。。。扯远了。。。
微信巨佬果然是巨佬,还给了我解决方案。我照着巨佬给的思路, copy
了传入的 aspectBlock
:
// If aspectBlock is a NSStackBlock and invoked asynchronously, it will cause a wild pointer. We copy it. _aspectBlock = [aspectBlock copy];
解决 GlobalBlock 没有写权限的问题
用 Xcode 11 编译时,将 Deployment Info 中的 target 选择 iOS 13 后,GlobalBlock 对象所占的内存是只读的,这就导致 Hook 过程中无法对 invoke
函数指针做写操作,直接 crash。
首先需要判断下 invoke
指针对应的地址有没有写权限,如果没有写权限则需要提权。这涉及到 VM Region 和 Protection 的一些操作,在获取内存地址的基本信息时也要注意区分下 64 位和 32 位:
static vm_prot_t ProtectInvokeVMIfNeed(void *address) { vm_address_t addr = (vm_address_t)address; vm_size_t vmsize = 0; mach_port_t object = 0; #if defined(__LP64__) && __LP64__ vm_region_basic_info_data_64_t info; mach_msg_type_number_t infoCnt = VM_REGION_BASIC_INFO_COUNT_64; kern_return_t ret = vm_region_64(mach_task_self(), &addr, &vmsize, VM_REGION_BASIC_INFO, (vm_region_info_t)&info, &infoCnt, &object); #else vm_region_basic_info_data_t info; mach_msg_type_number_t infoCnt = VM_REGION_BASIC_INFO_COUNT; kern_return_t ret = vm_region(mach_task_self(), &addr, &vmsize, VM_REGION_BASIC_INFO, (vm_region_info_t)&info, &infoCnt, &object); #endif if (ret != KERN_SUCCESS) { NSLog(@"vm_region block invoke pointer failed! ret:%d, addr:%p", ret, address); return VM_PROT_NONE; } vm_prot_t protection = info.protection; if ((protection&VM_PROT_WRITE) == 0) { ret = vm_protect(mach_task_self(), (vm_address_t)address, sizeof(address), false, protection|VM_PROT_WRITE); if (ret != KERN_SUCCESS) { NSLog(@"vm_protect block invoke pointer VM_PROT_WRITE failed! ret:%d, addr:%p", ret, address); return VM_PROT_NONE; } } return protection; }
在修改 invoke
指针后,还需要恢复原来的权限。相当于我只是在需要替换 invoke
指针的时候临时开了写权限:
static BOOL ReplaceBlockInvoke(struct _BHBlock *block, void *replacement) { void *address = &(block->invoke); vm_prot_t origProtection = ProtectInvokeVMIfNeed(address); if (origProtection == VM_PROT_NONE) { return NO; } block->invoke = replacement; if ((origProtection&VM_PROT_WRITE) == 0) { kern_return_t ret = vm_protect(mach_task_self(), (vm_address_t)address, sizeof(address), false, origProtection); if (ret != KERN_SUCCESS) { NSLog(@"vm_protect block invoke pointer REVERT failed! ret:%d, addr:%p", ret, address); } } return YES; }
虽然我还没花时间去追查苹果爸爸为啥要在 Xcode 11 上 iOS 13 target 编译时给 GlobalBlock 只读权限,但理论上我的这个操作并不是对非法内存地址的提权,应该是被允许的,毕竟线上检测是否越狱等功能也会用到这些 API。但我还是不放心,请教了页面仔大佬后,答复是可以上架,终于安心了,也期待下个版本可以试试。
如果有大佬知道苹果爸爸为何会这样做,或者有更优雅更安全的方案,请给小弟赐教,欢迎指出缺陷,一起开源共建。
优化 BlockHook 检测 Private Data 的方式
在 BlockHook with Private Data
这篇文章里我曾经介绍过一种『骨骼惊奇』的 Block,不能直接替换 invoke
函数指针来 Hook。当时判断这类带有 Private Data 的 Block 的依据是直接用 Private Data 中的 dbpd_magic
字段与 DISPATCH_BLOCK_PRIVATE_DATA_MAGIC
判等:
DISPATCH_ALWAYS_INLINE static inline dispatch_block_private_data_t bh_dispatch_block_get_private_data(struct _BHBlock *block) { // Keep in sync with _dispatch_block_create implementation uint8_t *x = (uint8_t *)block; // x points to base of struct Block_layout x += sizeof(struct _BHBlock); // x points to base of captured dispatch_block_private_data_s object dispatch_block_private_data_t dbpd = (dispatch_block_private_data_t)x; if (dbpd->dbpd_magic != DISPATCH_BLOCK_PRIVATE_DATA_MAGIC) { return nil; } return dbpd; }
我知道这种暴力 Memory Overflow 的行为有潜在隐患,而且 调试时开启了 Address Sanitizer 后会必现 crash
。当时这么做的原因我也在文章里写了,GCD 源码中会检查 Block 的 invoke
指针是否为 _dispatch_block_special_invoke
,以此判断 Block 是否包含 Private Data。而这个标志位指针是私有的,我无法在没有符号表的场景下获取到。现在想想当时的自己真是个 SB,当初这么简单的问题,其实现在换个思路不就解决了:
DISPATCH_ALWAYS_INLINE static inline dispatch_block_private_data_t bh_dispatch_block_get_private_data(struct _BHBlock *block) { if (!blockWithPrivateData) { blockWithPrivateData = dispatch_block_create(0, ^{}); } if (block->invoke != ((__bridge struct _BHBlock *)blockWithPrivateData)->invoke) { return nil; } // Keep in sync with _dispatch_block_create implementation uint8_t *privateData = (uint8_t *)block; // privateData points to base of struct Block_layout privateData += sizeof(struct _BHBlock); // privateData points to base of captured dispatch_block_private_data_s object dispatch_block_private_data_t dbpd = (dispatch_block_private_data_t)privateData; if (dbpd->dbpd_magic != DISPATCH_BLOCK_PRIVATE_DATA_MAGIC) { return nil; } return dbpd; }
既然无法直接拿到 _dispatch_block_special_invoke
指针,那我干脆创建一个带有 Private Data 的 Block 然后取它的 invoke
指针不就搞定了吗!现在看看当初的自己好傻啊。
最后谈谈 BlockHook
其实 BlockHook 的诞生纯属偶然,起初是我本想做些其他关于 Block 的事情,但技术太菜一直没搞成。一顿瞎折腾失败后,剩余的代码就是 BlockHook 的雏形。然后业余时间不断踩坑和填坑,收到用户反馈后不断打磨,最终搞出了个能用的版本。我本以为打磨了这么久,应该没啥大问题了,然而还是不断有新的问题和挑战出现。毕竟自己曾经吹下了牛皮,含着泪也要继续打磨下去。有时候兴趣带来的动力真的远超 KPI 的压力,让人干劲十足,哈哈。
我曾经吹牛说 BlockHook 『(应该是)填补了 Objective-C 业界在 Hook Block 技术领域的空白』,这件事也一直被五子棋嘲讽。后来他跟我说之前肯定有人做过这件事,不过记不清是哪个项目了。我也很想知道在这之前是否有人 Hook 过 Objective-C 的 Block,也跪求打脸并虚心接受。但我对 Hook 的理解并不是局限于替换个函数指针 IMP 就可以了,我个人觉得能配得上是 Hook/AOP 的框架,至少要满足下面几个要求中的大部分吧:
- 用同一个 Hook 框架多次 Hook,能够有完整的 Hook 调用链。甚至能兼容其他框架。
- 兼容 90% 以上的使用场景,经得住大规模验证(不一定线上,也可以是作为测试工具)。
- 不能为了『轻量级』和高性能而去牺牲兼容性、鲁棒性和易用性,否则就是实现度不够。
- 支持 Revert Hook,最好能 Revert Hook 链的中间节点,甚至能完美还原现场。
其实替换个函数指针并用 libffi 调用任意函数之类的事情随便找个人都会很快上手,如果就只做了这点事情我个人是不敢称其为 Hook/AOP 框架的。 BlockHook 的大部分内容都是解决上面所列出的几点要求,并且自认为解决的还算不错。所以 BlockHook 是否填补了业界空白,就看大佬们如何看待 Hook 这件事情的定义了。PS: 可能会误伤一些人,千万别对号入座啊。我也曾经搞过『轻量级』的轮子,性能也牛逼,其实问题一堆实现度很低。我其实在吐槽我自己。。。
最后,跪求苹果爸爸别搞事情了。。。