十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LLM引导macOS私有框架逆向:从静态证据到类型推理的实战方法论

LLM引导macOS私有框架逆向:从静态证据到类型推理的实战方法论 做 macOS 逆向的人应该都有过这种体验对着一个陌生私有框架的二进制翻来覆去找方法原型最后发现参数类型全是id返回值是id连 block 长什么样都看不出来。传统做法是 class-dump 拉一遍头文件再进反汇编器里人肉追踪调用点凭经验猜。这几年我开始尝试把大语言模型LLM引入到类型推理这个环节让模型基于已经抽出来的静态特征去推断类型。实际跑过几个私有框架之后我发现这条路是走得通的而且能节省大量时间。这篇文章就把我自己的思路、工具组合、提示词模板和踩过的坑完整记录下来给想用 LLM 加速逆向分析的朋友当个参照。1. 为什么私有框架逆向需要 LLM 引导的类型推理1.1 私有框架逆向的天然难点私有框架和公开 SDK 最大的区别在于没有头文件。苹果在公开 framework 提供.h和文档但私有框架只有一个 Mach-O 二进制加一堆内部符号类型信息早被编译器抹掉了。Objective-C 的运行时虽然保留了 selector 和 ivar 的名字但方法签名里的对象类型几乎全部退化成id。编译器在做消息发送时也不做类型检查objc_msgSend统一走指针传递这就导致二进制里根本不存在完整的类型图。更麻烦的是很多私有类里的属性也会被 clang 优化成内部的_ivar通过 KVC 或者带set/get前缀的方法暴露。class-dump 能导出一份很“薄”的接口但返回给我们的经常是满屏的id和空荡荡的Properties。想搞清楚一个方法到底是传NSString还是NSNumber你得回到反汇编看它怎么被使用再结合周围的字符串和调用点去猜。这一步非常依赖经验也是整个逆向链条里最耗时的环节。1.2 类型推理在整个逆向流程中的位置类型推理不是最终目标它是连接“看懂二进制”和“动态操控”的桥梁。拿到一个私有 API只有知道参数的真实类型你才能正确构造调用才能在 Hook 层面对参数做解析才能在写注入逻辑时往容器里塞对类型的对象。举个例子我曾经分析过一个私有缓存框架某个方法接受一个id key从名字完全看不出是字符串还是数字。我顺着调用点翻上游代码发现这个 key 被当成NSMutableDictionary的 key 使用而调用方传入的是一个NSUUID字符串。如果没有这个推理后面写测试脚本一定会出错。类型错误还会带来连锁反应。一旦你把某个参数臆断成NSArray而实际它是一个 block动态调用时会直接 crash。所以在决定调用私有接口前类型推理的结果必须先和现场证据对齐再进入下一步。1.3 LLM 为什么适合做这件事传统静态分析工具比如 Ghidra 的 Decompiler能给你数据流和控制流但它不懂语义。一个rax存了objc_msgSend的返回值它只知道这是一个指针不会告诉你这是指向_TtC12AppModule14CacheManager的实例。而 LLM 在训练过程中看过大量 Objective-C 和 Swift 代码它熟悉苹果框架的命名模式也清楚NSCache、NSMapTable、NSDictionary这类类型在方法名中常见的使用方式。你给它几个线索它能在语义层面给出合理的候选类型而不是机械地做指针传播。我把这个过程看作“让一个懂 Cocoa 的老开发坐在旁边看反汇编”。他看不见类型符号但能看到方法名叫objectForKey:看到字符串常量看到调用方把结果传给另一个方法然后他会说“这八成是个可拷贝的对象可能是一个 NSData 的封装。”LLM 的作用就是这个“老开发”只不过它读上下文的速度比人快得多能同时处理十几个分散线索。2. 方案选型与实践思路拆解2.1 三条路线对比在决定用 LLM 之前我试过其他几条路各有取舍。简单整理一下路线原理优点缺点传统静态类型传播基于数据流和 vtable/objc 元数据做抽象解释推导寄存器/变量类型结果可解释没有幻觉对id和 ObjC 动态派发基本失效规则写吐血模式匹配 特征库用正则、符号名、字符串常量、已知 API 签名做匹配快精确覆盖率低苹果常用缩写和混淆命名会直接打脸LLM 辅助推理把静态证据变成 prompt让模型给出候选类型和置信度能处理模糊推理语义理解强适合探索有幻觉风险需要校验和成本控制实际操作下来LLM 不是替代前两者而是叠加在前两者之上。先用传统方法把能确定的类型都定掉把不确定的id、void *、block 交给 LLM最后再用静态规则验证结果。这样把容错圈在可控范围内。2.2 核心策略静态证据为主LLM 为推理引擎我在这个项目里反复强调“LLM 引导”不是让 LLM 直接读整个 Mach-O 文件。二进制太大了直接塞给模型既不经济也容易淹没关键信息。正确做法是先由自动化脚本做静态特征抽取把目标方法的上下文抽取成结构化的“证据包”再让 LLM 在这个证据包基础上做类型推理。整个工作流分三个阶段证据抽取提取目标类、方法名、属性名、调用点寄存器操作、字符串常量、局部变量存储模式。LLM 推理把证据包传给模型让其输出一个带候选类型列表和置信度的 JSON。校验反馈用编译验证、调用一致性检查、人工抽查来过滤错误结果有冲突则把冲突信息回灌给 LLM 要求重新推理。这种“证据包 推理模型”的方式比单纯让 LLM 看字符串要可靠得多。因为每一步都有迹可循哪怕模型推错了我们也知道是哪个证据误导了它可以修正后重跑。2.3 提示词模板设计提示词是整套方法的核心。一开始我以为让模型“分析这段反汇编”就够了结果输出的内容天马行空。后来我把 prompt 重构成几个固定区域角色设定你是一名有 20 年经验的 macOS 逆向工程师擅长从二进制证据推断 Objective-C 类型。目标对象类名、方法名、属性名、协议名。已知证据可读的字符串列表、objc_msgSend 调用序列、寄存器操作、局部变量存储、调用方上下文。候选类型集合从 SDK、依赖库、其他头文件提取的类型名称要求只能从中选择。输出格式严格 JSON schema包含type、confidence、evidence_keys、reasoning。每次推理前候选类型集合都会根据当前类的继承关系和 importing 的 framework 动态生成。这样模型不会发明出一个不存在的MyPrivateFooClass。我给一个简化版 prompt 例子系统你是一名 macOS 逆向工程师。请基于提供证据推断参数/返回类型。只允许从候选类型中选择。不确定时返回 unknown。 用户 目标方法-[_TtC8Finance11TransactionManager cacheResultForKey:] 已知证据 - 方法内部调用 objc_msgSend(obj, objectForKey:, var_10) - var_10 是传入的 id字符串检查 transaction_cache_key_prefix - 返回值被放入 NSMutableArray 的方法 addObject: - 类实现中有 ivar _cache: NSMutableDictionary 候选类型NSString, NSData, NSNumber, NSArrayTransaction *, Transaction, NSError, unknown 请输出 JSON格式 {type: ..., confidence: 0-1, evidence_keys: [...], reasoning: ...}这个模板看起来很朴素但实测非常有效。约束输出格式后后续脚本可以自动解析省掉大量手工整理。3. 实操过程从 Mach-O 到类型推断结果3.1 环境准备与工具链我的工作机是一台 macOSXcode Command Line Tools 是基础还需要 class-dump、Ghidra或者 Hopper、Python 3以及一个能调用的 LLM 服务。API 我用过 OpenAI 和本地 Ollama 两种如果只做实验本地跑一个 14B 模型也行效果差一点但成本低。安装基础工具大概是这几条命令# 安装 class-dump 通常用 brew 或者直接下载二进制 brew install class-dump # Ghidra 用官方发布版 brew install --cask ghidra # 以及一个用来做 demangle 的 Xcode 自带工具我不建议在分析目标设备上装任何东西所有操作都在独立研究环境里做。另外每一步都记录好目标 framework 的版本和哈希方便后续回归比对。3.2 第一步提取 Objective-C 运行时元数据先找到目标私有 framework 的二进制路径常见位置在./System/Library/PrivateFrameworks/或者某个.app包里的Frameworks目录。用 class-dump 导出 Objective-C 接口class-dump -A /path/to/PrivateFramework.framework/PrivateFramework dump.h如果遇到加密或瘦身问题可以先用lipo -thin arm64提取当前架构再用otool -ov看 Objective-C 类信息。class-dump 导出的头文件里往往充满了id类型正好是我们的切入点。我建议先把所有包含id签名的方法提取到一个 CSV方便后续批量推理。3.3 第二步从反汇编和调用点收集证据类型推理不能只看头文件。对一个目标方法我用 Ghidra 打开二进制定位到方法实现把以下信息整理进一个证据文件方法参数寄存器分配ARM64 下x0是 selfx1是 selectorx2开始是真正参数。参数被如何使用是直接传给objc_msgSend还是被strlen/objc_retain处理还是被写入某个容器。字符串常量方法体内引用到的cString、NSString字面量比如com.example.cache、key。局部变量存储模式通过栈地址或者寄存器保存下来的对象最终是否被objc_storeStrong存储。调用点上下文谁调用了这个方法调用方在调用之前从哪个方法拿到了参数。举个例子在分析一个私有缓存框架时我提取到目标方法cacheResultForKey:的内部证据方法体引用了 class NSMutableDictionary 调用了 objc_msgSend(x20, setObject:forKey:) x20 是一个从 self-_cacheDict 加载的实例 参数 x2 被转换为 NSString 后作为 setObject:forKey: 的 key这些证据虽然不会直接写出类型但能告诉 LLM传入的参数会被当作字典 key 使用多半是NSString。同时setObject:forKey:的 value 参数从返回值来于是返回值的类型也被约束了。3.4 第三步调用 LLM 进行类型推理这一步我会写一个 Python 脚本把上一步整理好的证据包拼进 prompt然后向 LLM 发起请求。脚本核心逻辑大致是import json from openai import OpenAI client OpenAI(api_key..., base_url...) def infer_type(target, evidence, candidates): prompt f 目标: {target} 证据: {json.dumps(evidence, ensure_asciiFalse)} 候选类型: {, .join(candidates)} 请输出 JSON: {type: ..., confidence: 0.0-1.0, evidence_keys: [...], reasoning: ...} resp client.chat.completions.create( modelgpt-4o, # 或本地模型 messages[ {role: system, content: 你是 macOS 逆向专家只能从候选类型选择不确定输出 unknown。}, {role: user, content: prompt} ], temperature0.2, response_format{type: json_object} ) return json.loads(resp.choices[0].message.content) # 示例调用 evidence { method: -[_TtC8Finance11TransactionManager cacheResultForKey:], calls: [ objc_msgSend(x20, \setObject:forKey:\), objc_msgSend(x21, \count\) ], strings: [transaction_cache_key_prefix], ivar: [_cacheDict: NSMutableDictionary] } candidates [NSString, NSData, NSNumber, NSArrayTransaction *, Transaction, NSError, unknown] print(infer_type(cacheResultForKey:, evidence, candidates))一段合理的输出可能是{ type: NSString, confidence: 0.82, evidence_keys: [strings[0], calls[0]], reasoning: 参数被用作 dict key且字符串前缀提示是 key综合判断为 NSString。 }注意这不是一次就完事。同一个方法我会跑三到四次每次稍微调整候选类型集合或补充证据然后看置信度是否稳定。稳定的结果才进入下一轮。3.5 第四步校验与合并结果LLM 给出的类型不能直接信。我会做两层校验。第一层是自动一致性检查如果模型说返回类型是NSString就回到反汇编确认返回值是否被当成NSString调用比如调用了length、characterAtIndex:。如果调用的是length说明确实是字符串置信度上升如果调用了count则更可能是NSArray或字典。第二层是用 clang 做一个假的头文件编译测试把推断出的类型写进去写一段符合该类型使用模式的 Objective-C 代码让它编译。如果编译通过且产物逻辑一致说明类型至少没违反编译规则。合并这一步我会把多个方法的推理结果汇总成一个类型映射 JSON再用脚本自动生成一份补全后的头文件。这个头文件可以直接喂给 Ghidra 或者用于 Dynamic Library 注入测试。整个过程跑下来一个中等规模的私有框架大概有一半以上的id类型能被自动推理成具体对象剩下的再靠人工排查。4. 常见问题与排查技巧实录4.1 LLM 输出“幻觉类型”最典型的问题就是模型一本正经地给出一个并不存在的类名或者把某个id推成过于具体的子类。比如一个明显是NSData返回值模型却根据方法名里的ZIP把它猜成ZipArchive。原因通常是候选类型里没有NSData或者上下文里字符串干扰太强。我的应对办法把候选类型列表收窄到当前 framework 和系统库中真实存在的类利用nm -gU从依赖库中动态生成候选。在 system prompt 里强调“只允许从候选类型中选择宁可 unknown不要发明”。调低 temperature强制 JSON 输出。在结果里要求模型输出evidence_keys这样如果结果错误可以回溯到是哪个证据导致的。4.2 证据不足怎么办有些方法体极短只有一行return objc_msgSend(...)除了 selector 和字符串什么都没有。这种场景我会先去看它的调用方。调用方如果是公开方法拿到调用方上下文后补充参数来源和返回去向再一起喂给 LLM。还可以利用二进制里的协议信息。如果一个类声明了NSCopying、NSSecureCoding协议那么它的方法大概率要处理NSString或编码后对象。另外私有框架里经常出现同一个 key 在多个方法中使用比如identifier既出现在objectForKey:的地方也出现在initWithIdentifier:的参数上把它串起来就能获得强约束。4.3 Swift 和 Objective-C 混编框架的特殊处理现在很多私有框架其实是 Swift 写的但对外暴露成 ObjC 对象。这类二进制的特点是带大量 mangled symbol类名可能是_TtC开头。你需要先 demangle 它再喂给 LLM比如用xcrun swift-demangle拿到可读名称。Swift 的泛型和Any在运行时几乎不保留推断难度更高。我建议优先推理那些通过objc暴露给 ObjC runtime 的方法内部 Swift 函数可以先跳过因为 LLM 对 mangled symbol 的语义理解更弱。4.4 工程化落地成本、版本与回归批量推理时 API 成本容易失控。一个 framework 可能有一千多个方法每个方法跑四次光 token 消耗就很可观。我现在的做法是先用一个本地小模型跑全量初筛只把置信度低于 0.5 或者 unknown 的方法交给云端大模型复推。这样成本能降低一半以上。结果统一保存在 JSON 文件里做成一个简单的类型数据库framework 更新后只需要跑一遍 diff对变更的方法重新推理不需要全量重算。最后分享一个体会LLM 引导类型推理真正厉害的地方不是给你一个“正确答案”而是它能高质量地提出“候选方向”。二进制逆向本质上是一场概率游戏谁能更快缩小范围谁就赢。把静态证据和 LLM 的语义能力组合起来等于在拼图时多了几十个帮手。但帮手终究只是帮手最后的验证永远要自己来。现在每次拿到一个新私有框架我都会先把这套流水线跑一遍再去看那些 LLM 没把握的角落效率比过去人肉翻汇编高太多了。
返回列表