文章总结: 本文详述了对HopperDisassemblerv6的安全审计与破解实战。作者利用Ghidra与Frida工具链,通过定位关键Flag、绕过多重签名校验、解决brk异常混淆及反篡改陷阱,最终利用ObjC运行时Hook恢复了被禁用的调试器功能。文中复盘了从静态分析到动态调试的完整攻防技术链路,深入剖析了Demo限制、延迟崩溃与完整性检查等防护机制,为逆向工程与软件保护提供了极具深度的技术参考与实操经验。 综合评分: 95 文章分类: 逆向分析,二进制安全,实战经验
【万字笔记】我与 Hopper disassembler v6 的五天攻防战:从第一个断点到完全接管
原创
心态与度量 心态与度量
随心记事
2026年3月5日 11:36 江苏
一个反汇编器的安全审计之旅,踩过的坑比写过的代码还多
Hopper——macOS上最懂逆向工程师的反汇编器,它的防护也是逆向工程师设计的。brk异常迷宫、通信协议里的隐形杀手、延迟两秒的定时炸弹……当 我用它来逆向它自己,才发现这场攻防战远比想象中精彩
工具链与环境
在开始之前,先介绍本次分析用到的全部工具。没有趁手的兵器,面对一个武装到牙齿的目标是不可能的。
| 工具 | 版本 | 用途 |
| — | — | — |
| Ghidra | 11.x | 主力静态分析。反编译、字符串搜索、交叉引用、数据流追踪 |
| Frida | 16.x | 动态注入。ObjC 方法替换、内存读写、运行时调试 |
| LLDB | Xcode 内置 | 调试器。断点、单步、寄存器查看、内存 dump |
| Hopper | v6.1.0 Demo (arm64) | 目标本身也是分析工具,用来辅助查看自己的 ObjC 类结构 |
| Xcode CLT | 15.x | xcrun clang 编译 dylib,codesign 重签名,install_name_tool 修改 LC |
| Python 3 | 3.11 | 编写二进制补丁脚本,hex 计算 |
| lipo | 系统自带 | 从 Universal Binary 提取 arm64 slice |
| radare2 | 5.x | 辅助反汇编,快速查看特定地址的指令编码 |
环境要求:
- macOS (Apple Silicon / arm64)
- SIP 已禁用(
csrutil disable)—— 代码补丁需要vm_protectwithVM_PROT_COPY - Frida 需要 root 权限或调试权限(
pip install frida-tools)
序幕:一切从好奇开始
先说说 Hopper 是什么
如果你做过 macOS 或 iOS 逆向,大概率用过两个工具:IDA Pro 和 Hopper Disassembler。它们都是反汇编器/反编译器——把编译后的机器码还原成人能读的汇编或伪代码。但两者的技术定位有明显差异。
IDA Pro 是行业标杆。它覆盖几乎所有处理器架构(x86、ARM、MIPS、PowerPC、RISC-V……),支持几十种文件格式,拥有最成熟的反编译引擎 Hex-Rays,以及庞大的插件生态(IDAPython、Lumina 云端函数签名识别等)。它的强项是广度和深度的结合——无论你扔给它什么二进制,它基本都能处理。
Hopper 走的是不同的路。它由法国独立开发者 Vincent Bénony 一人开发和维护,专注于 macOS / iOS / Linux 平台的逆向分析。从技术层面看,Hopper 有几个 IDA 没有或做得不同的特点:
| 维度 | Hopper | IDA Pro |
| — | — | — |
| Objective-C 支持 | 原生深度支持。自动解析 ObjC 方法列表、消息发送、Protocol、Category,反编译输出直接还原成 [obj method:arg] 语法 | 支持但需要额外配置和插件辅助,消息发送的还原不如 Hopper 直观 |
| Swift 支持 | 内建 Swift demangling 和类型推断 | 需要依赖外部脚本或插件 |
| 内置调试器 | 自带 GDB/LLDB 前端,可以在同一界面内反汇编 + 调试 + 动态分析 | 需要配合外部调试器(WinDbg/GDB),虽然集成度也高但主要针对 Windows |
| 控制流图 | 实时交互式 CFG,支持拖拽和手动标注 | 同样强大,Graph View 是 IDA 的招牌功能之一 |
| 架构支持 | x86/x86_64、ARM/ARM64(专注这四种) | 几十种架构 |
| 插件系统 | 提供 Hopper SDK(C/ObjC/Swift),可开发 Loader、Disassembler、Tool 插件 | IDAPython + IDC,生态更庞大 |
| 原生 macOS 体验 | 纯 Cocoa/AppKit 应用,macOS 原生 UI,支持 Apple Silicon | 跨平台 Qt 界面 |
| 自动分析速度 | 对中等规模二进制(<50MB)分析速度快,UI 响应流畅 | 对超大二进制(数百 MB)的处理能力更强 |
简单说:IDA 是全能选手,Hopper 是 Apple 生态的专精选手。 如果你的日常工作是逆向 macOS/iOS 应用,Hopper 对 ObjC/Swift 的开箱即用体验确实更顺手。特别是它的 ObjC 消息发送还原——你在 Hopper 的反编译窗口里看到的几乎就是原始源码的样子,这在分析 Cocoa 应用时省了大量脑力。
那么,故事开始
Hopper Demo 版有三道铁门:不能保存、不能导出、不能调试,还有 30 分钟的使用限制。
作为安全研究者,面对这样一个目标,很难不产生好奇:一个专门给逆向工程师用的工具,它自己的防护做得怎么样?
这个问题特别有意思。Hopper 的用户群体就是逆向工程师——它面对的”潜在攻击者”恰恰是最懂这行的人。开发者 Vincent 本身就是资深逆向工程师,他知道自己的用户会怎么想、怎么做。所以 Hopper 的防护不是普通的商业软件那种”加个壳就完事”的水平,而是一个逆向工程师设计的、防御其他逆向工程师的系统。
于是我决定,用 Hopper 来逆向 Hopper 自己。
这是一个用矛攻盾的故事。
第一章:初探虎穴 —— Demo Flag 的发现
第一步:提取 arm64 二进制。 Hopper 的原始安装包是 Universal Binary(x86_64 + arm64),约 27.5MB。用 lipo 提取单架构:
# 查看架构
file "Hopper Disassembler"
# → Mach-O universal binary with 2 architectures:
# x86_64 (for architecture x86_64)
# arm64 (for architecture arm64)
# 提取 arm64
lipo "Hopper Disassembler" -thin arm64 -output "Hopper Disassembler_arm64"
# → 13.2MB 单 arm64 Mach-O
第二步:拖进 Ghidra 分析。 新建项目,导入 arm64 二进制,等待自动分析完成(约 5-10 分钟)。
第三步:搜字符串。 在 Ghidra 的 Defined Strings 窗口搜索 "demo"``"not available"``"disabled"。顺着字符串的交叉引用(Xrefs),很快定位到一个全局变量:
DAT_100c471f4 (偏移 0xc471f4)
在 Ghidra 中右键这个地址 → References → Find references to 可以看到所有读写它的代码。
这是一个 1 字节的 flag:1 = demo,0 = 完整版。
太好了!把它改成 0 不就行了?
我满怀信心地找到了它的 4 个写入点,准备把所有写入 1 的指令都改成写入 0。
然后 Hopper 启动后卡死了。
第一个坑就这么来了。原来 entry 函数(写入点 W1)在启动时设 flag = 1,后续的插件加载、MAC 地址获取等初始化代码依赖这个初始值为 1。如果一开始就是 0,某些初始化路径会进入死胡同。
教训:不要动 entry 函数。 让它正常设 1,然后在 license 验证阶段再把 flag 改成 0。
最终我只补丁了 3 个写入点(W2/W3/W4),保留了 W1。W2 特别隐蔽 —— 藏在 Sparkle 更新检查的回调 updater:didFinishLoadingAppcast: 里,每次联网检查更新后偷偷把 flag 重置回 1。如果你不补丁它,过一会儿所有限制就会悄悄回来。
一个自动更新框架被用来重置 Demo 状态 —— 这招确实巧妙。
思路:为什么从字符串入手?
逆向工程的第一步永远是信息收集。面对一个 13MB 的二进制,你不可能从头读到尾。需要一个”锚点”来缩小范围。
字符串是最好的锚点。 因为开发者写代码时,错误提示、日志输出、UI 文案都会以明文字符串存在于二进制中。搜索 "demo"``"not available"``"trial" 这类关键词,顺着字符串的交叉引用(Xref)向上追溯,就能找到判断逻辑。
找到 flag 后的思路也很关键:不要急着全部补丁。 先分析每个写入点的上下文:
问自己三个问题:
1. 这个写入发生在什么时机?(启动? 验证后? 定时?)
2. 写入之后,有没有代码依赖这个值?
3. 如果不让它写入,下游会怎样?
W1 在 entry 中写入,下游的插件加载依赖 flag=1。如果跳过 W1,初始化逻辑就乱了。这种依赖关系只有通过分析 Xref 的读取点才能发现——看看谁在读这个 flag,在什么时机读的。
方法论总结:字符串搜索 → Xref 追写入点 → 分析每个写入的上下文 → 选择性补丁。
第二章:铁壁铜墙 —— 四层代码签名防护
改完 demo flag,信心满满地重签名、启动——
2 秒后,闪退。
原来 Hopper 有一套完整的代码签名验证体系,总共 4 个调用点:
| | | — | | |
| 调用点 | 说明 | 能否补丁? | | — | — | — | | #1 | XPC 连接验证 | ❌ 补了窗口不可见 | | #2 | 路径签名验证 | ❌ 补了窗口不可见 | | #3 | 路径签名验证 | ❌ 补了窗口不可见 | | #4 | dylib 验证→崩溃 | ✅ 唯一能动的 |
我花了整整一个下午,反复尝试补丁不同的组合。每次改错一个点,Hopper 就会出现”窗口不可见”的诡异现象 —— 进程在运行,但屏幕上什么都没有。这比闪退更让人崩溃。
最终发现:只能补丁第 4 个调用点,其余三个碰都不能碰。
第 4 个调用点在一个被控制流扁平化混淆的函数 FUN_100541ffc 里。这个函数遍历所有 LC_LOAD_DYLIB 条目,跳过系统库(以 /Sys 或 /usr 开头),对非系统库验证开发者证书(Team ID 2AMA2753NF)。验证失败后,它不是立即崩溃,而是调用 dispatch_after_f延迟 2 秒再崩溃 —— 给你一种”好像能用”的错觉,然后突然死掉。
补丁方案:把崩溃回调入口 0x543e3c 改成一条 ret 指令。签名检查照做,但崩了也无所谓。
思路:面对多个校验点,如何找到”安全”的那一个?
这一章的核心教训是:不是所有校验都能补丁。
直觉告诉你”把所有校验都干掉”,但现实是每个校验存在于不同的上下文中,改掉它可能破坏依赖它的逻辑。正确的思路是逐一排除法:
对每个校验点:
1. 补丁它 → 重签名 → 启动
2. 观察行为:
- 闪退? → 记录崩溃日志,分析原因
- 窗口不可见? → 说明破坏了 UI 初始化链
- 正常? → 这是安全的补丁点
3. 如果不安全,恢复原始字节,测试下一个
4 个调用点中,3 个补丁后都”窗口不可见”——这个现象本身就是线索。说明这 3 个校验嵌在 XPC 连接或 UI 初始化的关键路径上,校验失败后某个 flag 没设对,导致窗口创建逻辑走了不同的分支。
另一个重要思路:不一定要绕过校验本身,可以绕过校验失败后的惩罚。 第 4 个调用点的校验照做,但崩溃回调被 ret 掉了。校验函数说”签名不对”,但没人听它的。这比”让校验返回成功”更安全,因为你不会影响上游逻辑依赖的返回值。
还有一个隐蔽的反调试技巧:延迟崩溃。 如果崩溃是立即的,你马上就知道是哪里出了问题。但 dispatch_after_f 延迟 2 秒,给你一种”好像能用”的错觉——当你以为成功的时候突然死掉,增加了排查难度。遇到”启动后几秒闪退”,先怀疑是不是有定时器/延迟回调在搞鬼。
复现步骤
定位 4 个 codesign 调用点:
# 在 Ghidra 中搜索函数名
# Search → For Strings → "SecStaticCodeCheckValidity"
# 找到导入符号后,右键 → References → Find references to
# 结果: 4 处调用 (0x10001040c, 0x100075eac, 0x10009f2ec, 0x100543440)
逐一测试补丁哪个调用点(耗时过程):
每次只改一个点,重签名、启动、观察行为。前 3 个调用点补丁后要么窗口不可见(XPC 上下文被破坏),要么功能异常。只有第 4 个(0x543440,在 FUN_100541ffc 内)是安全的。
定位 dylib 验证函数 FUN_100541ffc:
# Ghidra 中跳转到 0x100543440 → 向上翻,找到函数入口 FUN_100541ffc
# 观察控制流扁平化结构 (switch-case 状态机)
# 关键逻辑:
# 读 MACH_HEADER.ncmds → 遍历 LC_LOAD_DYLIB
# 跳过 /Sys 和 /usr 前缀 → 剩余的调 SecStaticCodeCheckValidity
# 失败 → dispatch_after_f(2秒, LAB_100543e3c) ← 延迟崩溃
应用补丁(Python):
with open('Hopper Disassembler', 'r+b') as f:
# 补丁1: SecStaticCodeCheckValidity 调用 → mov w0, #0 (永远返回成功)
f.seek(0x543440)
f.write(bytes([0x00, 0x00, 0x80, 0x52])) # mov w0, #0
# 补丁2: 崩溃回调入口 → ret (即使校验失败也不崩溃)
f.seek(0x543e3c)
f.write(bytes([0xC0, 0x03, 0x5F, 0xD6])) # ret
# 重签名 (ad-hoc)
codesign --force --deep --sign - hp.app
第三章:brk #1 —— 异常控制流的迷宫
正当我以为最难的部分已经过去,文件大小检查给了我当头一棒。
Demo 版不允许加载大文件。这个检查的代码藏在 loadMachO64Bits 函数里,而这个函数被 brk #1 异常控制流混淆 保护着。
什么意思?ARM64 的 brk 指令会触发异常。正常程序不会用它来执行逻辑。但 Hopper 在 entry 中设置了 Mach 异常端口,用自定义的异常处理器拦截 brk 异常。每个 brk 立即数(>= 0x966)索引到一张跳转表,异常处理器解密并执行一小段代码,然后修改线程上下文跳到下一个代码块。
在 Ghidra 里看到的就是一片 brk 指令的海洋,完全无法静态分析。
后来发现,这个检查的本质很简单:读取 demo flag,如果是 1 且文件太大,就弹错误。但由于外面裹着 brk 混淆,我额外补了一个”安全网”——把其中一条 cbz w27(条件跳转)改成无条件 b(跳转),直接跳过整个检查分支。
双保险:即使 demo flag 在某个窗口期被重置,文件大小限制也不会触发。
思路:静态分析走不通时怎么办?
brk 混淆是你在逆向中经常会遇到的”墙”—— Ghidra 的反编译器完全无法理解异常控制流,输出的是一堆 brk 指令的碎片。
此时需要切换思维:从静态分析转向动态分析。
静态分析的极限:
- 控制流被混淆 → 反编译失败
- 数据流被加密 → 常量不可读
- 间接跳转 → 目标地址运行时决定
动态分析的优势:
- 不管代码怎么混淆,最终都要执行真正的逻辑
- 设断点观察寄存器值,就能知道实际的判断条件
- Frida 的 Stalker 可以追踪每一条执行的指令
但这里有个更聪明的思路:不去理解混淆的代码,而是绕过它。
文件大小检查的核心逻辑无非是 if (demo && file_too_big) reject()。我已经有了 demo flag 的补丁,理论上 demo=0 就能跳过。但如果 flag 在某个时间窗口被重置呢?
“安全网”思维:在 brk 混淆解码后的代码中,找一条明确的条件跳转(cbz w27),把它改成无条件跳转。这样即使 demo flag 被重置,检查也被物理跳过了。
这种思路叫双保险(Defense in Depth)——不只依赖一个补丁点,而是在不同层面都有保底。攻击者也可以借鉴防御者的方法论。
复现步骤
理解 brk 混淆机制:
# Ghidra 中打开 entry 函数 (0x10000b318)
# 搜索 "task_set_exception_ports" 的调用 → 这是 Mach 异常端口注册
# Hopper 在 entry 中设置了自定义异常处理器拦截 brk 异常
# 每个 brk 的立即数 (>= 0x966) 索引跳转表中的一段解密代码
定位文件大小限制(需 Frida 辅助):
# 直接在 Ghidra 中搜索不到明确的大小检查
# 因为逻辑藏在 brk 混淆背后
# 但可以通过搜索 loadMachO64Bits 函数名找到入口
// 用 Frida 动态观察哪个分支导致大文件被拒绝:
// frida -p <pid> -e '
Interceptor.attach(ptr("0x10049181c"), { // loadMachO64Bits 入口
onEnter(args) { console.log("loadMachO64Bits called"); }
});
// '
// 加载大文件 → 观察是否被拒绝
30 分钟超时阈值定位:
# Ghidra: 跳转到 assemblyView 方法 (0x10002c994)
# 查看反编译代码,找到:
# if (systemUptime - DAT_100c77ae0 > DAT_1006433e0 && demo_flag == 1)
# → 栈溢出崩溃 (128 个 qword 写栈上)
# DAT_1006433e0 的值 = 1900.0 (double),即 31.7 分钟
应用补丁:
import struct
with open('Hopper Disassembler', 'r+b') as f:
# 超时阈值 1900.0 → 99999999.0
f.seek(0x6433e0)
f.write(struct.pack('<d', 99999999.0))
# 文件大小检查: cbz w27 → 无条件 branch
# 原始: 0x7b000034 (CBZ W27, +0xC) → 如果 demo=0 则跳过
# 补丁: 0x03000014 (B +0xC) → 无条件跳过
f.seek(0x4919f8)
f.write(bytes([0x03, 0x00, 0x00, 0x14]))
ARM64 指令编码提示:
CBZ Wn, offset的条件码为 0x34,改成B offset的编码为 0x14,只需改一个 nibble。可以用radare2验证:rasm2 -a arm -b 64 -d 03000014→b 0xc
第四章:消失的调试器 —— 两层清空与完整性陷阱
到这里,加载和导出都搞定了。但调试器是最硬的骨头。
点击菜单里的”Debugger”,弹出一个 Demo 限制提示框。用 Frida hook 掉 NSAlert,再点 —— 什么都没发生。
Ghidra 里查看 toggleDebuggerWindowAndForceLocal:,发现整个方法体被替换成了纯 NSAlert stub。这是第一层清空。
但更阴险的是第二层:showWindowForDebuggerType:startingWithDocument: —— 调试器的核心初始化方法 —— 只有一条 ret 指令。整个方法体被清空了。
这意味着即使你绕过了 alert,也无法触发调试器初始化。
更刺激的是反篡改保护。toggleDebuggerWindow: 方法里有一个完整性哈希检查:
对某段不相关的代码做 59 次 dword 滚动 XOR
检查 (hash & 0x7f8000) == 0x5d8000
如果失败 → do {} while (true) // 死循环!
如果你试图补丁这个方法的某些字节,哈希就会变化,app 直接卡死在无限循环里。
破解方法:不补丁,直接用 method_setImplementation 整体替换。 ObjC 方法分发走的是消息转发机制,我替换的是 IMP 指针,不修改任何代码字节,哈希检查管不着。
然后我发现 —— 调试器的整个后端代码都在!DebuggerWindowController、DebuggerChannel、LLDBDriver、GDBDriver……所有类都完整存在,只是入口被堵死了。
这就像一栋大楼,所有房间都好好的,只是大门被砌死了,窗户被封了。我需要的不是重建,而是找到另一条路进去。
思路:当功能被”删除”了,如何判断后端还在?
遇到”点了没反应”的情况,新手的第一反应往往是”功能被删了”。但在商业软件中,完全删除代码比保留代码更难——因为调试器模块和其他模块有大量依赖关系,删除会导致编译错误。
判断后端是否存在的方法:
1. 搜索类名: 在 Ghidra 的 Symbol Table 中搜索 "Debugger"
→ 如果 DebuggerWindowController, DebuggerChannel 等类存在,后端还在
2. 看方法列表: 查看这些类有多少个方法
→ 如果只有 1-2 个空方法,但有几十个正常方法,说明只清空了入口
3. 查看 nib/xib 资源: 在 app bundle 的 Resources 中搜索
→ 如果 DebuggerWindowController.nib 存在,说明 UI 资源也完整
4. 追踪调用链: 从被清空的方法往下看,它原本应该调用谁?
→ 如果被调用者的实现完整,说明只是"断路"而不是"拆线"
面对完整性哈希保护,关键认知是分清”层次”:
Hopper 的完整性检查只覆盖 __TEXT 段的代码字节。而 ObjC 的方法分发走的是运行时的 IMP 指针表——这在 __DATA 段或堆上。method_setImplementation 改的是指针,不是代码,哈希值完全不变。
方法论:面对完整性保护,不要在它检查的范围内动手——换一个层面操作。 就像门上装了报警器,你不拆门,而是换锁芯。
复现步骤
发现第一层清空(NSAlert stub):
# Ghidra: Search → For Strings → "toggleDebuggerWindowAndForceLocal"
# 或直接搜索 ObjC 方法: Window → Symbol Table → filter "toggleDebugger"
# 查看反编译: 整个函数体只有 NSAlert 创建 + runModal,没有真正的调试器逻辑
发现第二层清空(空 ret):
# Ghidra: 搜索 "showWindowForDebuggerType"
# 跳转到 0x10009eef8
# 反编译结果: 只有一条 return 语句,整个方法体被清空
发现完整性哈希陷阱:
# Ghidra: 搜索 "toggleDebuggerWindow:" (注意: 没有 AndForceLocal)
# 在函数体中找到一段可疑的循环:
# 对地址 0x100029b84 开始的 59 个 dword 做滚动 XOR
# 检查 (hash & 0x7f8000) == 0x5d8000
# 如果失败 → do { } while(true) // 无限循环
# 这意味着如果你修改了 0x100029b84 附近的任何代码,哈希会变,app 卡死
发现调试器后端完整存在:
# 用 class-dump 或 Ghidra 列出所有 ObjC 类:
# Ghidra: Window → Symbol Table → Filter "Debugger"
# 结果:
# DebuggerWindowController (nib: DebuggerWindowController)
# DebuggerChannel
# LLDBDriver / GDBDriver
# LocalXPCTransport / RemoteDebugServerTransport
# 所有方法实现完整,只有 UI 入口被阻断
绕过方案:ObjC 运行时替换
// 在 dylib 中使用 method_setImplementation 替换整个方法
// 不修改任何代码字节 → 完整性哈希无法检测
Class cls = objc_getClass("HopperDocument");
Method m = class_getInstanceMethod(cls, @selector(toggleDebuggerWindowAndForceLocal:));
method_setImplementation(m, (IMP)my_toggleDebugger);
// 同样替换 showWindowForDebuggerType:startingWithDocument:
关键洞察:ObjC 消息分发走的是
isa → method_list → IMP指针查找。method_setImplementation只改 IMP 指针值,不修改__TEXT段的代码字节。完整性哈希检查的是代码区域,管不到运行时 IMP 指针。
第五章:XPC 迷宫 —— 一个偏移量引发的血案
调试器 UI 恢复了,但点”Attach”后总是连不上。
日志显示 localConnect 失败。追踪代码,发现 archForCurrentFile 需要从 channel+0x60 读取 DisassembledFile。
但我之前一直以为文件在 channel+0x30。
+0x30 实际上是 LocalXPCTransport(init 时自动创建的 XPC 传输层),真正的 DisassembledFile 在 +0x60。一个偏移量的差异,debug 了大半天。
修正偏移后,XPC 连接能建立了。LLDB 后端启动,startBackgroundThread 返回成功。
然后 —— 连接建立 0.5 秒后自动断开。
这次是真的绝望了。日志里没有任何错误,连接就是无声地断掉。
思路:当”连不上”时,如何缩小排查范围?
调试器连接涉及多层:UI → DebuggerWindowController → DebuggerChannel → LLDBDriver → LocalXPCTransport → XPC → lldb-rpc-server。任何一层出问题都会”连不上”。
分层排查法:
从最底层往上逐层验证:
1. XPC 连接本身能建立吗?
→ 在 Frida 中调用 xpc_connection_create → 看是否返回有效连接
→ 如果失败,是 codesign 问题
2. LLDB Driver 能初始化吗?
→ Hook LLDBDriver 的 init → 看是否被调用
→ Hook startBackgroundThread → 看返回值
3. Channel 的 ivar 设对了吗?
→ 逐个读取 channel 的 ivar → 确认类型和值
→ 这里发现了 +0x30 vs +0x60 的错误
ivar 偏移量错误是最隐蔽的 bug 之一。 编译器不会报错,运行时不会崩溃(只是读到错误的指针),连日志都看不出来。
验证偏移量的万能方法:
// 在 Frida 中,读一个指针后立即检查它的 ObjC 类名
var ptr = obj.handle.add(offset).readPointer();
if (!ptr.isNull()) {
try {
console.log("class:", newObjC.Object(ptr).$className);
} catch(e) {
console.log("not an ObjC object!");
}
}
// 如果类名不是你期望的 → 偏移量错了
这个习惯可以避免无数小时的排查。每次从内存读指针,都先验证它的类型。
复现步骤
追踪 DebuggerChannel 的 ivar 布局:
// Frida: 枚举 DebuggerChannel 的所有 ivar
var cls = ObjC.classes.DebuggerChannel;
var ivars = cls.$ivars; // 列出所有实例变量
// 关键 ivar 偏移:
// +0x28 debuggerType (int)
// +0x30 transport (LocalXPCTransport*) ← 容易误认为是 file!
// +0x60 file (DisassembledFile*) ← 真正的文件引用
// +0x80 state (int)
// +0x88 driver (LLDBDriver*)
发现 +0x30 vs +0x60 的陷阱:
// 在 Frida 中验证:
var channel = /* 获取 DebuggerChannel 实例 */;
var ptr30 = channel.handle.add(0x30).readPointer();
var ptr60 = channel.handle.add(0x60).readPointer();
console.log("+0x30 class:", newObjC.Object(ptr30).$className);
// → "LocalXPCTransport" (不是 DisassembledFile!)
console.log("+0x60 class:", newObjC.Object(ptr60).$className);
// → "DisassembledFile" (这才是 archForCurrentFile 要读的!)
追踪 localConnect 调用链:
# Ghidra: 搜索 "localConnect" → 找到 localConnect, localConnectUsingLLDB:arch:
# localConnect 调用 archForCurrentFile → 读取 channel+0x60 (file) 的 CPU 类型
# 然后调用 localConnectUsingLLDB:YES arch:<cpu>
# localConnectUsingLLDB 做:
# 1. 创建 LLDBDriver
# 2. 创建 LocalXPCTransport (这会存到 +0x30)
# 3. 调用 [driver startBackgroundThread]
# 4. 调用 [driver attachToProcess:pid]
XPC 连接中的 codesign 问题:
# LLDB 后端通过 XPC 启动一个 helper 进程
# XPC 连接建立时,macOS 会验证双方签名
# FUN_10009f28c 是 XPC 连接验证函数(codesign 调用点 #3)
# 我们不能永久补丁它(会导致窗口不可见)
# 方案: 临时补丁 → 连接成功 → 恢复原始代码
// dylib 中的临时绕过:
static void temp_patch_codesign(void) {
// 将 FUN_10009f28c 改为 mov w0,#1; ret
uint8_t patch[] = {0x20,0x00,0x80,0x52, 0xC0,0x03,0x5F,0xD6};
vm_protect(mach_task_self(), addr, 8, 0,
VM_PROT_READ|VM_PROT_WRITE|VM_PROT_COPY);
memcpy((void*)addr, patch, 8);
sys_icache_invalidate((void*)addr, 8);
}
static void restore_codesign(void) {
// XPC 连接建立后,恢复原始字节
memcpy((void*)addr, orig_bytes, 8);
sys_icache_invalidate((void*)addr, 8);
}
第六章:隐形杀手 —— 埋在通信协议里的 License 检查
我逐字节对比了 demo 版和正常连接流程的差异,终于在 sendCommandAndWaitForResponse: 的反编译代码里发现了这段:
// @ 0x1004229ec
void **globalPtr = *(void **)(0x100c48a70); // license 状态
if (*(globalPtr + 0x10) != 0x14) {
*(uint8_t *)(driver + 0x38) = 0; // 清零 connected flag!
}
每次发送命令给 LLDB 时,都会检查 license 状态。如果不是 0x14(正版),就把 driver 的 connected 标志清零。
这不是在入口处检查,而是埋在每一次通信的过程中。即使你成功建立了连接,第一条命令发出去后,连接就被斩断了。
这一刻我对 Hopper 的作者充满了敬佩 —— 把检查藏在通信协议的最深处,而不是门口,这才是真正的纵深防御。
修复方案只需要一条 NOP:
strb wzr,[x20,#0x38] → nop
4 个字节,把 0x3900e29f 改成 0xd503201f。
连接终于稳定了。
思路:连接”建立后断开”——怎么找到隐藏的检查?
这是整个项目中最难的一关。难点在于:连接成功建立了(startBackgroundThread 返回 OK),然后 0.5 秒后无声断开。没有错误日志、没有异常、没有崩溃。
当”一切正常但就是不工作”时,问题往往藏在你不会去看的地方。
排查思路的演进:
第一阶段 — 怀疑初始化问题:
hook localConnect 的每一步 → 全部成功 → 排除
第二阶段 — 怀疑 XPC 通道问题:
hook receiveData:maxLength: → 能收到数据 → 排除
第三阶段 — 怀疑状态被修改:
"连接建立后断开" → 某个地方把 connected flag 清零了
→ 但谁在写 driver+0x38?
第四阶段 — 逆向思考:
不找"谁导致了断开",而是问"connected flag 在哪些地方被写入"
→ Ghidra: 对 driver+0x38 的偏移搜索所有 strb 指令
→ 在 sendCommandAndWaitForResponse: 中发现内联 license 检查!
关键方法论:当正向追踪失效时,用反向追踪。
正向:从连接建立的代码往下追 → 找不到问题 反向:从”connected flag 被清零”这个结果往上追 → 找到写入点
在 Ghidra 中,对目标地址(结构体成员偏移)做 Xref 搜索,可以找到所有读写它的代码。这里的关键是要知道搜什么——不是搜某个函数名,而是搜某个特定偏移量的内存写入。
为什么这个检查这么难找? 因为它违反了”常规”设计模式。正常的 license 检查在功能入口处——”你没有权限使用这个功能”。但 Hopper 把检查放在每次通信的数据路径上——让你先进来,然后在你说第一句话的时候断开。这就是纵深防御的精髓。
复现步骤
定位内联 license 检查:
# Ghidra: 搜索 "sendCommandAndWaitForResponse"
# 跳转到实现,逐行阅读反编译代码
# 在 0x1004229ec 处找到:
# void **globalPtr = *(void **)(0x100c48a70);
# if (*(globalPtr + 0x10) != 0x14) { // 0x14 = 正版标识
# *(uint8_t *)(driver + 0x38) = 0; // 清零 connected flag
# }
# 关键: 这条 strb 指令藏在每一次 LLDB 命令发送的路径中
用 Frida 动态验证(确认是这条指令导致断开):
// frida -p <pid>
var base = Module.findBaseAddress("Hopper Disassembler");
var patchAddr = base.add(0x4229ec);
// 读取原始指令
console.log("原始指令:", patchAddr.readU32().toString(16));
// → 3900e29f = strb wzr, [x20, #0x38]
// NOP 掉这条指令
Memory.patchCode(patchAddr, 4, code => {
code.writeU32(0xd503201f); // NOP
});
console.log("已 NOP license check");
// 此后 attach 进程 → 连接保持稳定
在 dylib 中实现同样的补丁:
// 运行时 NOP: vm_protect + memcpy
uintptr_t addr = g_base + 0x4229ec;
uint32_t nop = 0xd503201f;
vm_protect(mach_task_self(), addr & ~0xFFF, 0x1000, 0,
VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY);
memcpy((void *)addr, &nop, 4);
sys_icache_invalidate((void *)addr, 4);
调试技巧:如果你不确定哪条指令导致连接断开,可以在 Frida 中用
Interceptor.attach监控driver+0x38的写入。但更高效的方式是:在 LLDB 中对driver+0x38设置 watchpoint(watchpoint set expression -w write -- (uint8_t*)(driver_addr + 0x38)),断在写入时查看调用栈。
第七章:保存的艺术 —— 逆向一个文件格式
调试器搞定后,剩下最后一个大 Boss:保存功能。
writeToURL:ofType:error: 是纯 alert stub,saveHopperDocumentInStream:withPresentation: 只有一条 ret。两层阻断,和调试器一样的套路。
但保存不像调试器那样只需要”通路”,它需要实现一个完整的文件格式协议。
通过逆向 loadHopperDocumentInStream:(加载方法),我还原了 .hop 文件格式:
+00: "hopperdb" (8 bytes magic)
+08: ULEB128 version (当前 = 61)
+09: uint32 CRC32 (4 bytes)
+13: [LZ4 压缩数据]
↓ 解压后:
+00: uint64 cursor_address
+08: 序列化对象图 (27+ 个类,递归序列化)
关键发现:虽然入口被清空了,但所有 27 个 serializeWithState: 实现都完整保留!这意味着我不需要重新实现整个序列化逻辑——只需要正确调用入口方法,把”管道”接上。
但 CRC32 给了我一个教训。第一次保存的文件打不开,因为我忘了在写完数据后回头修正 CRC32 校验值。.hop 格式要求对 offset 13 到 EOF 的所有字节计算 CRC32,写入 offset 9。少了这 4 个字节,加载时直接 status 5 失败。
还有压缩 —— version >= 45 必须用 LZ4。不压缩的话,加载器会把原始数据丢给 LZ4 解压器,然后得到一堆垃圾。
思路:如何逆向一个私有文件格式?
保存功能不像调试器那样”接上管道就行”。你需要理解目标文件格式的每一个字节。
逆向私有文件格式的万能思路:先看加载(Load),再写保存(Save)。
为什么先看 Load:
1. Load 函数是 "specification by implementation"
—— 它告诉你每个字节的含义、顺序、编码方式
2. Load 通常有详细的错误处理
—— 哪个字段校验失败,错误码是什么,都写得很清楚
3. Load 的逻辑比 Save 简单
—— 只需读取和解析,不需要构造和编码
具体方法:
1. 找到 loadHopperDocumentInStream: → 在 Ghidra 中反编译
2. 逐行阅读:
- 前 8 字节 → magic "hopperdb"
- 下一个 ULEB128 → version
- 下 4 字节 → CRC32
- 剩余 → LZ4 压缩数据
3. 用已知的 .hop 文件验证你的理解
→ 用 Python 读取前几个字节,确认 magic 和 version
4. 然后按 Load 的逆过程写 Save
另一个关键洞察:检查被清空的入口和完整的后端之间的关系。
saveHopperDocumentInStream: 被清空了,但 DisassembledFile 上的 27 个 serializeWithState: 方法全都完整。这意味着底层的序列化引擎还在,只是没人调用它。
所以保存的实现不是”从头写序列化”,而是”写一个正确的入口函数来调用已有的序列化代码”。这就把一个几千行的工程量缩减到了几十行。
方法论:逆向加载 → 理解格式 → 确认底层代码存在 → 写一个入口把管道接上。
复现步骤
逆向 .hop 文件格式(通过 loadHopperDocumentInStream:):
# Ghidra: 搜索 "loadHopperDocumentInStream"
# 反编译后逐行分析加载逻辑:
# 1. 读 8 bytes magic → 必须是 "hopperdb"
# 2. 读 ULEB128 → version (当前 = 61)
# 3. 读 4 bytes → stored CRC32
# 4. 读剩余所有数据 → LZ4 解压
# 5. 计算 CRC32(offset 13 → EOF) → 与 stored CRC32 对比
# 6. 从解压数据中反序列化对象图
实现保存的关键代码(Frida 版):
// 替换 saveHopperDocumentInStream:withPresentation:
ObjC.implement(method, function(self, sel, stream, presentation) {
var doc = self.document();
var disFile = doc.disassembledFile();
// 1. 写 magic
var magic = Memory.allocUtf8String("hopperdb");
stream.write_bytes_(NSData.dataWithBytes_length_(magic, 8));
// 2. 写 ULEB128 version (61)
var verBuf = Memory.alloc(1);
verBuf.writeU8(61);
stream.write_bytes_(NSData.dataWithBytes_length_(verBuf, 1));
// 3. 预留 CRC32 占位 (4 bytes zero)
var crcPlaceholder = Memory.alloc(4);
crcPlaceholder.writeU32(0);
stream.write_bytes_(NSData.dataWithBytes_length_(crcPlaceholder, 4));
// 4. 序列化所有对象 → LZ4 压缩 → 写入
// 调用 disFile 上的 27 个 serializeWithState: 方法
// (它们都完整保留在 Demo 中!)
// 5. 回头修正 CRC32
// 计算 offset 13 到 EOF 的 CRC32
// seek 到 offset 9,写入 4 bytes CRC32
});
验证 .hop 文件正确性:
# 用 Python 读取并验证 .hop 文件
import struct, zlib
with open("test.hop", "rb") as f:
magic = f.read(8)
assert magic == b"hopperdb", f"Bad magic: {magic}"
ver = f.read(1)[0] # ULEB128 (version <= 127 only needs 1 byte)
print(f"Version: {ver}") # 应该是 61
stored_crc = struct.unpack("<I", f.read(4))[0]
rest = f.read()
computed_crc = zlib.crc32(rest) & 0xFFFFFFFF
print(f"Stored CRC: {stored_crc:#010x}")
print(f"Computed CRC: {computed_crc:#010x}")
assert stored_crc == computed_crc, "CRC mismatch!"
print("CRC OK ✓")
第八章:从 Frida 到原生 —— 1425 行的蜕变
到这里,所有功能都通过 Frida 脚本实现了。但每次启动都要挂载 Frida,不够优雅。
终极目标:一个纯原生 dylib,零外部依赖。
将 Frida 的 JavaScript hook 全部翻译成 Objective-C 的 method_setImplementation,听起来简单,做起来全是坑。
坑 1:SIGSEGV —— 一个 %@ 引发的崩溃
id stoppedThread = [channel getFirstStoppedThreadID];
NSLog(@"thread: %@", stoppedThread); // 💥 SIGSEGV
getFirstStoppedThreadID 返回的是 Mach thread ID(一个原始整数,比如 0xADF47A),不是 ObjC 对象!%@ 会调用 objc_msgSend(0xADF47A, @selector(description)),对一个随机整数调用消息发送 —— 在 isa 查找阶段必然崩溃。
改成 %p 就好了。但这种 bug 在运行时才会暴露,编译器不会警告你。
坑 2:task_for_pid 的背叛
在 Frida 里,读取寄存器用 Mach API 完美工作。但移植到 dylib 后:
[REGS] task_for_pid failed: KERN_FAILURE (5)
原因:Frida 作为外部调试器以 root 权限运行,拥有 com.apple.security.cs.debugger entitlement。但 dylib 在 Hopper 进程内运行,继承的是 Hopper 的受限权限。同样的 API,不同的上下文,完全不同的结果。
最终方案:不用 Mach API,改为通过已建立的 LLDB XPC 连接发送 register read 命令,解析文本输出。殊途同归。
坑 3:About 窗口的三重误导
用户想改 About 面板的文字。我的第一反应:
- 改
Credits.rtf→ 没用。About 窗口用的是自定义 nib,不读 Credits.rtf - Hook
openAboutPanel:→ 不触发。About 窗口不走标准路径 - 试了
orderFrontStandardAboutPanel:→ 还是不触发
最终发现唯一有效的方式:监听 NSWindowDidBecomeKeyNotification,检测到 AboutWindowController 窗口出现时,遍历所有子视图修改文本。三次尝试,三次失败,第四次才找到正确姿势。
思路:从动态注入到原生代码,最大的陷阱是什么?
Frida 是弱类型的 JavaScript,写起来快但容易隐藏 bug。翻译成 ObjC 后,很多问题暴露出来了。
最核心的思维转变:上下文不同了。
Frida 的上下文:
- 以 root 权限运行在独立进程
- 通过 ptrace/task_for_pid 操作目标进程
- 有完整的调试权限 (com.apple.security.cs.debugger)
Dylib 的上下文:
- 运行在目标进程内部
- 继承目标进程的权限(Hopper 的权限很有限)
- 没有调试权限 → task_for_pid 失败
- 初始化时序不同 → constructor 阶段 AppKit 还没准备好
所以移植时的第一件事不是翻译代码,而是列出哪些 API 在新上下文中不可用:
不可用:
- task_for_pid → 需要调试权限(改用 LLDB XPC 命令)
- thread_get_state → 同上
- Interceptor.attach → 没有对应物(改用 method swizzle)
- setTimeout → 不存在(改用 dispatch_after)
需要特殊处理:
- constructor 中不能访问 AppKit(改用通知延迟)
- %@ 格式化非 ObjC 对象会崩溃(Frida 自动处理,ObjC 不会)
- weak reference 需要手动 objc_storeWeak(Frida 自动处理)
另一个重要思路:临时补丁 + 恢复。
codesign 调用点 #3 不能永久补丁(会破坏窗口),但 XPC 连接建立时需要它通过。方案是:在需要的瞬间临时改掉,用完立即恢复。这种”闪补”思维在很多场景下都有用——比如过反调试检查时临时修改返回值,过完就恢复。
复现步骤
从 Frida 到 dylib 的完整移植流程:
# 1. 编译 dylib (1425 行 ObjC)
xcrun --sdk macosx clang -dynamiclib \
-framework Foundation -framework AppKit \
-lz -fno-objc-arc -arch arm64 \
-install_name @executable_path/libhopperexport.dylib \
-o /tmp/libhopperexport.dylib /tmp/hopper_bypass.m
# 2. 注入 dylib: 替换一个不存在的 weak dylib 引用
# libswiftSpatial.dylib 在 macOS 上不存在,原始是 LC_LOAD_WEAK_DYLIB
# 替换它不会改变 ncmds/sizeofcmds → 不触发 FUN_100541ffc 检测
install_name_tool -change \
/usr/lib/swift/libswiftSpatial.dylib \
@executable_path/libhopperexport.dylib \
"hp.app/Contents/MacOS/Hopper Disassembler"
# 3. 复制 dylib 到 app 的 MacOS 目录
cp /tmp/libhopperexport.dylib "hp.app/Contents/MacOS/"
# 4. 重签名
codesign --force --deep --sign - hp.app
# 5. 启动 (无需 Frida!)
open hp.app
Frida → dylib 的关键技术对照:
| | | — | | |
| Frida 概念 | dylib 对应 | 注意事项 |
| — | — | — |
| ObjC.implement(method, fn) | method_setImplementation(m, (IMP)fn) | 完全等价,不触发 anti-hook |
| Interceptor.attach(addr, {onEnter, onLeave}) | method swizzle(保存原始 IMP,前后调用) | 需手动管理原始 IMP |
| Memory.patchCode(addr, size, fn) | vm_protect + memcpy + sys_icache_invalidate | 必须加 VM_PROT_COPY flag |
| setTimeout(fn, ms) | dispatch_after(dispatch_time(...), queue, block) | 用 GCD 替代 |
| ObjC.schedule(ObjC.mainQueue, fn) | dispatch_async(dispatch_get_main_queue(), block) | UI 操作必须在主线程 |
| ptr(addr).readU64() | *(uint64_t *)(base + offset) | C 指针运算 |
| new NativeFunction(objc_msgSend, ...) | 直接调用 objc_msgSend 加强制类型转换 | ((ret(*)(id,SEL,...))objc_msgSend)(...) |
常见移植陷阱:
// 坑1: %@ 对非 ObjC 对象崩溃
id threadID = [channel getFirstStoppedThreadID];
NSLog(@"thread: %@", threadID); // 💥 threadID 是整数不是对象!
NSLog(@"thread: %p", threadID); // ✅ 用 %p
// 坑2: task_for_pid 在 app 内失败
// Frida 以 root 运行有 debugger entitlement,dylib 继承 app 权限
// 方案: 改用 LLDB XPC 命令读寄存器
// 坑3: constructor 中不能用 AppKit
__attribute__((constructor)) staticvoid init(void) {
// ❌ 此时 AppKit 还没初始化,访问 NSApp 会死锁
// ✅ 注册 NSApplicationDidFinishLaunchingNotification 延迟执行
}
第九章:最后的细节 —— 水印、注册窗与完美主义
功能全部到位后,还有三个碍眼的东西:
1. 反汇编视图上的大号半透明 “Demo Version” 水印
找到绘制方法 drawStatusIndicatorAt:,替换为空函数。一行代码搞定。
2. 启动时的注册/许可证对话框
第一版方案:监听窗口出现 → performClick: 点击 “Try the Demo”。但窗口会闪一下。
第二版方案:Hook RegistrationWindowController 的 windowDidLoad,在窗口可见之前直接调用 tryDemoButtonClicked:。窗口从来不会出现在屏幕上。完美。
3. 版本号里的 “-demo” 后缀
改 Info.plist 的 CFBundleShortVersionString,加上运行时文本替换。
思路:UI 层的修改为什么总是最折腾?
功能绕过有明确的对错——能用就是成功。但 UI 修改的坑在于你不知道 UI 是怎么搭建的。macOS app 的 UI 可能来自 nib/xib、Storyboard、纯代码、或者混合方式。
找到正确 hook 点的排除法:
想修改 About 窗口的文字:
尝试1: 改 Credits.rtf
→ 失败。About 窗口是自定义 nib,不用标准的 Credits 机制
→ 教训: 别假设 macOS app 都走标准路径
尝试2: Hook orderFrontStandardAboutPanel:
→ 不触发。Hopper 用了自定义的 AboutWindowController
→ 教训: 先确认入口方法是什么
尝试3: Hook AboutWindowController 的 windowDidLoad
→ 触发了! 但此时子视图还没加载完,文本是空的
→ 教训: hook 时机很重要
最终: 监听 NSWindowDidBecomeKeyNotification
→ 窗口完全加载并显示后触发,此时所有子视图都有值了
→ 遍历 subviews 修改文本 ✓
注册窗口的方案演进也体现了”逼近最优解”的过程:
方案1: 通知 + performClick → 窗口闪一下再消失(能用但不完美)
方案2: windowDidLoad hook → 在显示之前就关掉(完美)
为什么方案2更好?
windowDidLoad 在 -[NSWindow orderFront:] 之前调用
此时窗口还没被加入窗口服务器的渲染队列
所以用户永远看不到这个窗口
方法论:当 hook 的”位置”对了但”时机”不对时,往 AppKit 的生命周期上下游移动——windowDidLoad → windowWillAppear → windowDidAppear → NSWindowDidBecomeKeyNotification,每个阶段 UI 的状态不同。
复现步骤
水印移除:
// 搜索绘制方法: Ghidra → "drawStatusIndicatorAt"
// 位于 AssemblyView 类,demo 模式下在反汇编视图中央绘制大字 "Demo Version"
// 替换为空函数即可:
Method m = class_getInstanceMethod(cls, @selector(drawStatusIndicatorAt:));
method_setImplementation(m, (IMP)noop_method);
注册窗口自动关闭:
// 方案1 (有闪烁): 监听 NSWindowDidBecomeKeyNotification
// 检测到 RegistrationWindowController 窗口 → performClick: demoButton
// 问题: 窗口会短暂出现再消失
// 方案2 (完美): 直接 hook windowDidLoad
// windowDidLoad 在窗口显示之前被调用
static void patch_registrationWindowDidLoad(idself, SEL _cmd) {
// 直接调用 tryDemoButtonClicked: → 窗口永远不会出现
((void(*)(id,SEL,id))objc_msgSend)(self,
@selector(tryDemoButtonClicked:), nil);
}
// tryDemoButtonClicked: 内部:
// 1. 加载 hopperDocumentWindow (weak ref)
// 2. 关闭注册窗口
// 3. 显示主窗口
// 效果: 启动时完全看不到注册对话框
About 面板文本修改:
// NSWindowDidBecomeKeyNotification 回调中:
// 检测 windowController 是否为 AboutWindowController
// 如果是 → 遍历 contentView 的所有子视图
// 找到 NSTextField:
// - 含 "By " 前缀的行 → setHidden:YES (隐藏作者信息)
// - 含 "-学习版" 的行 → stringByReplacingOccurrencesOfString 去掉后缀
Info.plist 修改:
# 还原版本号
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString 6.1.0" \
hp.app/Contents/Info.plist
终章:全貌
最终的 hopper_bypass.m 有 1425 行,包含 17+ 个 hook,覆盖了 Hopper v6 的全部 Demo 限制。
从二进制启动到正常使用,不需要 Frida、不需要调试器、不需要任何外部工具。一个 dylib,编译一次,永久生效。
全部绕过清单:
| | | — | | |
| 防护 | 绕过方式 |
| — | — |
| Demo Flag (4处写入) | 二进制补丁 str wzr |
| 代码签名验证 (4处) | 补丁崩溃回调 + 运行时临时绕过 |
| 30分钟超时 | 阈值补丁 99999999.0 |
| 文件大小限制 | demo flag + brk 绕过 |
| NSAlert 弹窗 | runModal swizzle |
| 调试器 UI (3个入口) | method_setImplementation |
| 调试器初始化 | 重写 showWindowForDebuggerType: |
| LLDB XPC 连接 | 重写 localConnectUsingLLDB:arch: + 临时 codesign bypass |
| 通信协议 license check | NOP strb wzr,[x20,#0x38] |
| 保存 (.hop 格式) | 实现完整序列化协议 |
| 导出二进制/文本 | 调用底层 patchedData / produceTextFileForURL: |
| 寄存器读取 | LLDB XPC register read |
| “Demo Version” 水印 | drawStatusIndicatorAt: → no-op |
| 注册窗口 | windowDidLoad → tryDemoButtonClicked: |
| About 面板 | NSWindowDidBecomeKeyNotification + subview 遍历 |
| 反 Hook 检测 | ObjC 层面替换,不触发 dlsym 对比 |
| 完整性哈希 | 整体替换方法,不修改被检查的代码区域 |
附录:完整复现清单(方案一:纯补丁 + Dylib)
以下是从零到完成的全部操作步骤。假设你已安装 Xcode CLT 和 Ghidra。
# ===== 第一步: 准备二进制 =====
cd ~/Downloads
cp -r "Hopper Disassembler v6.app" hp.app
# 提取 arm64 slice (如果是 Universal Binary)
cd hp.app/Contents/MacOS
lipo "Hopper Disassembler" -thin arm64 -output "Hopper Disassembler_arm64"
mv"Hopper Disassembler_arm64""Hopper Disassembler"
# ===== 第二步: 应用 8 处二进制补丁 =====
python3 << 'PATCH'
import struct, binascii
with open("hp.app/Contents/MacOS/Hopper Disassembler", "r+b") as f:
patches = [
# (offset, bytes, description)
(0x543440, bytes([0x00,0x00,0x80,0x52]), "codesign → mov w0,#0"),
(0x543e3c, bytes([0xC0,0x03,0x5F,0xD6]), "crash callback → ret"),
(0x6433e0, struct.pack('<d', 99999999.0), "timeout 1900→99999999"),
(0xf8cc, bytes([0x1F,0x01,0x00,0xB9]), "demo flag W3 → wzr"),
(0xf8e4, bytes([0x3F,0x01,0x00,0xB9]), "demo flag W4 → wzr"),
(0x138a8, bytes([0x1F,0x01,0x00,0xB9]), "Sparkle W2 → wzr"),
(0x45bd0, binascii.unhexlify( "debugger entry (76B)"
'f44fbea9fd7b01a9fd430091a85000f0'
'082946f9000140f9065a1494f30300aa'
'682640f9a80000b5a85f00b0003d46f9'
'fd2e1494602600f9602640f9e2031faa'
'fd7b41a9f44fc2a862cf1414'),),
(0x4919f8, bytes([0x03,0x00,0x00,0x14]), "file size cbz→b"),
]
for p in patches:
f.seek(p[0])
f.write(p[1])
print(f" ✓ Patch @ 0x{p[0]:06x}: {p[2] if len(p)>2 else ''}")
print("All 8 patches applied.")
PATCH
# ===== 第三步: 编译 dylib =====
xcrun --sdk macosx clang -dynamiclib \
-framework Foundation -framework AppKit \
-lz -fno-objc-arc -arch arm64 \
-install_name @executable_path/libhopperexport.dylib \
-o /tmp/libhopperexport.dylib /tmp/hopper_bypass.m
# ===== 第四步: 注入 dylib (替换不存在的 weak dylib) =====
install_name_tool -change \
/usr/lib/swift/libswiftSpatial.dylib \
@executable_path/libhopperexport.dylib \
"hp.app/Contents/MacOS/Hopper Disassembler"
# ===== 第五步: 安装 dylib =====
cp /tmp/libhopperexport.dylib hp.app/Contents/MacOS/
# ===== 第六步: 修正 Info.plist =====
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString 6.1.0" \
hp.app/Contents/Info.plist
# ===== 第七步: 重签名 =====
codesign --force --deep --sign - hp.app
# ===== 第八步: 启动 =====
open hp.app
# 无需 Frida。所有功能(导出、调试、保存)开箱即用。
验证清单:
- 启动后无注册对话框
- About 面板显示 “6.1.0”(无后缀)
- 加载大文件(>10MB)正常
- 使用超过 30 分钟不崩溃
- File → Produce New Executable 导出成功
- Debug → 附加进程 → 寄存器有值
- Detach 后恢复初始状态
- File → Save 保存 .hop 文件成功
- 重新打开保存的 .hop 文件正常
复盘:Hopper 做对了什么
作为安全审计的对象,Hopper v6 的防护设计值得尊重:
-
纵深防御
:不是在一个地方检查 license,而是在 entry、更新回调、通信协议深处都埋了检查
-
双层清空
:调试器不仅堵入口,还清空了初始化方法体
-
brk 混淆
:让静态分析几乎不可能
-
完整性哈希
:检查不相关代码区域的 hash,防止局部补丁
-
延迟崩溃
:不立即闪退,而是 2 秒后崩溃,增加排查难度
-
反 Hook 检测
:dlsym 与导入表对比,检测运行时 hook
每一层都不是银弹,但叠加在一起确实大大提高了逆向难度。
后记
整个过程最大的感悟是:安全是一个攻防博弈的过程。 没有绝对安全的防护,也没有不可攻破的系统。重要的是提高攻击成本,让攻击者的投入大于收获。
Hopper 的防护确实提高了很多成本 —— 如果不是有 Ghidra + Frida + LLDB 的组合拳,很多细节几乎不可能发现。特别是 sendCommandAndWaitForResponse: 里那个内联 license 检查,如果不是逐条指令追踪,我大概永远找不到调试器断开的真正原因。
最终这 1425 行代码,记录的不仅是绕过方法,更是一份详尽的防护机制分析报告。每一个坑都是一个教训,每一次闪退都是一次学习。
用矛攻盾,以攻促防。这就是安全研究的意义。
本文仅用于安全研究和教育目的。尊重软件开发者的劳动成果。
微信公众号:随心记事
-
思路:UI 层的修改为什么总是最折腾?
-
复现步骤
-
坑 1:SIGSEGV —— 一个 %@ 引发的崩溃
-
坑 2:task_for_pid 的背叛
-
坑 3:About 窗口的三重误导
-
思路:从动态注入到原生代码,最大的陷阱是什么?
-
复现步骤
-
思路:如何逆向一个私有文件格式?
-
复现步骤
-
思路:连接”建立后断开”——怎么找到隐藏的检查?
-
复现步骤
-
思路:当”连不上”时,如何缩小排查范围?
-
复现步骤
-
思路:当功能被”删除”了,如何判断后端还在?
-
复现步骤
-
思路:静态分析走不通时怎么办?
-
复现步骤
-
思路:面对多个校验点,如何找到”安全”的那一个?
-
复现步骤
-
思路:为什么从字符串入手?
-
先说说 Hopper 是什么
-
那么,故事开始
-
工具链与环境
-
序幕:一切从好奇开始
-
第一章:初探虎穴 —— Demo Flag 的发现
-
第二章:铁壁铜墙 —— 四层代码签名防护
-
第三章:brk #1 —— 异常控制流的迷宫
-
第四章:消失的调试器 —— 两层清空与完整性陷阱
-
第五章:XPC 迷宫 —— 一个偏移量引发的血案
-
第六章:隐形杀手 —— 埋在通信协议里的 License 检查
-
第七章:保存的艺术 —— 逆向一个文件格式
-
第八章:从 Frida 到原生 —— 1425 行的蜕变
-
第九章:最后的细节 —— 水印、注册窗与完美主义
-
终章:全貌
-
附录:完整复现清单(方案一:纯补丁 + Dylib)
-
复盘:Hopper 做对了什么
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:随心记事 心态与度量 心态与度量《【万字笔记】我与 Hopper disassembler v6 的五天攻防战:从第一个断点到完全接管》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论