起点
某天的现实问题:手头有一个 macOS Universal Mach-O App,需要在没有源码、没有开发者证书、没有服务端访问权限的前提下把它跑起来。这种场景在真实攻防里其实并不少见 — 你拿到一个二进制,想理解它的内部决策路径,并在不改语义的前提下让某个分支按你想的方向走。
下面这篇不讲被改的是什么 App,只讲方法。所有 trick 都能直接迁移到日常的安全审计、debug、灰盒测试里。
前提:本文假设你对二进制有合法的逆向权 — 自己的产品、CTF 题、获得授权的安全审计目标,或者已经下架但你购买过的旧软件。拿这套手艺去碰别人的商业产物不是这篇文章的预期场景。
剧透:这次实战 8 小时里,6 小时栽在软方法上,2 小时做完最终 18 个补丁的 90%。下面的 5 个教训就是这 8 小时的复盘。如果你做过 Ghidra 至少一次但还没把 patch 真正落到一个跑得起来的 app 上,这篇适合你。
第一个教训:先穷尽软方法,但要知道什么时候放弃
正式开始改二进制之前,我整整花了几小时在"软方法"上 — 改用户态配置,改 Keychain,改 hosts,挂出站防火墙。这些尝试每个都失败了,但失败模式不一样,每一种失败都教了我一件事:
- 先看 crash log:动 binary 之前的第一动作不是反编译,是
~/Library/Logs/DiagnosticReports/<app>-*.ips。Dyld 早期错误(Library not loaded: @rpath/...、Symbol not found、unknown library ordinal -8、bundleIdentifierVerification)通常在termination reasons和exception字段里写得很直白,能直接告诉你下一步该挖框架完整性、符号导出表还是 receipt 校验路径。这条比下面所有软方法都"软",应该是软方法谱系的第 0 步。 - 改 plist preferences:很多看起来像 feature flag 的 key 实际只是 i18n localization key。区分 i18n key 与真实状态 key 的一个识别信号:app 拿到这个字符串后是直接显示还是当判断分支用。看 reflection metadata 里的命名约定通常能立刻识别出来。
- 伪造 Keychain entries:现代 Swift app 普遍对持久化数据做 Ed25519 签名。没有私钥就生成不出合法签名。即使你把 entry 写进去,加载阶段一过签名校验就被丢,并以
hasCachedXxx=false形式记录到日志 — 但你以为它认了。 /etc/hosts屏蔽:在用了 Cloudflare WARP / iCloud Private Relay / 任何用户态 DNS 解析器的机器上,/etc/hosts根本不被查。这条尤其值得记,因为它会让你看着自己的 hosts 文件然后疑惑为什么 nslookup 返回真实 IP — 表象和事实完全分离。- 出站防火墙(LuLu):能 work,但不可移植。换台 Mac 就要重做。
教训不是"软方法没用"。教训是 — 软方法各自有它能解决的问题域,当目标的整个验证链都不在你能改的范围里时,再多软方法叠加也是噪音。这时候要果断切到二进制层。
判断这个临界点有个粗略信号:你已经搞清楚目标的状态机怎么运转,但每个状态入口都被签名/服务器/缓存交叉守住。这时候继续在外面打补丁是在和验证链赛跑。
软方法穷尽之后的判断点过了,就进入工具链选择。
第二个教训:工具链是叠起来用的,不是替代关系
真正进入二进制层后用到的工具集:
- Ghidra 11.3.2 headless mode — 主力反编译。Swift 二进制对 Hopper/IDA 来说也不算好啃,但 Ghidra 自带的 Swift demangler + decompiler 在 10MB 量级的 Mach-O 上一次完整 AutoAnalysis 大概 7–8 分钟。值得跑一次然后让自己的 Java 脚本反复 query 已分析过的
.gprproject,不要每次都重新跑 Analysis。 xcrun llvm-objdump --disassemble --start-address=X --stop-address=Y— Ghidra 反编译的伪 C 不准的时候,就回到 asm 看。具体到 spot-check 一个 cbnz/tbnz 的 immediate,或者一段 movz/movk 拼出来的内联 Swift 字符串。xcrun swift-demangle—nm输出 pipe 进去,能立刻知道_$s10AppName14SomeManagerC...是哪个 class 哪个 method。Swift 把太多结构信息藏在 mangling 里,不会 demangle 没法工作。rabin2 -zz(radare2) — 一次性 dump 所有 cstring / objc methname / Swift reflstr,带 vaddr。比strings -a强很多。lldb -b -s commands.txt— batch mode 验证 patch 真的进了运行中的 image。Attach +memory read --size 4 --count 1 --format x <addr>,对比你期望的字节。这是唯一能区分"我改了文件"和"运行中的进程真的执行新代码"的方法 — 后者还要考虑 ASLR slide。otool -l/lipo -detailed_info— 拿 Universal binary 的 fat header 里 arm64 slice 的 offset。Universal binary 的 universal-offset 和 arm64-slice-offset 差一个固定 base,这个数因 app 而异,每次都得用lipo -detailed_info <bin> | grep -A4 arm64现查(输出里的offset字段就是 base)。我一开始就栽过算错 base 的跟头,导致 patch 看着写成功了但 byte 落到了完全不相关的位置。这是新人最容易踩的坑:写脚本时把 universal offset 和 slice offset 用不同变量名严格区分。dyld_info -fixups <bin>/dyld_info -exports <bin>— macOS 12+ 自带。比nm强的是它能告诉你 dyld 真正看到的 chained fixups 和 export trie 视图。nm看到的"已导出符号"和 dyld 实际用来 bind 的 export trie 可能不一致(构建脚本半重命名 class 时常见),这是单凭 nm 永远查不出来的坑。codesign --force --deep --sign -— 改完字节必须重新签。macOS 的 sealed-resource 校验在 deep mode 下还会去看Contents/里的小文件。识别套路:codesign --verify --verbose /Applications/<app>.app报sealed resource is missing or invalid时,错误信息里会指明具体路径(通常是某个内置 framework / bundle 资源),把那个文件挪出 .app 再 sign,签完再恢复。每个 app 的"违规文件"不一样,但定位模式是固定的。hdiutil create -format UDZO— 把改完的 .app 打回 DMG,给"别的机器"做可分发载体。
第三个教训:找"杀手补丁",不要遍历所有 callsite
逆向 8 小时里最有价值的认知转变:与其在 36 个 callsite 各打一个补丁,不如找他们共同调用的那个函数。
具体表现:badge 渲染、modal 显示、feature gate、菜单项点击响应 — 表面上是 36 个独立的 UI/动作 callsite。但反汇编几个之后你会发现都走同一个 pattern:
bl <some_fn> ; 返回一个 bitmask
mvn wN, w0 ; 求反
and wN, wN, MASK ; 取关注的几位
cbnz xN, deny_path ; 不全是 0 就拒绝
差别只是 MASK 不同。所有 callsite 共享同一个 <some_fn> 决策函数。这时候直接把那个函数的 prologue 重写成 movn x0, #0; ret(返回 0xFFFFFFFFFFFFFFFF),所有 callsite 同时看到 "MASK 完全匹配,放行"。一处改动顶 36 处。
这个思路在防御侧也成立:审计代码时找出这种"全局决策点"函数,它们既是攻击者最高价值的目标,也是你设防应该重点保护的对象(多源验证、运行时完整性、混淆名称)。
补一个 caller-side 安全准则
"把 callee 的 prologue 改成 movn x0, #0; ret" 这个套路只对返回基本类型 / 整型 bitmask 的函数安全。如果 callee 是 Swift 函数返回一个 retain-counted class 实例 / Optional,caller 几乎肯定会做 swift_retain(x0)、bl _objc_release、把 x0 存到栈作为 log 参数,甚至后续 swift_release_n(x0, #2)。这时候你让 callee 返回 0/nil,caller 立刻把 nil 当对象 dereference,SIGSEGV。
实操准则:
- 动 callee 之前先看 caller 怎么用返回值。一个
mov x28, x0紧跟bl _swift_retain就是危险信号。 - 如果返回值被复用,从 caller 侧绕过整个 call 更安全 — 改一条
tbnz/cbz让它永远走"已经满足条件"的分支,让 callee 根本不被调用,或者被调用了但 caller 走"不消费返回值"的路径。 - 改 callee 入口直接 ret 的前提条件是:callee 本来就 void-ish 返回,或者 caller 不在 x0 上做 retain/release/dereference。
这条听起来像废话,但我自己第一次试就直接 SIGSEGV,把这个坑明确写出来。
第四个教训:可复现是底线
任何一个有点野心的 patch 项目,都必须从第一天就配套一份 reapply 脚本。原因:
- 系统升级 / app 更新 / 你自己手滑覆盖了一次 — 这些情况下你需要 30 秒内回到 patched 状态。
- 把工作复制到第二台 Mac 时,"我手动改了哪些字节"作为唯一文档是灾难。
- 复盘和团队分享时,脚本是最好的"做了什么"叙述 — 每一行都是一个明确动作。
脚本格式建议:
- 表驱动,不是 hex blob。每个 entry 是
(univ_offset, expected_old_hex, new_hex, description)。 - 每个 patch 在写之前 check 一遍原字节是不是预期。不匹配就跳过 + 记日志,不是 abort 整个脚本 — 这样脚本是 idempotent 的,重复跑无害。
- patch 完调一次
codesign --verify确认重新签的状态。 - 最后把环境侧的强化措施(比如禁用自动更新)也 reassert 一遍,因为这部分是 per-user defaults,迁移时容易丢。
第五个教训:验证比 patch 本身更花时间
写完 patch 文件、签完、起进程 — 这只是第一步。真正确认它生效需要的步骤:
- 静态确认:
xxd看磁盘上的字节确实改了。 - 重签确认:
codesign -dv确认 signature 是新的(codesign 不会因为你只改了几个字节就嫌弃,但会嫌弃你忘了重签)。 - 加载确认:进程起来后用
vmmap <pid>拿 Load Address,算出 ASLR slide,然后 lldb attach 读运行内存里 patch 后的位置。看到 movn 的 encoding 而不是原来的 stp,才说明 patch 真的在 CPU 执行路径上了。 - 行为确认:触发会走 patch 路径的 UI / API,肉眼或日志确认行为变化。
- 回归确认:让 app 跑足够长时间,确认你 patch 的不是某个一次性初始化路径而是稳定生效。
这五步缺一不可。我有几次是 step 1 + 2 看着好好的,但 step 3 发现 ASLR slide 算错了 — 进程里的字节还是旧的。原因:
- 文件不一致:你改的不是
/Applications/...里那个真正被启动的副本。 - 签名缓存:codesign 后 macOS 内核有 cache,老 app 实例的
Code Signing Identifier跟新签的不匹配,但内核没立即知道。 - ASLR slide 计算错:Mach-O 64-bit executable 的
__TEXTvmaddr base 是0x100000000(这是 link 时定的,看otool -l <bin> | grep -A4 'segname __TEXT'的 vmaddr 字段确认);dylib / framework 的 base 通常是0x0。slide = LoadAddress - static_base,不是 LoadAddress 本身。给 executable 算 slide 用0x100000000,给 framework 算用0x0— 不要弄混。 - TCC 权限随 CDHash 失效:
codesign重签会改变 binary 的 CDHash,macOS 的 TCC(Privacy & Security)以 CDHash 作为权限主键。之前给的 Accessibility / Input Monitoring / Screen Recording / Full Disk Access 全部静默失效 — UI 里 toggle 还显示是开的,实际无权限。表现:⌥d / 全局快捷键之类突然不响应。修法:System Settings → Privacy & Security → Accessibility(或对应项),把 app 删除再重新添加,不是 toggle off/on。
每一个都让我损失过 20–30 分钟。
在团队 share 里能讲什么
把这次工作转成一次内部分享时,我会建议这样组织:
- "软方法 vs 硬方法的转折点判断" — 这是经验,不是知识。讲 5 个具体软方法的失败、每个失败暴露了什么。新人通常会在某一类软方法上死磕一整天,看到别人的失败模式能省他们的时间。
- "工具链不是平行替代,是栈式叠加" — Ghidra 反编译看大方向,llvm-objdump 抠局部 asm,lldb 验证运行时。我看过有同事只用 IDA 或者只用 r2,到了某些场景就卡住。
- "找杀手补丁的思路" — 用 callgraph 工具列出最高入度的函数。配合静态分析定位决策点。这个套路在防御侧用来识别"关键保护对象"同样有效。
- "reapply 脚本和可复现性纪律" — 这条最容易被低估。配合一个 demo:拿一个练习 app 走完整流程(包括最后的脚本),让团队成员当场感受一次"啊 patch 没了 — 跑脚本 — 回来了"。
- "验证的五个层级" — 上面那 5 步,每一步配一个真实踩坑案例。
主题落点不一定是"如何破解",而是"二进制层的可观察性与可复现性"。这套技能在排查线上 crash、做安全审计、给 third-party SDK 做 sanity check 时都用得上。
结尾
这次 8 小时里,6 小时浪费在试软方法上,2 小时拿出最终 18 个补丁的 90%。听起来效率很低 — 但那 6 小时不是白费。每一次软方法的失败都教了我一件关于目标内部结构的事,这些信息后来全部沉淀到了最终的补丁选址判断里。如果一开始就直接上 Ghidra 闷头反编译,反而会在没有先验假设的情况下花更多时间在错的函数上。
逆向工程的本质是一种"信息密度优化"工作 — 你拿到的二进制是高熵的,你要在有限时间里通过工具压缩它的熵,把"哪几行代码决定了哪个用户可见行为"这个问题答出来。从这个意义上说,攻防演练的训练价值不在最终的 patch,而在"在受限信息下推断结构"的肌肉记忆。
这部分肌肉记忆,写代码做产品的同学也用得上。
评论
评论发布后会立即公开,如触发规则可能被审核下架。