rea-reverse

macOS 应用逆向:用 REA 分析 Mach-O、Swift 符号和 NIB 界面

基于 REA 6.4.0更新于 约 9 分钟读完
本页目录

一个 macOS 应用其实是一棵目录树:一个或多个 Mach-O 可执行文件、嵌套的 framework 和 helper、各种 plist、编译后的 Interface Builder 文件,还有资源目录(asset catalog)。打开反汇编器之前,你已经能弄清楚很多事:应用是怎么组装起来的、链接了哪些库、声明了哪些 entitlement、界面怎么连到代码上。REA(morluto 维护的开源项目)把这些层逐一读成 Evidence,再把具体的函数交给 Ghidra 或 Hopper 深入分析。

适合谁读: 排查自家构建的 macOS 开发者、想了解开源 Mac 应用结构的人,以及需要一套可复现静态流程的安全学习者。需要熟悉终端,见过 .app 包的内部结构。

你会学到:

  1. 清点一个 .app 或 .dmg,并投影出它的 bundle 结构
  2. 读取 Info.plist、Mach-O 头、架构和代码签名元数据
  3. 追踪 dyld 如何解析每个 @rpath 加载
  4. 还原(demangle)Swift 符号,读取 Objective-C 和 Swift 的派发元数据
  5. 解码编译后的 NIB 和 storyboard、keyed archive 和资源目录
  6. 在 Ghidra 或 Hopper 里从一个界面动作找到它的处理函数

遵守许可协议。 只分析开源应用、你自己编译的应用,或许可协议允许分析的应用。很多商业软件的 EULA 限制逆向工程,动手前先读 EULA,并在它允许的范围内操作。本文只读取元数据和代码,不涉及修改、重签名、打补丁、去除保护或绕过授权,所有步骤都是只读分析。涉及运行时的步骤会以你的用户权限启动目标,只在你有权使用的应用和机器上做。参见 REA 的 DISCLAIMER.md。

示例目标: Rectangle,一个用 Swift 编写、MIT 许可的窗口管理工具,从它的 GitHub 页面下载发布版即可。换成任何开源 Mac 应用或你自己用 Xcode 编译的项目,流程都一样。

哪些工具在哪里能跑

REA 在 macOS 上有一部分工具调用 Apple 的命令行工具,另一部分用 TypeScript 自己解析文件,任何主机都能跑。

工具 依赖 能否在非 macOS 上运行
inspect-artifact、extract-artifact、project-apple-application-graph 无额外依赖(DMG 内部清单需要 macOS) 可以,DMG 内容除外
trace-dylib-resolution 无额外依赖,纯头部解析 可以
decode-interface-builder、inspect-keyed-archive 无额外依赖,静态解析归档 可以
inspect-macho、list-architectures、inspect-signature、inspect-plist、demangle-swift 通过 xcrun 查找并调用 Xcode 命令行工具(lipo、otool、nm、dyld_info、vtool、codesign、plutil、swift-demangle) 不行,报 unsupported_host
inspect-native-dispatch-metadata 需要 macOS 上的 Mach-O 字节读取器(不调用外部工具) 只能通过 Ghidra 等引擎拿到基于符号名的元数据
inspect-asset-catalog macOS 的 assetutil 不行
decompile、search、trace-native-ui-action 等函数级分析 Ghidra 或 Hopper(或 IDA) 取决于所用引擎

前置条件

  1. REA 6.4.0 或更新版本。 npm install --global rea-agents,或在命令前加 npx -y rea-agents@latest。在 Claude Code / Cursor / Codex 里使用,运行 npx rea-agents setup(见 安装与接入)。
  2. macOS 上的 Xcode 命令行工具: xcode-select --install。
  3. Ghidra 或 Hopper,第 9、10 步要用。setup 能检测已有的 Ghidra,也可以在你同意后安装 Hopper(独立的商业软件,有免费演示模式)。用 rea doctor --provider ghidra --json(或 hopper)检查某个引擎是否就绪。
  4. jq。这里有几个结果会很大。

