多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

iOS逆向实战:反编译与Method Swizzling剖析微信@所有人权限逻辑

iOS逆向实战:反编译与Method Swizzling剖析微信@所有人权限逻辑 那天晚上我在整理一台测试设备上的脱壳样本微信的聊天记录数据库和研究用的砸壳包都躺在桌面上旁边是一个被我折腾了好几轮的调试脚本。起因特别简单群里有人问为什么普通群成员看不到“所有人”这个选项只有群主和管理员能用。我随口说了一句“这玩意儿估计就是个权限判断的事”但说完自己心里也没底。打开Ghidra翻了半小时之后我发现这事根本没那么简单——iOS反编译这条链路从头到尾走一遍你才能搞清楚一个看似不起眼的按钮背后到底站着哪几层代码。这篇内容想记录的就是这个完整过程从一个微信测试包出发用反编译和hook的手段把“艾特所有人”这个功能从界面到数据层面拆干净。适合对iOS逆向、Objective-C运行时机制、静态分析与动态调试感兴趣的人。我不打算绕弯子也不打算把结论藏在最后先把话放这儿微信里“所有人”本质上是两层东西叠在一起——一层是UI上的入口可见性另一层是消息体里特殊的mention标记。你能hook到哪一层取决于你手里有什么样的运行环境以及你打算做到哪一步。所有分析只针对本地测试环境的逆向学习不涉及绕过服务端校验、不搞群发骚扰、更不碰任何灰度外挂分发渠道。1. 先给目标定性所有人是权限功能还是消息协议功能动手之前最重要的事不是打开工具开干而是把问题拆开。我见过太多人上来就把Mach-O丢进Ghidra搜完“所有人”三个字没搜到就开始骂娘其实是因为根本没想清楚目标长什么样。1.1 把“艾特所有人”拆成三层一个完整的“所有人”交互在代码层面最少涉及三层UI层聊天输入框里点“”之后弹出的联系人选择器以及选择器顶部或某个位置出现的“所有人”选项。这一层决定你在界面上看不看得到它。数据层消息体在发送时如何携带“我被了”这个信息。微信里某个人的消息本质上是在内容里插入特定格式的标记所有人就是其中一个特殊的标记位。权限层入口的显示和发送是否被允许是由客户端写死的逻辑判断还是由服务端在下发群信息时就标注好了。这三层对应着完全不同的代码位置和hook思路。很多人一上来就想在发送消息的方法上做文章但如果权限判断不在本地光改客户端就跟对着空气挥拳一样。反过来如果“所有人”这个选项只是UI层被隐藏了那hook一下数据源就能让它在界面上出现但这不代表你发送的消息能被服务端当成合法的所有人处理。1.2 为什么“所有人”字符串是第一个突破口Objective-C运行时的特性决定了iOS App里的大部分方法名都是以字符串形式明文存在的。微信虽然号称用了各种加固但它的核心业务逻辑大量依赖runtime的消息派发机制没法把class名和selector完全抹掉。这就意味着head数据里搜“所有人”“All”“mention”这些关键词通常是性价比最高的第一步。不过要提醒一句中文字符串不一定躺在主二进制里很多文案会被编译到Localizable.strings或其它资源文件里。如果你在Ghidra的字符串列表里搜不到“所有人”不要觉得目标找错了先去检查有没有加密的资源包再去看看App的bundle目录里有没有现成的strings文件。我那次就是在resources目录下的一个plist里先看到了线索然后才反查回到代码里的。1.3 给目标代码画像在搜索之前你可以先给自己画个像我们要找的代码应该具备什么特征如果目标是UI层的入口那附近应该会有table view的cell配置方法或者某个成员列表的数据源方法返回值是Bool或者数组如果目标是消息体的构造逻辑那附近应该会有拼接字符串的方法里面大概率出现了at、userName、wxid这类词如果目标是权限判断那函数里大概率会有角色类型枚举的比较比如管理员、群主、普通成员之类的分支。先想清楚你要找什么再让工具帮你找搜索效率会翻倍。不然你就是在监狱一样的符号表里瞎转悠。2. 反编译准备脱壳、class-dump、Ghidra一套走完工欲善其事必先利其器。但“利器”不是越多越好你这台机器上装十个逆向软件不如把两三个用得明明白白。2.1 从App Store包到可分析样本如果你只是扔一个从App Store下载的ipa进Ghidra大概率会看到一个只有一堆加密数据的Mach-O符号表是空的字符串是乱码根本没法看。iOS的App Store二进制在运行时靠DRM解密静态分析之前必须先砸壳。砸壳这个步骤没什么好藏着掖着的就是把自己手里越狱测试设备上运行着的App的内存镜像导出来再替换掉原ipa里的加密二进制。实际操作上我用的是frida-ios-dump那一套配合一台iPhone和一台Mac。砸壳完成后把可执行文件拖进Ghidra能看到的代码和字符串才是有意义的东西。提示如果你不想折腾越狱环境也可以去找一些学习社区里公开的已脱壳测试样本但要注意样本来源和合规性。我是坚持“自己设备自己砸壳”这个习惯的至少你知道你分析的东西是什么版本不会被投毒。2.2 class-dump让Objective-C类结构一览无余拿到砸壳后的Mach-O接下来就是class-dump上场。这个工具的用途很简单把Objective-C的类、方法、属性、协议从二进制里提取出来生成一堆头文件。微信的头文件公开逆向社区里一直有人维护但我那次还是重新跑了一遍目的是拿到和当前样本版本完全对应的头文件。命令环节比较简单class-dump -H WeChat -o ./headers/跑完以后打开headers目录你会看到成百上千个.h文件。不要被数量吓到你需要的是带着上一章的目标画像去筛选文件名。搜Group、Contact、ChatLog、MessageService这些关键词很快就能缩小范围。2.3 Ghidra里搜字符串与交叉引用的实战技巧class-dump解决了“有哪些类和方法”的问题但解决不了“方法里干了什么”的问题。这个时候轮到Ghidra这样的反汇编工具出场。我用的流程是这样的把砸壳后的Mach-O拖进Ghidra创建项目分析等它跑完切换到字符串搜索窗口搜这个二进制里所有的中文字符串找到“所有人”或“所有人”之后右键查找引用看看到底是哪段代码引用了它从引用处继续追交叉引用一步一步回溯到入口。Ghidra的交叉引用功能比老牌工具直观很多尤其是XREF面板的颜色高亮和跳转对有多年逆向习惯的人来说很顺手。Hopper和IDA当然也能干同样的事但Ghidra有一点好——免费而且脚本生态强后面对大量同类型函数做批处理的时候会省力不少。为了让你有直观感受我顺手整理了个对比表工具主要用途上手难度收费情况备注class-dump提取OC类结构头文件低免费只对Objective-C有效Ghidra静态反汇编、反编译、脚本批处理中免费NSA出品跨平台Hopper静态反汇编、伪代码中收费macOS上体验很顺滑IDA Pro全平台反汇编王者高收费贵但插件生态无敌回到正题。我那次搜字符串搜出来的第一条引用指向了一个看起来平平无奇的函数里面有个判断逻辑大概长这样if (memberType 2 || memberType 3) { showAtAllOption(); }这里的memberType就是群成员角色类型的枚举2和3大概率对应群主和管理员。看到这行代码的时候我心里踏实了大半截——UI入口的组织形式已经被确认了而且是在客户端写死的不是服务端下发后才隐藏。这给后续hook提供了明确的切入点。3. 顺着UI线索定位“所有人”选项的藏身之处确认了“所有人”选项的可见性是本地判断之后下一步就是把这个UI入口背后一整条调用链走通。逆向最忌讳的是找到一个点就急着写hook你得先把这段代码放在整个App的运行轨道里看清楚。3.1 从聊天页“”入口到联系人选择器在微信聊天窗口里用户触发“”的方式有两种一种是在输入法里打“”符号另一种是点输入框旁边的“”号弹出菜单后选“”。无论哪种方式最后都会进入一个成员选择界面。这个界面对应的类名在头文件里搜ContactSelect、MemberPicker、AtSelect这几个关键词基本能锁定。实际的项目里微信把它叫做MMContactSelectViewController一类的东西负责展示群成员列表并支持多选。我当时的分析切入点是去头文件里找这个选择器的delegate方法接口里通常会有一个类似onSelectContact:的回调用来告诉上层“用户选了谁”。我们关注的是这个选择器的数据源是怎么来的以及“所有人”这一个选项是什么时候被插进去的。3.2 数据源里的特殊Cell与权限判断分支class-dump的头文件只能告诉你有哪些方法具体的数据组装还是要回到Ghidra里看反编译结果。我追踪了成员选择器数据源的实现发现它在返回cell的时候有一段比较隐蔽的逻辑if (isGroupOwner || isGroupAdmin) { [sections insertObject:所有人 atIndex:0]; }也就是说群主和管理员打开选择器时数据源里会多出一个“所有人”的条目普通成员则完全没有。这个insertObject操作的位置非常关键因为它不是通过服务端动态下发的而是纯客户端的本地判断。这里要特别说一句分析到这一步你看到的是“为什么群主能所有人而普通成员不能”的直接代码证据。但如果你的目的是“让普通成员也能看到这个入口”那现在hook点就很明确了——把这个判断条件的结果强制改成真或者直接在数据源插入对应条目即可。3.3 从UI可见到消息构造别忘了中间还有一步不过让选项显示出来只完成了三分之一。你还要搞清楚用户点下“所有人”之后这个选择结果是怎么被翻译成消息体里的mention标记的。头文件里通常会有一个AtMessage或MentionContext之类的模型里边至少包含两个字段一个是纯文本所有人另一个是给其他客户端解析用的特殊标记位。微信协议里单个用户时会带一个wxid所有人时则是一个固定的特殊值。这个特殊值不是“所有人”三个字的中文编码而是一个类似notifyAll、All的约定标记。要找到这段代码最直接的办法是去搜那些在拼接字符串时出现的常量。Ghidra里搜不到中文就是搜All、notifyAll这类英文常量命中率会高很多。4. 消息体里的标记找到mention的构造和发送入口UI层的hook只解决“能不能看到”真正决定“发出去的消息算不算数”的是消息体构造那一环。这一章我们来拆解消息从点击到落地的完整过程。4.1 消息体模型与标记的存放位置微信iOS端的消息发送链路已经演化过很多版本但核心的消息体模型一直都叫CMessage负责承载消息的类型、内容、发送状态等信息。与相关的字段通常在content区域或扩展字段里不是每个消息都有的。我在分析过程中比较关注的是当你选中“所有人”之后消息体里的content会发生什么变化。反编译结果大致是这样的组装逻辑if (selectedTarget AtAll) { [msgContent appendString:所有人 ]; [msgInfo setAtUserList:all]; }看到all这类固定占位符的时候事情就清楚了。所有人不是一个真的人名它就是一个特殊的标记位。消息发出去后其他成员的客户端读到这个标记位就会在聊天界面上把“所有人”高亮成提醒样式。这里的setAtUserList:就是一个值得设计的hook点。如果我在发送前的某个时机把这个值强行塞进消息体那从协议层看这条消息确实携带了合法的所有人标记。4.2 发送入口MessageService与sendMessage消息最终是走MessageService的sendMessage:方法发出去的。这个类负责把CMessage序列化成网络层需要的格式然后交给底层的socket或者长连接通道发出。顺着4.1的构造逻辑往下追我看到了大概这样的调用流用户点击发送按钮回调到chat logic层把输入框的内容和选择结果打包成CMessage调用MessageService sendMessage:上抛到网络层网络层做序列化、加密、提交。这一步最关键的是第2步到第3步之间。如果你想hook可以从两个方向入手方案Ahook消息构造方法在CMessage组装完成之后、上传网络之前修改content和atUserList字段方案BhooksendMessage:方法拿到SEL之后先改message对象再调原始实现。两种方案各有优劣。方案A更精准但要找到正确的构造方法名可能需要翻不少类方案B粗暴直接一个方法就能拦住所有消息缺点是任何消息都会走你的逻辑要小心别把所有消息都加上所有人的标记否则你就是活体刷屏器。4.3 服务端会不会二次校验这个问题我研究过很多次也跟人讨论过。坦率说纯客户端模拟出all标记在自己的测试小群里发着玩消息是能正常发出去的其他成员也能看到“有人了我”的提醒。但在大群或者敏感场景下微信服务端对所有人的频率和权限是有风控的客户端撸出来的消息体如果触发了频控策略照样会被拦截或者限流。所以这里划个重点别把这个能力当成绕过服务端的手段它就是一段在可控测试环境里观察协议行为的实验代码。真的想在业务上要所有人权限路径应该是产品层面和服务端配合而不是靠客户端hook硬刚风控。5. hook落地的技术选型与动态验证现在到了最核心的环节怎么把前面分析出来的结论落地成一个能跑通的hook。这块有两个层级的技术选型先讲原理再讲我的实践过程。5.1 Method Swizzling为什么比fishhook更合适iOS上的hook手段不少但要按使用场景分门别类Method Swizzling通过runtime交换两个方法的IMP是Objective-C世界最正统的hook方式适用于类和实例方法fishhookFacebook开源的Mach-O符号绑定重写工具适合hook C函数比如malloc、free之类Cydia Substrate / Substitute越狱环境下的系统级hook框架能拦任意进程里的OC方法甚至系统库调用。为什么我建议用Method Swizzling而不是一上来就上Substrate因为Substrate虽然强大但它的学习和调试成本更高而且经常会跟系统版本、越狱环境打架。你要hook的是微信自己的类不是系统底层函数Method Swizzling完全够用而且代码形式最直观。#import objc/runtime.h implementation XXXHook (void)hookIt { Class cls NSClassFromString(YourTargetClass); SEL originalSel NSSelectorFromString(originalMethod:); SEL swizzledSel selector(hooked_originalMethod:); Method originalMethod class_getInstanceMethod(cls, originalSel); Method swizzledMethod class_getInstanceMethod(cls, swizzledSel); method_exchangeImplementations(originalMethod, swizzledMethod); } - (void)hooked_originalMethod:(id)arg { [self hooked_originalMethod:arg]; // 调原实现别死循环 // 在这里修改你想要的数据 id message [arg valueForKey:msgContent]; if (message) { [message setValue:所有人 forKey:msgContent]; [message setValue:all forKey:atUserList]; } } end注意上面这段是原理演示的伪代码类名和方法名都被我改过了。真到实战里你要按前面章节定位到的具体类名和字段名替换进去。拿着伪代码去抄作业然后发现编译不过那才叫冤枉。5.2 加载时机为什么hook要在App启动早期执行hook代码写好了什么时候执行也是学问。微信的MPAAService、MicroMessengerAppDelegate这些初始化逻辑在启动早期就会跑你要hook的类可能在你还没来得及插入代码的时候就已经被创建并使用。最稳妥的做法是做一个load方法或者放在某个启动必调的delegate回调里执行保证在业务逻辑运行前完成方法交换。我用的是load dispatch_once 的组合 (void)load { static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ [self hookIt]; }); }load在类加载时调用时机比application:didFinishLaunching还要早基本能满足绝大多数hook场景的需求。5.3 lldb动态验证看调用栈、打印消息体hook写完怎么确认它真的生效了我的习惯是三步走断点验证在hook方法里加断点看发送消息时是否命中调用栈回溯用lldb的bt命令看当前方法是从哪里走进来的确认调用链和静态分析的一致对象内容打印在命中后执行po message直接看消息体内容是不是已经被改动。实际操作上我是先用lldb attach到微信进程然后在sendMessage:上下breakpoint等消息发送时自动断下来再用po打印参数。这套流程跑通之后基本能确认你hook的类名、方法名、字段名全都正确。注意lldb附加到目标进程后如果App杀掉了调试进程也会退出。要长时间调试最好配合auto-launch配置或者直接在越狱环境的启动参数里挂上调试器。我自己比较喜欢分段调试改一段代码验证一段毕竟一次改太多出了问题你根本不知道是哪个hook点造成的。5.4 常见翻车现场方法签名不匹配、缓存、线程问题我把踩过的坑整理一个表希望能帮你少走弯路问题原因解决办法交换方法后调用原实现死循环swizzled方法里又调了交换后的同名方法调用原SEL而不是调用自己hook不生效类名或方法名没找对、加载时机太晚用class-dump核对改到load里执行闪退方法签名不匹配、内存参数类型错严格用method_getReturnType和method_getArgumentType校验消息发送成功后界面显示异常改完内存对象后没有回到主线程刷新UIdispatch到main queue更新所有人发送后被拦截触发服务端风控或频控仅限测试小群低频验证不要拿大号硬造其中方法签名不匹配是新手最容易踩的雷。Method Swizzling交换的是IMP如果两个方法的返回值类型和参数类型不一致运行起来轻则消息内容错乱重则直接崩溃。别说你要hook微信了连自己写的类都可能翻车。所以hook之前一定要检查原型尤其是参数个数和类型。6. 复盘一下这次逆向教会我的不止是hook走到这一步整个流程算是闭环了。从脱壳到head数据提取再到Ghidra静态分析然后Method Swizzling动态修改最后lldb验证。放在几年前这套流程可能要折腾一整个通宵现在的工具链确实让门槛低了很多。但我也想给看到这里的朋友提个醒逆向分析能力本身是一把中性工具你会不会用它取决于你拿它干什么。它可以帮你理解一个成熟App的架构设计、帮你调试自己开发的App崩溃问题、帮你研究第三方SDK的调用逻辑也可以帮你发现协议层的安全漏洞并提交报告。这些才是它最体面的用法。如果非要我总结这次操作的收获我觉得在于掌握了“从UI到数据层、从静态到动态”的完整分析方法。以后再碰到一个陌生App不管要研究什么问题我都会先画一遍它的条理入口在哪、数据在哪、权限在哪然后按图索骥。这套方法论比单纯会hook几条方法值钱得多。最后再分享一个个人习惯我每分析完一个功能模块会顺手写一份结构笔记画一下类之间的调用关系和需要关注的字段。这不是给谁看的纯粹是为了下次能快速捡起来。逆向这种事忘得比想象中快得多好记性永远不如烂笔头。
返回列表