打破真机砸壳宿命:从 SINF/SUPF 格式到白盒查表变换,剖析 fairplay-decrypt 的离线密钥派生架构
在传统的 iOS 逆向工程与安全审计流程中,“砸壳”(Decryption / App Dumping)始终是一道必须跨越的物理门槛。长期以来,无论是经典的 dumpdecrypted、Clutch,还是主流的 frida-ios-dump 与 bagbak,社区普遍遵循一条根深蒂固的技术路径:依赖一台处于越狱或调试状态的物理 iOS 设备,将目标 App 实际拉起运行,待操作系统内核与 dyld 在缺页异常中完成动态解密后,再从进程虚拟内存中转储明文代码并重构 Mach-O 文件。这种动态内存转储不仅环境准备极为繁琐,且极其容易触发高级 App 的反越狱、反注入或反调试探针,导致进程启动阶段直接闪退。
很多移动安全研究者曾据此推论:App Store 的 FairPlay DRM 必然将二进制执行段的解密过程深度锁死在 A 系列处理器的 Secure Enclave(SEP)硬件信任根内,脱离物理芯片的运行时环境,纯算法层面的“离线静态脱壳”在密码学上是不可能实现的。然而,安全研究员 Ethan Arbuckle 开源的 fairplay-decrypt 项目彻底打破了这一“真机砸壳”的技术宿命:该项目证明,只要通过极轻量的硬件调试手段一次性提取购买账号对应的 16 字节凭据,便可以在 Linux 或 macOS 桌面环境中,基于公开的 SINF、SUPF 资源与白盒密码学变换,实现 100% 离线、纯算法驱动的 App Store IPA 静态脱壳。
一、范式跃迁:动态内存转储 vs 离线密钥派生
为了清晰展现这一技术的革新性,我首先将传统的动态运行时内存转储与 fairplay-decrypt 所采用的离线静态密钥派生方案进行多维对比。
flowchart TD
subgraph Traditional["传统动态转储方案 (Frida / Clutch / Dumpdecrypted)"]
direction TB
T1["越狱物理 iOS 设备"] --> T2["启动宿主 App 进程"]
T2 --> T3["触发 Mach 缺页异常 (Page Fault)"]
T3 --> T4["内核与 FairPlay 硬件协同解密到物理内存"]
T4 --> T5["Frida / Dylib 注入目标进程空间"]
T5 --> T6["遍历虚拟内存读取 __TEXT 明文段并 Dump 写盘"]
T6 --> T7["重构 Mach-O 头部并重设 cryptid = 0"]
end
subgraph Offline["fairplay-decrypt 离线独立派生方案"]
direction TB
O1["一次性从越狱机提取 16 字节 Account Credential"] -.->|永久复用| O2["桌面环境 (macOS / Linux CLI)"]
O2 --> O3["解析 IPA 内的 SC_Info/*.sinf 授权块"]
O3 --> O4["AES-128-CBC 解密 priv 字段派生 work16 密钥"]
O4 --> O5["解析 *.supf 页表:提取每 4KB 页的 wrapped 密文与 IV"]
O5 --> O6["白盒查表变换 (transform.c) + 多轮置换 (finish)"]
O6 --> O7["计算出每个 4KB 物理页独立的 AES-128 Key"]
O7 --> O8["本地直接覆写解密 Mach-O 并将 cryptid 归零"]
end
| 评估维度 | 传统动态内存转储(Dynamic Dump) | 离线独立密钥派生(fairplay-decrypt) |
|---|---|---|
| 设备依赖度 | 每次脱壳新 App 或新版本必须连接物理越狱设备 | 仅首次提取凭据需设备,后续全量脱壳 100% 离线 |
| 反逆向干扰 | 极易受反调试、反越狱、ptrace、内存完整性探针干扰 | 零干扰:根本不启动 App 进程,所有对抗逻辑形同虚设 |
| 执行环境 | 强绑定 iOS 系统与硬件环境 | 跨平台:原生编译支持 macOS 与 Linux CLI |
| 多架构处理 | 需对 Fat Mach-O 的多架构 slice 分别拉起转储 | 自动化单步处理:按需精准解析并解密 arm64 slice |
| 处理耗时 | 单 App 启动、注入、转储通常耗时 5~15 秒 | 极速纯算力:单 App 仅需数百毫秒完成全量页面算力解密 |
从依赖脆弱的“运行时进程黑盒”跃迁到可预测、可批处理的“密码学离线解构”,这一步范式跨越的核心,正是彻底摸清了苹果 FairPlay 在物理硬件与软件算法之间的真实信任边界。
二、密码学边界澄清:硬件 SEP 究竟在防什么?
在深入代码之前,我必须先厘清长久以来笼罩在 iOS 逆向工程领域的底层误区:“App Store 下载的二进制执行文件是硬件芯片逐页解密的吗?”
答案是:绝非如此。
如果苹果在 A 系列处理器的硬件设计中,将每个 App 在虚拟内存缺页异常时的 4KB 代码段解密全都交由物理 Secure Enclave(SEP)协处理器来完成,那么每一次冷启动、动态链接库换页乃至高频的指令重载,都会带来极为沉重的跨核心 IPC 握手与上下文切换开销,这在移动设备的低功耗与高性能要求下是完全无法接受的。
苹果实际采用的是经典的多层级密钥封装与职责分离架构:
+-------------------------------------------------------------------------+
| [硬件安全边界] 物理 Secure Enclave / fairplayd 授权层 |
| 1. 验证设备身份与 Apple ID 购买授权 |
| 2. 派发解密授权材料所需的 16 字节 Account Credential |
+-------------------------------------------------------------------------+
│
一次性安全释放 (16 Bytes)
▼
+-------------------------------------------------------------------------+
| [软件与内核边界] 基于白盒密码学的纯数学派生层 |
| 1. SINF 解析:利用 Account Credential 解出工作密钥 work16 |
| 2. SUPF 解析:对每一个 4KB 页结构执行白盒矩阵变换 (BlockTransform) |
| 3. 逐页恢复独立的 AES-128 Key 与 IV,完成 __TEXT 代码段原地解密 |
+-------------------------------------------------------------------------+
换言之:
- 硬件负责“门禁授权”:SEP 确保只有登录了对应 Apple ID 且拥有设备授权的机器,才能向系统守护进程
fairplayd释放解密所需的 16 字节账户凭据; - 算法负责“批量脱壳”:一旦该 16 字节凭据就绪,后续针对 Mach-O 的全量解密完全是一套标准对称密码学与白盒查表置换算法的组合,根本不再需要硬件介入!
三、解构 App Store IPA 版权容器:SINF 与 SUPF
当我们在 App Store 购入并下载一款 App 后,IPA 解包目录中通常会包含两组极其关键的数字版权元数据文件:
- 位于应用包或 Framework 内的
SC_Info/*.sinf(Software Information); - 伴随可执行文件的
*.supf(Supplementary FairPlay,支持版本 3 与版本 5 规范)。
1. SINF 授权原子结构与 work16 密钥解密
.sinf 文件本质上遵循 QuickTime / ISO-BMFF 的 Atom 容器格式。它的顶层节点是 sinf,其子容器包含 schi(Scheme Information),其下挂载着一组四字符代码(FourCC)标识的属性字段:
name:购买者的 Apple ID 用户名或账户标识字符串;user:4 字节的数字账户 ID(Account ID);key:4 字节的许可证密钥索引;iviv:16 字节的初始化向量(IV);priv:440 或 448 字节的加密私有载荷(Private Payload)。
在 core.py 中,derive_work16 函数展示了如何利用 16 字节账户凭据解出第一级主工作密钥:
def derive_work16(credential: bytes, sinf: bytes) -> bytes:
name = _sinf_field(sinf, b"name").split(b"\0", 1)[0]
iviv = _sinf_field(sinf, b"iviv")
raw_priv = _sinf_field(sinf, b"priv")
if len(raw_priv) not in (440, 448):
raise ValueError("unsupported SINF priv-record length")
if len(raw_priv) == 440:
if raw_priv[-8:] != bytes(8):
raise ValueError("invalid SINF priv-record trailer")
raw_priv = raw_priv[:-8]
if len(credential) != 16 or len(iviv) != 16:
raise ValueError("invalid SINF priv-record dimensions")
# 1. 计算第一层 AES-CBC 解密所需的 IV:对用户名与 iviv 进行 MD5 散列
iv = hashlib.md5(name + iviv).digest()
# 2. 将 16 字节 Account Credential 与 16 字节的字符 '0' (0x30) 进行逐字节异或
derived_aes_key = _xor(credential, b"0" * 16)
# 3. 使用 AES-128-CBC 解密 priv 载荷
op = Cipher(algorithms.AES(derived_aes_key), modes.CBC(iv)).decryptor()
clear = op.update(raw_priv) + op.finalize()
# 4. 严谨校验明文结构中的苹果特征魔数 (Magic Markers)
if clear[:4] != b"itun" or clear[20:24] != b"key " or clear[44:48] != b"iviv":
raise ValueError("credential does not decode this SINF priv record")
# 5. 截取明文 offset 24..40 处的 16 字节中间密钥,与硬编码的 WORK_MASK 异或
return _xor(clear[24:40], WORK_MASK)
解密后的明文不仅包含 itun(iTunes)、key 和 iviv 等校验魔数,还包含一段至关重要的中间密钥。将其与固定的 WORK_MASK(c7626110cecd15963d850f6605f11c4b)异或之后,便得到了贯穿后续所有计算的核心凭据:16 字节的 work16 主工作密钥。
2. SUPF 逐页记录映射机制
在 Mach-O 二进制文件中,Load Command LC_ENCRYPTION_INFO_64(命令码 0x2C)声明了受保护区域的起始偏移 cryptoff 和大小 cryptsize。在 64 位 ARM 架构下,cryptsize 必须按物理页对齐(通常每页 4096 字节,即 PAGE_SIZE = 4096)。
如果一个 App 的 cryptsize 是 8MB,那么它就包含 个独立的数据页。
苹果并没有采用单一密钥流直接加密整个 8MB 区间,而是针对每一个 4KB 页分别分配了一个独立的 32 字节结构体,按顺序密集存储在 *.supf 文件中:
SUPF 物理页描述记录结构 (单页 32 Bytes)
0 16 32
+-------------------+-----------------------------------+
| wrapped (16 B) | page_iv (16 B) |
| (受保护页密钥密文)| (该 4KB 页面独立的 AES-CBC IV) |
+-------------------+-----------------------------------+
wrapped(前 16 字节):包含了派生该物理页解密 Key 所需的原始白盒素材;page_iv(后 16 字节):该特定 4KB 物理页执行标准 AES-128-CBC 解密时所需的专用向量。
四、数学与混淆的攻防:逐页密钥派生的核心管线
掌握了 work16 密钥与 SUPF 逐页记录后,最硬核的挑战在于:如何通过那 16 字节的 wrapped 密文,逆向还原出真正的 AES-128 解密 Key?
苹果为了防止逆向者直接恢复密钥派生逻辑,在此处设计了极高复杂度的白盒密码学混合算法(White-box Cryptography)。Ethan Arbuckle 将其核心拆解为四大连续阶段:
flowchart LR
A["SUPF wrapped (16B) + 绝对页索引"] --> B["构建三组 source_blocks (各 16B)"]
B --> C["C 语言白盒查表变换 BlockTransform (220KB Tables)"]
C --> D["四轮多项式置换与位折叠 (_mix_round)"]
D --> E["64位非线性终结变换 (finish)"]
E --> F["输出该 4KB 页的真实 AES-128 Key"]
1. 上下文构造与三路源块派生(source_blocks)
每一页的解密不仅取决于其自身的 wrapped 数据,还严格绑定了该页面在整个 Mach-O 虚拟内存布局中的绝对物理页编号 absolute_index:
在 core.py 中,通过位移与固定的基础上下文常数 CONTEXT_BASE(0x3E4A45B664EBA141)进行非线性位折叠(Bit-folding),构建出三个互相关联的 16 字节数据块:
def source_blocks(wrapped: bytes, absolute_index: int) -> tuple[bytes, bytes, bytes]:
context = CONTEXT_BASE ^ (absolute_index << 12)
first = _xor(wrapped[8:16], CONSTANT[:8]) + _high8_from_context_word(context)
second = _xor(wrapped, CONSTANT)
third = (int.from_bytes(CONSTANT[:8], "big") ^ (absolute_index << 12)).to_bytes(
8, "big"
) + _xor(wrapped[:8], CONSTANT[8:])
return first, second, third
这种设计确保了即便是内容完全相同的代码页,只要在 Mach-O 中的偏移位置不同,其派生上下文便彻底不同,有效防御了差分重放攻击。
2. 白盒查表变换(transform.c 与 220KB 常量表)
派生出的三个 16 字节块(first, second, third),必须与此前得到的 work16 密钥一起,送入底层预编译的高性能 C 语言程序 transform.c。
该程序加载了一个高达 223,056 字节的静态预计算查找表 transform_const_data.bin。查表逻辑包含了错综复杂的非线性代数混淆:
/* transform.c 底层查表与位混合片段 */
static uint8_t decode_output_byte0(uint8_t value, uint8_t table_mask, uint8_t value_mask, uint8_t output_mask) {
return (uint8_t)((const_data[196656 + (value ^ table_mask)] - (value ^ value_mask) - 3) ^ output_mask);
}
static uint8_t decode_output_byte1(uint8_t value, uint8_t table_mask, uint8_t value_mask, uint8_t output_mask) {
return (uint8_t)(((value ^ value_mask) + 0x78) ^ const_data[144848 + (value ^ table_mask)] ^ output_mask);
}
通过这一层白盒矩阵变换,三组输入被映射为中间向量 、、。
3. 多轮二次置换与 finish 状态终结
中间状态随后进入多轮级联函数:
def derive_page_key(
wrapped: bytes, absolute_index: int, work: bytes, block: BlockTransform
) -> bytes:
a, b, c = (block(source, work) for source in source_blocks(wrapped, absolute_index))
return finish(_xor(finish(_xor(finish(a), b)), c))
在 finish 函数内部,包含了 4 轮迭代置换 _mix_round。每一轮将 16 字节切分为 8 个 16 位整型字,利用选择子数组与常量进行二次多项式乘法扩散:
紧接着,在终结阶段结合 64 位非线性整数运算与大数位移折叠,最终凝聚为该 4KB 页面的真正 16 字节 AES 密钥!
4. 逐页解密与 Mach-O 标志复位
在获取到页密钥 key 与 SUPF 中提供的 page_iv 后,解密过程便完全回归标准 AES-128-CBC:
# 对该 4KB 页面实施原地解密
ciphertext = source[start : start + PAGE_SIZE]
op = Cipher(algorithms.AES(key), modes.CBC(record[16:32])).decryptor()
result[start : start + PAGE_SIZE] = op.update(ciphertext) + op.finalize()
遍历完成所有物理页后,解密程序在 Mach-O Header 的 Load Command 中将 cryptid 字段由 1 覆写为 0:
struct.pack_into("<I", result, info.cryptid_offset, 0)
至此,整份 Mach-O 在磁盘上彻底变为完全合法的明文二进制文件,IDA Pro、Ghidra、Hopper 等反汇编工具便可直接展开静态分析,完全免除了繁琐的调试与转储过程。
五、零注入捕获:硬件断点如何优雅提取 16 字节凭据
整个离线体系唯一需要接触真机的一步,就是提取属于当前 Apple ID 的 16 字节 Account Credential。
以往的逆向工具往往喜欢编写 MobileSubstrate / Theos 插件,或者通过 Frida 强行 dlopen 注入到目标进程中 Hook 私有方法。这种方式侵入性极强,且极易导致系统级签名崩溃。
fairplay-decrypt 在 credential-retrieval 模块中展示了一种教科书级别的系统级无痕观测架构:它完全不修改目标进程的任何代码字节,而是基于 ARM64 硬件调试寄存器与 Mach 异常端口机制实现“零注入捕获”。
sequenceDiagram
autonumber
participant CLI as 桌面端 retrieve.py
participant OBS as 越狱机 observer (Native C)
participant FPD as fairplayd (系统守护进程)
participant KRN as XNU 内核 (Mach Exception)
CLI->>OBS: USB SSH 启动 observer-uid-layer
OBS->>FPD: task_for_pid() 获取任务端口
OBS->>FPD: task_suspend() 挂起 fairplayd
OBS->>KRN: thread_set_state(ARM_DEBUG_STATE64)<br/>在 IOConnectCallStructMethod 上装载硬件断点 (bvr/bcr)
OBS->>KRN: task_swap_exception_ports()<br/>接管 EXC_MASK_BREAKPOINT 异常消息
OBS->>FPD: task_resume() 恢复守护进程
CLI->>FPD: 触发目标 App 启动,唤醒 FairPlay 授权协商
FPD->>KRN: 执行至断点,触发硬件单步调试中断
KRN->>OBS: 派发 catch_exception_raise 异常消息
OBS->>OBS: 校验选择子 selector == 90,抓取函数返回出参
OBS->>CLI: 从 IOKit 缓冲区提取 16 字节凭据并落盘 credential.bin
OBS->>FPD: 恢复原始调试寄存器与异常端口,守护进程无感运行
其底层核心实现位于 credential-retrieval/credential/observer.c:
/* 使用 ARM_DEBUG_STATE64 装载硬件断点,避免在代码段打补丁写入 brk 0x1e5 指令 */
static int set_breakpoints(ThreadWatch *watch, uint64_t call_address, uint64_t return_address) {
arm_debug_state64_t state = watch->saved;
state.__bvr[0] = call_address;
state.__bcr[0] = return_address ? 0 : USER_BREAKPOINT;
state.__bvr[1] = return_address;
state.__bcr[1] = return_address ? USER_BREAKPOINT : 0;
return replace_debug_state(watch->port, &state) == KERN_SUCCESS ? 0 : -1;
}
由于没有在 fairplayd 的代码段注入任何 0xd4200000(ARM64 BRK)指令,页面的只读属性(VM_PROT_READ | VM_PROT_EXECUTE)没有发生任何变化,这就完美规避了 XNU 内核对代码段完整性(Code Signing / Page Validation)的严苛校验。
同时,这个 16 字节凭据对于同一 Apple ID 购买的所有 App 是完全通用且永久有效的。这意味着在实际工程中,开发者只需为每个测试账号执行一次抓取,便可将生成的 credential.bin 永久部署在 CI/CD 服务器或安全审计集群中,后续所有分析流水线均可实现全自动纯离线脱壳。
六、安全架构启迪与工程思考
深入解构 fairplay-decrypt 之后,作为移动安全研发与底层架构研究者,我获得了三点极具价值的宏观认知:
1. 柯克霍夫原则的绝对胜利(Kerckhoffs's Principle)
白盒密码学(White-box Cryptography)是工业界在无法完全掌控终端运行环境时的一种无奈妥协。苹果试图通过庞大的查找表、非线性多项式乘法以及深度混淆来隐匿密钥派生算法的内部构造。然而历史一再证明:试图通过混淆算法逻辑来保障安全的方案,终究抵不过拥有充裕算力与静态反汇编工具的逆向研究者。只要算法必须在 CPU 上执行,数学逻辑与数据流图就必然能够被逐层剥离。
2. 性能与安全之间的工业妥协
为什么苹果不把 4KB 缺页解密全面封装入 SEP?
从工程架构上看,这是一个极度理性的技术权衡。对于移动操作系统而言,冷启动耗时、内存带宽与电池寿命始终是核心指标。若在操作系统内核的虚存分页路径上强塞入协处理器硬件加解密,造成的系统卡顿与能耗代价将远远超出防范应用脱壳所带来的商业收益。因此,将真正的防线收缩至“16 字节凭据的设备分发”,是现代操作系统在极端工程约束下的必然选择。
3. 移动端安全防线的重心转移
这一离线脱壳工具的诞生,也为广大的 iOS 开发者敲响了警钟:不要将任何核心安全假设建立在“App Store 官方加密可以防止客户端被静态逆向”之上。
任何依赖客户端二进制不被反编译的安全逻辑(如硬编码密钥、本地简单校验、自制风控签名),在离线脱壳与反汇编器面前均形同虚设。真正的客户端安全,必须坚决推行端云协同校验、零信任架构、动态混淆与不可伪造的设备级硬件背书(如 Apple App Attest)。