先把应用复制到工作目录,比如 ~/re-work/Rectangle.app,这样 /Applications 里的那份更新了也不会影响你的分析。下面所有路径都要用绝对路径。

第 1 步:清点 bundle

rea inspect-artifact ~/re-work/Rectangle.app --json > inspection.json

为什么先做这一步: inspect-artifact 返回按内容寻址的完整清单:每个路径、大小、SHA-256、识别出的格式,外加完整性观测和建议的下一步探查。也可以直接传下载来的 .dmg。在 macOS 上,REA 会以只读方式挂载它、清点卷内容,完成后清理挂载。需要把文件落到磁盘上时,用 rea extract-artifact,REA 会自己选一个新的临时目录并告诉你位置。

第 2 步:投影出 bundle 结构

清单是平铺的列表,project-apple-application-graph 会把它整理成 Apple 应用特有的结构。把 inspect-artifact 结果里嵌套的清单 Evidence 传给它:

jq '{inventory_evidence: [.normalized_result.substeps[].evidence]}' \
  inspection.json > apple-input.json
rea project-apple-application-graph ./apple-input.json --json > apple-graph.json

jq '.normalized_result.bundles[] | {path, role, layout, info_plist_path, executable_candidates}' apple-graph.json
jq '.normalized_result.runtime_families' apple-graph.json

你会得到:

  • bundle 及其角色,按路径约定判断:application、app-extension、xpc-service、framework、login-item、helper-application、plug-in、resource-bundle 等,每个都带有 info_plist_path 和可执行文件候选。
  • 组件: Contents/Library/LaunchServices/ 下的特权 helper、LaunchAgents/ 和 LaunchDaemons/ 下的 launchd plist、签名文件和符号链接。
  • 运行时类型: 根据完整的 framework 路径段给出 react-native、flutter、unity 等提示。如果看到 Electron Framework.framework 和 app.asar,真正值得看的代码是 JavaScript,请转到 Electron 那篇。

这些都是基于路径的推断:投影本身不读 plist,也不能证明运行时真的加载了什么。后面几步就是用来核实的。

第 3 步:读取 Info.plist 和其他 plist

rea inspect-plist ~/re-work/Rectangle.app --json
rea inspect-plist ~/re-work/Rectangle.app \
  --relative-path Contents/Library/LaunchAgents/EXAMPLE.plist --json

不加 --relative-path 时,REA 读取 bundle 的 Info.plist,给出标识符、可执行文件名、名称和版本,同时返回完整的解码内容:URL scheme、各种权限用途说明、LSUIElement 等。要读 launchd 或 helper 的 plist,就用第 2 步列出的路径(第二条命令里是占位符)。底层调用的是 plutil,所以需要 macOS。

第 4 步:Mach-O 头、架构切片和依赖

rea list-architectures ~/re-work/Rectangle.app --json
rea inspect-macho ~/re-work/Rectangle.app --json > macho.json

传 .app 路径时会自动定位到主可执行文件。也可以直接传 Contents/MacOS/<name>,或第 2 步列出的某个 framework 二进制。

inspect-macho 返回文件类型、标志位、UUID、入口点、架构、构建元数据(平台和 SDK)、load command、依赖、导入、导出和段,每个列表都带 items、total 和 exhaustive 标志。一定要存成文件再切片看。 真实应用的结果可能非常大,issue #2473(目前仍未关闭)实测过:在普通 macOS 应用上,一次 inspect_macho 调用大约有 8 万到 23 万个估算 token,原因是所有导入和导出都内联返回。这远超常见 MCP 客户端的结果上限,不要直接塞进 agent 的上下文。比如:

jq '.normalized_result.dependencies.items' macho.json
jq '.normalized_result.build_metadata' macho.json
jq '.normalized_result.imports.total, .normalized_result.exports.total' macho.json

对于通用二进制(universal binary),architectures.items 会列出每个切片及其在容器文件里的偏移和大小。6.4.0 的结果里没有字段标明 REA 实际读的是哪个切片;仓库文档里提到的 selected_architecture 字段只存在于 main 分支,npm 6.4.0 发布包里没有。

第 5 步:代码签名元数据

rea inspect-signature ~/re-work/Rectangle.app --json

这一步用 codesign 读取现有签名:标识符、团队 ID、CDHash、签发链、指定需求(designated requirement)、时间戳、是否启用 hardened runtime,以及声明的 entitlement。entitlement 往往是了解“这个应用被允许做什么”最快的途径:沙盒、网络、Apple Events、摄像头等等。REA 只报告签名信息,从不移除或替换签名。

第 6 步:追踪 dylib 解析

rea trace-dylib-resolution ~/re-work/Rectangle.app --json > dylibs.json
rea trace-dylib-resolution ~/re-work/Rectangle.app \
  --root Contents/MacOS/Rectangle --architecture arm64 --json

REA 对 bundle 里的每个可执行文件按 dyld 的规则模拟解析:按“依赖优先”的顺序,沿 LC_RPATH 栈展开 @executable_path、@loader_path 和每一个 @rpath。每个候选路径都有结果,每次加载被判定为 resolved、conditional(bundle 外更早的候选,比如 /usr/lib/swift,如果存在就会优先)、unresolved 或 undetermined。派生发现会标出无法解析的必需加载,以及缺失的更早 @rpath 候选。系统库保持 outside-target,因为 macOS 11 起它们在 dyld shared cache 里。整个过程不执行任何东西,在 Linux 上也能跑。上面的 root 路径假设可执行文件名是 Rectangle,实际名称以第 3 步读出的为准。

第 7 步:Swift 符号和派发元数据

Swift 符号是被 mangle 过的。把想看懂的符号传进去,比如第 4 步 imports 或 exports 里的:

rea demangle-swift ~/re-work/Rectangle.app '$s4main3FooV' --json

第一个参数把 Evidence 绑定到这个应用,后面每个参数是一个符号。REA 自己的测试用例正是用这个符号,期望 $s4main3FooV 还原为 main.Foo。不是 mangle 形式的符号会原样返回,状态为 unchanged。

想看名字背后的结构:

rea inspect-native-dispatch-metadata ~/re-work/Rectangle.app --json > dispatch.json

在 macOS 上它用一个经过校验的 Mach-O 字节读取器(在其他主机上,macOS 原生 provider 不可用,Ghidra 等引擎只能给出基于符号名的元数据),解码 Objective-C 的类、元类、父类、方法、实例变量、属性、category 和 protocol,以及简单的 Swift 协议一致性(conformance)、witness 槽位和非泛型、非 resilient 的类 vtable。每条记录都带精确的地址和文件偏移。泛型、resilient 和 async 的表会标为 unsupported 或 partial,不会去猜。

第 8 步:NIB、storyboard、归档和资源

rea decode-interface-builder ~/re-work/Rectangle.app --json > ib.json

它静态解析编译后的 .nib 文件和 storyboard 场景,生成一张对象、层级和连接图,包括 outlet、action、控制器名和类名。documents 列出每个归档及其对象数和连接数。识别不了的类、找不到的处理函数都会明确标为未知。默认上限是 64 个文档、20000 个对象、40000 条连接(--max-documents、--max-objects、--max-connections)。

其他和界面相关的资源:

rea inspect-keyed-archive ~/re-work/Rectangle.app Contents/Resources/SOME_ARCHIVE.plist --json
rea inspect-asset-catalog ~/re-work/Rectangle.app --limit 200 --json

inspect-keyed-archive 按原始对象表读取任意 NSKeyedArchiver plist,不会实例化任何类,共享引用和循环引用都会保留。用 --root 选根对象,用 --offset、--limit 分页。上面的归档路径是占位符,请换成清单里真实存在的文件。inspect-asset-catalog 通过 assetutil 读取 Assets.car 的元数据,不导出图片字节。

第 9 步:用 Ghidra 或 Hopper 反编译

现在用前面得到的信息挑函数:

rea search ~/re-work/Rectangle.app "applicationDidFinishLaunching" --provider ghidra --json
rea decompile ~/re-work/Rectangle.app \
  '--procedure=-[AppDelegate applicationDidFinishLaunching:]' --provider ghidra --json

Objective-C 方法名以 - 或 + 开头,会被当成命令行参数,所以要用 --procedure= 的写法。上面的 selector 只是示意,请从搜索结果或派发元数据里复制精确名称(包括命名空间)。Swift 方法以 mangle 后的名字或引擎还原后的名字出现。通过 MCP 时,agent 可以对已打开的二进制调用 get_objc_classes 和 analyze_swift_types,基于引擎的名称表得到类和类型清单。这两个工具在 6.4.0 里只能通过 MCP 调用:发布包把它们列在 MCP_TOOLS_WITHOUT_DEDICATED_CLI 里,没有 rea get-objc-classes 这样的命令。完整的 Ghidra 流程见 /zh/rea-ghidra-native-binary/。

第 10 步:从按钮找到处理函数

trace-native-ui-action 把第 8 步和第 9 步接了起来。给它一个来自 NIB 图的 action selector 或对象 ID,它会顺着界面里配置的连接找到 Objective-C 处理函数,再沿静态调用往下走:

rea trace-native-ui-action ~/re-work/Rectangle.app 'ACTION_SELECTOR:' \
  --provider ghidra --max-depth 4 --json

selector 必须唯一。有类型的直接调用、已解析的间接调用、有歧义的候选和没有目标的调用点会分开列出,结果也不会声称这条路径一定会执行。要确认某次运行里实际发生了什么,可以用 observe-native-calls 在 LLDB 下按显式断点启动应用。这属于运行时操作:应用会真正运行,可能修改文件或访问网络,需要事先取得授权。启用了 hardened runtime 的目标需要 get-task-allow entitlement,所以实际上只适合用在你自己编译的应用上。详见 native investigation。

让 agent 来驱动

用 REA 分析 ~/re-work/Rectangle.app:先清点,投影 Apple 应用图,
读取 Info.plist 和签名里的 entitlement,再追踪主可执行文件的 dylib 解析。
总结 bundle 结构和所有无法解析的加载。不要粘贴 inspect_macho 的原始结果,
只挑需要的字段。每个结论附 Evidence ID,并列出 unknown。

关于 inspect_macho 的那句提醒很重要:不加的话,agent 可能会尝试读完整份记录,把上下文撑爆(issue #2473)。

常见问题与排错

inspect-macho、inspect-plist、demangle-swift 报 unsupported_host。 这些工具需要 macOS。在 Linux 上改用 trace-dylib-resolution、decode-interface-builder、inspect-keyed-archive 和 Ghidra。

提示 “is unavailable through xcrun”。 用 xcode-select --install 安装或修复命令行工具,然后运行 rea doctor --json。

Active artifact is not Mach-O。 你传入的是脚本、plist 或其他非 Mach-O 资源。请传 .app,或 executable_candidates 里的文件。

DMG 只有根哈希。 读取 DMG 内部内容需要 macOS 上的只读挂载。在其他主机上,先在 Mac 上把应用取出来,或者直接分析 .app。

Interface Builder 结果是 partial。 所有归档加起来超出了 32 MiB 的总输入预算或 256 MiB 的总解码预算。前面已经解码的文档仍然可用,被跳过的路径会列出来。

Hopper 弹出了窗口。 在 macOS 上 REA 驱动的是 Hopper 应用本身,首次运行可能弹出对话框。需要完全无界面的分析时,请用 Ghidra。

下一步