iOS逆向实战:hook实现微信群@所有人的完整流程
搞iOS逆向这个圈子很有意思外行觉得是黑客专属内行知道它其实就是拿着放大镜看别人写好的代码。前阵子我倒腾微信目标很简单实现一个“艾特所有人”的hook插件在发消息时一键让群聊里的每个人都收到提醒。听起来有点恶搞但真做起来从砸壳、class-dump、定位类方法到编写Tweak、调试崩溃完整走了一遍iOS端hook的标准流程。这篇文章想把每一步的思考逻辑和踩过的坑都留下来给想在iOS逆向和hook方向入门的朋友做参考同时也整理一些调试大型App时能用的通用思路。1. 先拆需求微信的“艾特所有人”到底是什么1.1 一个看似简单的功能涉及多个模块在微信群聊里默认没有“一键艾特所有人”的入口用户只能手动选择单个群成员。虽然可以通过输入“”加名字做模糊搜索但群规模到几百人时这种操作基本不可用。从产品逻辑看微信故意不做这个功能很大程度是为了避免骚扰从技术层面看它其实并不是一个特别难的“新功能”而是一个对现有发送链路的“参数篡改”。“艾特所有人”本质上不是一种特殊消息而是多条“成员”指令的集合。一条消息里之所以能有多个高亮名字是因为消息体内记录了被成员的username列表。客户端要做的事只有三件拿到当前群聊的完整成员列表构造一条微信能识别的消息体在内容里标记这些成员调用发送接口时把客户端内部维护的“被联系人数组”替换成整个成员列表。所以hook的切入点并不在UI层而是在“发送消息”这个动作发生前修改消息携带的“被联系人”参数。你不需要去模拟用户在输入框里一个个点选只需要站在微信自己的代码后面替它把“联系人列表”换掉。这引出了iOS逆向的第一个关键认知不要被界面表现迷惑功能背后一定有一个更底层的方法调用链。输入框里显示的内容只是渲染层真正决定消息结构的是内存对象模型。微信本身是用Objective-C写的方法调用遵循runtime机制这给了我们很大的hook空间。1.2 为什么选hook而不是直接改包明确了目标接下来要选择实现路径。iOS上修改别人的App有三种常见做法第一种是直接改二进制文件把关键代码patch掉第二种是重签名打包在Mach-O中注入一个自定义动态库让App启动时加载我们的逻辑第三种是越狱环境下通过Substrate或fishhook在运行时hook方法。实际开发时往往组合使用通常“砸壳重签名动态库注入”或“Theos越狱插件”二选一。直接patch二进制的缺点很明显每换一个微信版本都要重新定位字节码、维护补丁而且微信有完整性校验一旦发现文件被改就拒绝启动或闪退。相比起来hook的优势在于不动原始代码只是在方法调用时插入一层“代理逻辑”处理完再调用原来的实现。这个思路就像给电话装了一个分机监听你不是把对方电话拆了只是在它响铃时先接一下再转过去。我在项目里更推荐用Theos写Tweak因为它基于Logos语法可以很直观地表达“hook哪个类、哪个方法、怎么改”编译完直接生成deb插件安装到越狱设备上调试效率非常高。如果是非越狱设备需要用MonkeyDev或手动重签名虽然麻烦一些但原理一致把一个dylib注入到微信的Mach-O加载命令里让dyld在启动时把我们的代码装进进程。1.3 整体工作流程总览整个项目可以分成五个阶段每一个阶段目的都很明确阶段核心动作产出准备设备越狱、安装Theos、配置开发环境可编译Tweak的环境砸壳从内存导出解密后的微信可执行文件未加密的Mach-O分析class-dump导出头文件grep定位方法类名、方法名、参数结构编码编写Logos代码替换目标参数安装到微信的tweak插件调试日志、LLDB、logify验证流程稳定的可用版本这个流程适用于绝大多数iOS闭源App分析不只是微信。把“微信”换成任何一个你感兴趣的App核心路径都是一样的。难的不是某一步操作而是如何在缺少官方文档的情况下从几千个类里精准找到“干预点”。2. 逆向分析从App Store版本到类名和方法2.1 建立工具链越狱设备、Theos、砸壳工具写hook之前得先把微信的“地图”画出来。App Store下载的微信是可执行文件但被Apple加密过直接拿class-dump导出头文件会得到一堆乱码必须先“砸壳”。砸壳的原理很简单iOS的可执行文件在运行时内核会把解密后的内存镜像加载进进程我们只要从内存里把这段完整镜像导出来就行。常用的工具是frida-ios-dump或dumpdecrypted。我习惯用frida-ios-dump因为整套流程很顺手机连上电脑指定进程脚本会自动定位可执行文件、dump加密段、修复Mach-O文件头然后在Mac上生成一个解密后的ipa。整个过程十分钟以内。砸完壳把解出来的Mach-O拖给class-dump它会根据Objective-C的runtime元数据重建所有头文件。微信的头文件数量相当夸张我导出过一版大概有几千个.h文件光浏览类名就得一阵子。这时还需要准备Theos环境这是iOS越狱开发的老牌工具链类似一个简化版的Xcode专门编译Tweak。装好之后用nic.pl创建一个tweak模板会自动生成Makefile、control、Tweak.xm。xm后缀表示文件里可以混用Objective-C和Logos语法。后续所有hook逻辑基本都写在Tweak.xm里。Theos默认会把编译出的deb依赖mobilesubstrate这也是Substrate能正常加载插件的必要条件。2.2 在海量符号里定位“发送消息”的入口拿到头文件后不要急着逐个翻先用grep把关键词筛出来。微信的代码命名总体还算有规律比如联系人相关的是CContact、ContactMgr群聊相关的是ChatRoom、Group消息相关的是Message、Session。我的思路是先找“消息管理”的核心服务类。在微信的架构里有一个类似服务注册中心的单例叫MMServiceCenter所有核心业务模块都挂在上面。发送消息一般会经过CMessageMgr或者CMessageSend这类类它们负责把输入框的富文本内容转成消息体然后走网络层发送。通过class-dump导出的接口可以看到这些类有大量类似SendMessage:、SendTextMessage:toUsrName:的方法。为了缩小范围我还在CMessageMgr的头文件里搜索包含At或者ContactList的方法名。这一步是决定成功率的重点如果你找错了入口后续所有hook都是白费。实践里可以用一个取巧的技巧先用logify.pl把头文件生成一个带NSLog的Objective-C实现再作为tweak注入微信把所有相关方法的调用过程打出来。启动后跑到聊天界面随便发一条带的消息控制台上就能看到实际走过的核心方法序列这时候再挑几个关键方法做hook命中率极高。2.3 找到与“被联系人”相关的数据结构定位到消息发送方法后还有一个关键问题方法参数里哪个才是“被联系人”群里发过的人都知道消息文本里有蓝色高亮的名字但这些名字只是UI展示。在消息对象内部微信会用某种数据模型保存“被成员的用户名列表”发送时要传给服务器。以我当时分析的某个版本为例消息发送接口大概长这样- (BOOL)SendMessage:(id)msg toUsrName:(NSString *)userName WithContent:(NSString *)content AtList:(NSArray *)atList;atList就是要替换的目标。如果它是nil消息不会提醒任何人如果它包含一个成员的username微信就会把这条消息识别成“了这个人”。所以实现“艾特所有人”变得很直接在原始方法执行前把atList改成当前群所有成员的username数组。但这里有个陷阱不同版本微信的参数名和类型可能完全不同有的版本用WCContactField有的用NSArray有的甚至把atList封装在CMessageWrap对象里。所以不能完全依赖静态分析必须结合运行时日志确认从UI上发一条消息时某个参数的值确实从空变成了目标成员数组。我在项目里就是先手动两个人再看po atList的输出通过对比很轻松就能确定哪个参数是真正被使用的。3. 编写Tweakhook代码与关键问题处理3.1 搭建Theos工程并写第一个hook明确了入口和参数结构接下来是写代码。在Theos的tweak模板基础上第一步是修改control文件填写包名、版本、依赖比如Depends: mobilesubstrate。然后在Tweak.xm里写Logos代码。Logos的语法很简单%hook声明要hook的类%orig调用原方法%end结束。一个最基础的示例是%hook CMessageMgr - (void)SendMessage:(id)msg toUsrName:(NSString *)userName WithContent:(NSString *)content AtList:(NSArray *)atList { if ([self isChatRoom:userName]) { NSArray *members [[CMemberManager sharedManager] getMembersForChatRoom:userName]; atList members; } %orig(msg, userName, content, atList); } %end这里把atList替换成群里所有成员后再调用原始发送方法。编译前需要确认CMessageMgr这个类在当前版本中真实存在方法签名也准确。为了减少对具体类名的依赖我在代码里更多使用NSClassFromString和objc_msgSend动态调用这样即使微信改了类名只要在配置表里维护一份映射改动成本也很小。3.2 获取群成员列表的可靠姿势要拿到群成员列表最可靠的是复用微信自己的数据层。虽然可以直接请求服务器接口但那样又要处理异步和网络状态太麻烦。微信本地存了一份群成员数据的缓存通常在Contact服务里。只要找到CContactMgr或ContactCache就能拿到完整的成员列表。我在实现里用的是“通过服务中心取单例”的方式id serviceCenter [NSClassFromString(MMServiceCenter) defaultCenter]; id contactMgr [serviceCenter getService:NSClassFromString(CContactMgr)]; NSArray *members [contactMgr getGroupMemberList:userName];实际方法名可能不一样但思路是一致的先拿到App自己的服务对象再调用它已经封装好的方法。这样做的好处是成员列表的拉取、缓存、排序逻辑微信自己都处理好了不需要重复造轮子。如果方法返回的是包含昵称和用户名的CContact对象数组还要做一次映射只提取里面的m_nsUsrName字段因为这个字段才是服务器识别成员用的username。3.3 防止重复触发和异常情况下沉一个容易被忽略的问题是一旦hook了发送方法你就接管了一个全App都会调用的关键路径。如果你判断条件写得不够严谨不仅你主动发的消息会带所有人别人发给你的消息、你去聊天窗口输入的内容甚至一些后台自动发出的消息都可能被误伤。所以必须加两层保护。第一层是开关。只在你主动输入一个特殊前缀时才启用替换比如消息内容以开头或者通过浮动开关控制。默认关闭需要时才打开。第二层是群聊判定。只有userName在微信内部是群聊ID时才执行替换这个可以通过联系人类型判断[[CContactMgr sharedContactMgr] getContact:userName]的m_uiType是群聊类型或者直接用isChatRoom:这类方法。千万不要对所有会话生效。另外获取群成员列表的方法有可能是异步的尤其是在第一次进入大群时本地缓存还没加载完成。如果此时的成员列表是空的你发出去的消息就变成了普通消息甚至因为数组为空而产生逻辑异常。稳妥的做法是先把%占位符写进消息内容然后用异步回调替换联系人数组在拿到成员列表后再正式发送。不过微信的发送链路通常是同步的更简单的方法是延迟50-100毫秒再调用原方法等缓存填充完毕。3.4 用logify和LLDB验证是否生效写完代码不等于结束还要验证hook是否真的生效。最直接的办法是在替换atList之后和调用%orig之前用NSLog打印出参数。Theos的日志可以写到系统日志用log show或者ideviceconsole查看。如果发现消息发出去后没有出现“所有人”效果大概率是两种情况一是原方法在hook方法之后又把atList重置了说明hook的层级不对需要找一个更晚执行的发送方法二是atList的元素格式不对微信需要的是“username”数组而你传了“昵称”进去服务器无法匹配。这个问题可以通过断点确认。用LLDB附加到微信进程在%hook里打上断点然后用po atList看数组元素内容。如果恰好是CContact: 0x...这种对象说明需要额外取m_nsUsrName字段不能直接把数组塞进去。3.5 处理富文本与消息展示有人可能会问既然改了atList那聊天界面里会不会显示所有成员的名字这个问题要看微信的消息渲染逻辑。消息发送时AtList是给服务器识别用的而本地展示的富文本内容通常是在发送前由输入框组件根据AtList生成的一串带“特殊run”的文本。如果你只是修改了atList而没处理content发送方本地可能只显示你输入的普通文本不过服务器和接收方还是能正确识别出提醒因为服务器解析消息时用的是AtList和消息内容里的标记而不是看本地富文本渲染。为了让发送方自己也能看到完整的“所有人”效果可以再hook输入框的富文本插入逻辑在发送前把消息内容加一段“所有人”字样。但这部分工作比较繁琐而且正向实现方式多种多样不建议一开始就做。先保证功能通再慢慢优化UI呈现。我第一版只改atList消息照样能正常发出接收方也能收到提醒说明核心链路已经打通。4. 常见问题速查表与适配经验4.1 必须避开的几个崩溃雷区我在这个项目里踩到的第一个坑是直接hook了不存在的方法。iOS的Objective-C方法调用如果没有通过respondsToSelector:检查直接%hook会编译失败或者运行时报“unrecognized selector”。这在Tweak里很常见因为class-dump导出的是接口声明不代表每个版本都有同名的实现。更稳妥的方式是先打印class_copyMethodList确认方法确实存在再写hook代码。第二个坑和内存管理有关。在Logos代码中%orig的参数需要和原方法完全一致如果漏传或多传轻则参数错位重则越界崩溃。尤其像NSArray这种对象参数ARC和MRC混用容易导致提前释放。我建议所有从参数里取出来的对象如果后面要异步使用先retain或者用Copy属性包一层避免野指针。第三个坑是签名和权限。如果你在非越狱设备上做重签名打包微信有“防止重签名”的检测逻辑启动时可能会闪退同时Apple的get-task-allow和task_for_pid权限也直接决定你能不能附加调试器。个人经验是先降低目标版本要求用一台越狱设备做开发调试正式流程走通后再考虑非越狱场景。4.2 不同微信版本的适配策略微信基本每周都会更新核心类的接口说变就变。我见过用class-dump导出的头文件里同一个方法在不同版本有完全不同的参数类型。为了不让自己被更新拖垮最实用的方式是建立一张“版本-方法-参数”映射表每次升级后用logify快速扫描一遍调用链更新映射即可。这种方法比持续维护二进制补丁简单得多。另外开发期尽量把tweak的日志开关做成运行时可控。比如通过读取一个配置文件来决定是否输出日志不用每次改代码重新编译。我习惯用一个轻量的UISwitch嵌入到微信的设置页这样在真机上调试时不需要反复连电脑手动拨动开关就能复现问题。这个思路其实是从服务端的feature flag借鉴过来的在逆向工程里同样好用。4.3 常见问题速查现象可能原因排查方式插件安装后微信闪退hook了不存在的方法或签名不匹配先用class_copyMethodList确认方法存在消息发出去但无提醒atList被后续逻辑重置找一个更晚的发送hook点或用断点观察参数写入时机只有部分成员被拉取群成员列表不完整检查本地缓存是否加载完成必要时等待异步回调普通私聊消息也变没做群聊类型判断增加isChatRoom:或联系人类型判断重新签名后启动白屏微信做了完整性校验检查dylib注入方式考虑改用越狱Substrate方案这张表是我实际排障时最常用的路径。遇到问题不要慌先确定现象属于“安装阶段”“运行阶段”还是“发送阶段”再针对性地看日志或断点。4.4 从hook中学到的调试心法说实话整个项目最有价值的部分反而不是最终插件而是过程中练出来的调试能力。iOS逆向的调试和普通App开发很不一样你没有源码没有Xcode的完整断点支持大部分时间只能靠日志、动态调用和“猜测-验证”循环。后来我养成了一个习惯每到一个新类先用class_copyMethodList把所有方法名打出来再用LLDB挨个调用逐步缩小范围。这种方式看起来笨但非常可靠。一个小技巧写一个通用的“方法调用日志”tweak用MSHookMessageEx对所有匹配规则的方法统一hook自动打印方法名、参数和返回值。这个工具一旦做出来以后分析任何App都能复用。我就用它快速定位过好几个模块比手动改代码快得多。尤其是在微信这种大App里方法调用链可能跨好多个类没有日志的话靠肉眼翻头文件真的会疯掉。5. 做“艾特所有人”插件之外的思考5.1 能从零到一跑通这套流程的收获这个项目看起来只是一个小功能真正跑通一遍之后你会发现收获远远超过“能所有人”这个结果。你理解了iOS的可执行文件结构、Objective-C runtime的消息机制、Substrate的hook原理还掌握了砸壳、符号分析、动态调试这一整套逆向工具链。这些东西在正经的iOS开发里同样有价值排查线上崩溃时面对没有符号的崩溃栈你至少能判断出崩溃在哪个模块做性能优化时也知道hook一个方法需要的成本并不是零。而且这种“从黑盒到透明”的能力是可以迁移的。今天你能分析微信的发送消息链路明天换个App、换个功能只需要重复同样的路径砸壳、倒头文件、找关键词、hook验证。它不是某个版本的特有技巧而是一套可复制的方法论。5.2 守住边界技术能力不等于可以乱用最后想多说一句像这样的hook能力目的是学习和研究不是用来批量骚扰用户。微信群里的“艾特所有人”如果被滥用对被的人来说其实是一种打扰也容易触发微信的安全策略。我建议所有代码只在测试群、小号里跑不要拿真实好友和正式群做实验。微信风控做得很细一旦检测到异于常规的操作限制消息能力或封号都是可能的结果。这不是小题大做是真的见过有人因为写了个自动发言脚本试了一次第二天就被限制功能了。技术在往外走的时候边界感也要跟上。做逆向最应该敬畏的是平台上每一个真实的使用者。研究原理、提升自己的技术深度完全没有任何问题但请一定把能力用在合理的地方。这次的“艾特所有人”插件本身只是一个不错的技术练习而不是一个可以拿去骚扰别人的工具。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →