rea-reverse

macOS 应用怎么逆向:以 Rectangle 为例一步步拆解

基于 REA 6.7.0发布者 rea-reverse更新于 约 6 分钟读完
本页目录

逆向 Mac 应用,别急着开反汇编器,先拆 bundle。Info.plist、Mach-O 切片和加载命令、代码签名、编译后的 NIB,这几样读下来,应用怎么组装、哪个菜单项由哪个类处理,基本就清楚了。反编译器只在最后一步用得上,而且那时你已经知道该打开哪个函数。

下面每条命令都在 Rectangle 2.0.4 上真实跑过。这是一个 MIT 许可的窗口管理工具,样本取自 GitHub release 的 Rectangle2.0.4.zip(SHA-256 0b9b8b11…aedc9)。环境是 macOS 27.0.1、Xcode 27.0、Node 22.22.3 和 REA 6.7.0。REA 是第三方开源工具(npm install -g rea-agents),它把 Apple 命令行工具的结果整理成 JSON。整个过程没有启动过应用。请只分析许可允许的应用,比如开源应用或你自己的构建。

curl -LO https://github.com/rxhanson/Rectangle/releases/download/v2.0.4/Rectangle2.0.4.zip
ditto -x -k Rectangle2.0.4.zip .
APP="$PWD/Rectangle.app"     # REA 要求绝对路径

哪些命令必须在 Mac 上跑

REA 命令 依赖 非 macOS
inspect-artifact、project-apple-application-graph、trace-dylib-resolution、decode-interface-builder、inspect-keyed-archive 纯文件解析 可用(DMG 内容需要 macOS)
inspect-plist、list-architectures、inspect-macho、inspect-signature、demangle-swift 经 xcrun 调 plutil、lipo、otool、nm、dyld_info、codesign、swift-demangle 报 unsupported_host
inspect-native-dispatch-metadata 内置 Mach-O 字节读取器 只能经 Ghidra 拿到符号级结果
inspect-asset-catalog assetutil 不可用
search、decompile、trace-native-ui-action Ghidra、Hopper 或 IDA 取决于引擎

1. 清点文件,看清 bundle 结构

rea inspect-artifact "$APP" --json > inspection.json
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 -c '.normalized_result.bundles[] | {path, role}' apple-graph.json

清点大约 1 秒,共 261 个节点(274 次出现),每个都有 SHA-256。按 bundle 归类后是这样:

{"path":".","role":"application"}
{"path":"Contents/Frameworks/Sparkle.framework","role":"framework"}
{"path":"Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app","role":"nested-application"}
{"path":"Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc","role":"xpc-service"}
{"path":"Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc","role":"xpc-service"}
{"path":"Contents/Resources/MASShortcut_MASShortcut.bundle","role":"resource-bundle"}

也就是说,Rectangle 由一个 Swift 主程序、自带一个 app 和两个 XPC 服务的 Sparkle 更新框架,再加 MASShortcut 库的资源包组成。runtime_families 为 ["native"]。如果这里出现 Electron Framework.framework 和 app.asar,就该换 Electron 指南了。角色只是按路径惯例推断出来的,后面几步会拿实际文件逐一核对。

2. Info.plist

rea inspect-plist "$APP" --json | jq '.normalized_result.bundle'
plutil -p "$APP/Contents/Info.plist"     # 不用 REA 也能看
{"identifier":"com.knollsoft.Rectangle","executable":"Rectangle","name":"Rectangle","version":"111","short_version":"2.0.4"}

完整结果共 30 个键,值得看的有这几个:LSUIElement: true,说明它是菜单栏应用,不显示 Dock 图标;LSMinimumSystemVersion: "14.0";CFBundleURLTypes 里注册了 rectangle:// URL scheme;还有 NSMainStoryboardFile 和 Sparkle 的更新源 SUFeedURL: https://rectangleapp.com/downloads/updates.xml。嵌套 bundle 用 --relative-path 指定,读 Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc/Contents/Info.plist 得到的是 org.sparkle-project.InstallerLauncher,版本 2.10.0。

3. Mach-O 切片、构建信息和依赖

rea list-architectures "$APP" --json | jq -c '.normalized_result.architectures.items[] | {name, file_offset, size}'
{"name":"x86_64","file_offset":16384,"size":2255536}
{"name":"arm64","file_offset":2277376,"size":1965840}

这是一个通用二进制,总共 4,243,216 字节。不借助 REA 检查切片的方法见 通用二进制指南。inspect-macho 默认读 arm64 切片(selected_architecture: {"name":"arm64","container_file_offset":2277376})。结果里有 UUID 9B853EC6-717D-3C6C-893B-6B10D844116A,平台 MACOS,最低系统 14.0,SDK 27.0,依赖共 33 个。第一个是 @rpath/Sparkle.framework/Versions/B/Sparkle,之后是 Foundation、AppKit、SwiftUI、Combine、Carbon、ServiceManagement,以及 19 个 /usr/lib/swift/libswift*.dylib。

别把整份结果塞给 agent。我们用 REA 自带的 --token-count 量了一下:

rea inspect-macho "$APP" --token-count                    # 30232(默认 TOON 格式)
rea inspect-macho "$APP" --format json --token-count      # 53147
rea inspect-macho "$APP" --filter-output normalized_result.dependencies --token-count   # 1165

JSON 一共 173,662 字节,光 1,235 条 imports 就占了 143,470 字节。imports.total 返回 null,exhaustive 为 false,因为 dyld_info 可能漏掉 chained fixups 里的元数据,所以这份导入表只能当下限看。体积问题上游在 issue #2473 跟踪。需要哪部分,就用 --filter-output 或 jq 取哪部分。

4. 代码签名和 entitlements

rea inspect-signature "$APP" --json | jq '.normalized_result | {identifier, team_identifier, authorities, hardened_runtime, entitlements}'
codesign -dv "$APP"; codesign -d --entitlements - "$APP"
{
  "identifier": "com.knollsoft.Rectangle",
  "team_identifier": "XSYZ3E4B7D",
  "authorities": ["Developer ID Application: Ryan Hanson (XSYZ3E4B7D)", "Developer ID Certification Authority", "Apple Root CA"],
  "hardened_runtime": true,
  "entitlements": {}
}

codesign 的输出与此一致:flags=0x10000(runtime),时间戳 2026 年 10 月 21 日,封存资源 96 个文件,entitlements 是一个空的 [Dict]。这个空字典本身就说明了问题:Rectangle 没有沙盒,也没声明任何特殊权限。它移动窗口靠的辅助功能(Accessibility)权限,是用户在系统设置里手动授予的(TCC),不写在 entitlements 里,所以在签名里找不到。二进制的导入表可以印证:里面有 _AXIsProcessTrusted、_AXObserverCreate、_AXUIElementCopyAttributeValue。

5. dyld 怎么找到 Sparkle

rea trace-dylib-resolution "$APP" --json > dylibs.json
jq -c '[.normalized_result.edges[].resolution.status] | group_by(.) | map({(.[0]): length}) | add' dylibs.json
{"conditional":2,"undetermined":150}

REA 追踪了 10 个根节点(5 个可执行文件 × 2 个切片),共 152 条加载边,没有 findings。150 条 undetermined 是 bundle 之外的系统库,它们在 dyld 共享缓存里,不代表缺失。真正值得看的是那两条 conditional,分别是两个切片上主程序加载 Sparkle 的边:

install_name: @rpath/Sparkle.framework/Versions/B/Sparkle
  1. /usr/lib/swift/Sparkle.framework/Versions/B/Sparkle   rpath /usr/lib/swift                 outside-target
  2. Contents/MacOS/../Frameworks/Sparkle.framework/...     rpath @executable_path/../Frameworks resolved

otool -l 能看出原因:可执行文件里依次有两条 LC_RPATH,先是 /usr/lib/swift,然后是 @executable_path/../Frameworks。dyld 会先试第一个位置(通常不存在),再加载 bundle 里自带的那份。所谓 conditional,意思是如果前一个位置真有文件,就会抢先被加载。遇到 Library not loaded: @rpath/... 报错怎么修,见 macOS 动态库路径排查。

6. 从菜单项找到处理它的类

rea decode-interface-builder "$APP" --json > ib.json
jq -c '.normalized_result.documents[] | {relative_path, document_kind, object_count, connection_count}' ib.json

不到 1 秒就解出了 7 个归档:Main.storyboardc/MainMenu.nib(128 个对象、42 条连接)、LogViewer.storyboardc 的三个场景、MessageView.nib,以及 Sparkle 的两个 nib。整张图有 570 个节点、287 条边,其中 target-action 126 条,outlet 45 条。有两个文档的 connections 标为 partial,原因是 connection_unresolved,即某些连接找不到所属对象。REA 把它们列为遗漏,不去猜。

MainMenu 里大多数动作发给 first responder(copy:、undo:、performFindPanelAction:),只有 5 个明确指向对象 229:

ignoreFrontMostApp:  openPreferences:  showAbout:  viewLogging:  checkForUpdates:   -> object 229
object 229 class_name: _TtC9Rectangle11AppDelegate
rea demangle-swift "$APP" '_TtC9Rectangle11AppDelegate' '$s7SwiftUI4ViewMp' --json
_TtC9Rectangle11AppDelegate -> Rectangle.AppDelegate                    demangled
$s7SwiftUI4ViewMp           -> protocol descriptor for SwiftUI.View      demangled

target-action 只说明 Interface Builder 里连了线,不能证明二进制里真有这个方法,所以下一步要核实。

7. 类、方法和地址

rea inspect-native-dispatch-metadata "$APP" --json > dispatch.json
jq -r '.normalized_result.result.objc_classes[] | select(.name=="_TtC9Rectangle11AppDelegate" and .is_meta_class==false)
  | .methods[] | select(.selector|test("openPreferences|applicationDidFinishLaunching")) | "\(.selector) \(.address)"' dispatch.json

输出有 3.28 MB,覆盖 214 个 Objective-C 可见的类(其中 202 个属于 Rectangle 模块)、184 条 Swift conformance 和 534 个 Swift vtable 槽位。Rectangle.AppDelegate 有 36 个方法、23 个 ivar,实例大小 192。我们要找的两个 selector 都找到了:applicationDidFinishLaunching: 在 0x1000d0010,openPreferences: 在 0x1000d1754。coverage 是 partial:有几个 Swift ivar 的偏移没解析出来,记录数也达到了上限。泛型和 resilient 的 Swift 表一律标为 unsupported,不会去猜。

地址对不对,不用反编译器也能验证:

otool -arch arm64 -tV "$APP/Contents/MacOS/Rectangle" | grep -A16 '^00000001000d1754'
00000001000d1754	sub	sp, sp, #0x40
...
00000001000d1770	bl	0x100136ed8 ; symbol stub for: _swift_unknownObjectRetain
00000001000d1788	bl	0x100136110 ; symbol stub for: _$ss018_bridgeAnyObjectToB0yypyXlSgF
00000001000d1790	bl	0x100136ecc ; symbol stub for: _swift_unknownObjectRelease
00000001000d1794	bl	0x1000d16d8

这是典型的 @objc thunk:先 retain sender,把它桥接成 Swift 的 Any,再调用位于 0x1000d16d8 的真正 Swift 函数体。要放进反编译器看的,是后面这个地址。

8. keyed archive 和资源目录

Rectangle 里没有 NSKeyedArchiver 文件。它的 .nib 是编译后的 NIBArchive 格式,REA 会直接说明,而不会硬解:

rea inspect-keyed-archive "$APP" Contents/Resources/MessageView.nib --json
unsupported_target: Selected file is a compiled NIBArchive, not a Foundation plist archive; decode it with decode_interface_builder

为了演示真正的 keyed archive,我们用 Swift 的 NSKeyedArchiver 归档了一个小 NSMutableDictionary,得到 413 字节。里面有一个 name;一个 actions 数组,引用了同一个字符串两次;还有一个指回字典自身的 peer:

rea inspect-keyed-archive "$PWD/demo.archive" --json | jq -c '.normalized_result.objects[] | {id, kind, class_name, value}'
{"id":1,"kind":"archived-object","class_name":"NSMutableDictionary","value":{"NS.keys":[{"UID":2},{"UID":3},{"UID":4}],"NS.objects":[{"UID":5},{"UID":6},{"UID":1}],"$class":{"UID":9}}}
{"id":6,"kind":"archived-object","class_name":"NSArray","value":{"NS.objects":[{"UID":7},{"UID":7}],"$class":{"UID":8}}}
{"id":7,"kind":"scalar","class_name":null,"value":"tile-left"}

一共 10 个对象、11 条引用。UID 是 objects 数组的下标。对象 1 的第三个值是 UID 1,也就是它自己,所以直接递归展开会陷入死循环,要记录已经访问过的 ID。分页时保留原始下标:--offset 6 --limit 2 返回 ID 6 和 7,next_offset: 8;最后一页(--offset 8 --limit 2)的 next_offset 是 null,truncated 却仍为 true。所以判断还有没有下一页要看 next_offset,不能看 truncated。UID 0 对应 $null,REA 也注明了:编码成 UID 0 的条件引用和普通 nil 无法区分。

rea inspect-asset-catalog "$APP" --limit 200 --json 通过 assetutil 读取 Assets.car,共 154 条记录,大部分是 AppIcon_Assets 的各个 rendition,还记录了生成工具(Xcode 27.0 (27A266a))。它只读元数据,不导出图片。

9. 反编译处理函数

函数级分析需要引擎。我们这台机器上一个都没配,REA 直接拒绝执行,不会瞎猜:

rea search "$APP" applicationDidFinishLaunching --json
provider_unavailable … ghidra: not_configured — "Set GHIDRA_INSTALL_DIR to an extracted Ghidra 12.1.x release directory."

trace-native-ui-action "$APP" 'openPreferences:' 也报了同样的错。装好 Ghidra 12.1.x 后(见 Ghidra 指南),就去反编译第 7 步找到的 Swift 函数体 0x1000d16d8。-[...] 这类 Objective-C 名字要写成 --procedure= 的形式,否则开头的 - 会被当成命令行参数。trace-native-ui-action 正好把第 6、7 步连起来:从一个 IB 动作跟到它的处理函数,再跟到这个函数静态调用的函数,但它不会断言这条路径真的被执行过。如果要观察真实运行(observe-native-calls,借助 LLDB),就得启动应用;像 Rectangle 这样开了 hardened runtime 的构建,还需要 get-task-allow,所以只在你自己的构建上做。