~/posts/mobile/checkm8-dfu-object-lifetime.md

checkm8:DFU 旧指针与 USB 回调的交点

沿 t8015 SecureROM 的 DFU 缓冲区、USB 请求状态和 reset 清理路径,解释长度合法时仍会出现的生命周期错误,并以对象代际模型和请求字段布局区分悬空引用、内存重用与回调控制。

date[31:24]
read[23:16]
6 分钟
cat[15:8]
移动端
目录
  1. 0x00先明确启动信任链的边界
  2. 0x01ROM 映射要能被字节与版本共同解释
  3. 0x02正常路径清理了什么
  4. 0x03半次传输改变的是生命周期
  5. 0x04请求对象把数据写入连接到控制流
  6. 0x05reset 回调链与复核顺序
  7. 0x06参考资料

checkm8 的核心不是复制了过长的数据,而是 DFU 与 USB 两侧对同一块内存的生命周期理解不一致:一侧已经释放对象,另一侧仍保存它的地址。长度合法,只能说明访问没有超过先前约定的容量,并不保证对象此时仍然存活。

下面聚焦 iPhone 8 / X 所对应的 t8015 历史 SecureROM,追踪保存指针、释放对象和再次使用指针这三个节点。分析中的绝对地址绑定 iBoot-3332.0.0.1.23,不作为其他芯片的通用符号表。

先明确启动信任链的边界

两种简化链路需要分开理解。Apple 的说明中,A9 及更早的 A 系列芯片还包含 LLB;后续代际由 Boot ROM 衔接 iBoot。以下只画出应用处理器的主要启动阶段,OS 节点概括内核之后的系统启动,而不是一种单独的签名对象。

A9 及更早的简化链路:

flowchart LR
  R1["Boot ROM"] --> L["LLB"] --> I1["iBoot"] --> K1["Kernel"] --> O1["OS"]

后续代际的简化链路:

flowchart LR
  R2["Boot ROM"] --> I2["iBoot"] --> K2["Kernel"] --> O2["OS"]

ROM 是芯片制造时固化的硬件信任根,位置靠前意味着它的缺陷会影响后续启动验证;这不等于其他隔离边界一并消失。Secure Enclave 有独立的安全启动过程,启动阶段代码执行也不自动证明获得用户数据解密能力或永久驻留能力。Apple 启动过程

checkm8 由 axi0mX 公开,其项目按芯片和 ROM 版本选择配置。这一点在分析上很重要:设备名称相近,并不保证函数地址、全局变量和内存布局相同。ipwndfu 官方项目

ROM 映射要能被字节与版本共同解释

本例按 AArch64、小端序加载,基址为 0x100000000,映射范围到 0x1000fffff,共 0x100000 字节。入口的前两条指令如下:

SecureROM 入口节选
0x10000000000 00 00 90adrpx0, 0x100000000
0x10000000400 00 00 91addx0, x0, #0

入口处的指令与跳转、数据引用应一起检查,而不是只看反编译输出是否像 C。函数名也可能是分析者添加的标签;它们与厂商符号有不同的证据强度。

t8015 样本中的分析锚点5 行
地址 功能标签
0x10000B24C USB 主模块
0x10000BCCC DFU 请求处理
0x10000BEF4 DFU 数据处理
0x10000B84C USB 主模块 reset
0x100004A44 USB 驱动 reset

项目的 device_platform.py 给出 t8015 的上述 ROM 版本、基址和大小;checkm8.py 中的 t8015_handle_interface_request 也指向 0x10000BCCC。这是版本与一个关键锚点的交叉核对,不等于重新验证了表中每个函数体。平台配置、t8015 配置

正常路径清理了什么

DFU 初始化分配 2048 字节缓冲区,并向 USB 主模块注册处理逻辑。正常传输可以压缩为五个状态:

正常传输中的对象与状态5 行
步骤 动作 必须成立的关系
1 检查请求长度 wLength 不超过缓冲区容量
2 保存缓冲区指针与预期长度 指针引用当前 DFU 对象
3 接收数据 目标对象仍存活
4 DFU 核对长度并交付内容 实际长度与请求状态一致
5 清理传输指针与长度 USB 侧不再保留旧引用

只测试 setup、完整数据阶段和正常收尾,会反复证明第五步存在,却验证不到异常路径是否也执行它。审计应把终止、失败、reset 和重新初始化视为独立出口:释放对象的模块与保存引用的模块,必须对这些出口共同收敛。

半次传输改变的是生命周期

缺陷路径在请求状态已经建立之后离开正常数据处理流程。DFU 结束时释放旧缓冲区,随后又回到初始化,而 USB 侧保存的指针没有同步失效。此时继续沿旧指针访问,就进入释放后使用(use-after-free,UAF)的条件。

下面是对象代际模型:每次初始化产生一个新的身份,saved 代表外部模块保存的引用。它不发送 USB 请求,也不模拟真实分配器、地址重用或取消时序。

checkm8_lifecycle_model.pypython
class TransferModel:
    def __init__(self):
        self.generation = 1
        self.live = {1}
        self.saved = None

    def begin(self, size):
        if not 0 <= size <= 2048:
            raise ValueError("invalid transfer length")
        self.saved = self.generation

    def complete(self):
        self.saved = None

    def restart_without_cleanup(self):
        self.live.remove(self.generation)
        self.generation += 1
        self.live.add(self.generation)

    def dangling(self):
        return self.saved is not None and self.saved not in self.live

normal = TransferModel()
normal.begin(64)
normal.complete()
normal.restart_without_cleanup()
assert not normal.dangling()

broken = TransferModel()
broken.begin(64)
broken.restart_without_cleanup()
assert broken.dangling()
print("PASS: normal=False; interrupted=True")

fields = [(0x00, 4), (0x04, 4), (0x08, 8), (0x10, 4),
          (0x14, 4), (0x18, 8), (0x20, 8), (0x28, 8)]
assert all(off + size == fields[i + 1][0]
           for i, (off, size) in enumerate(fields[:-1]))
assert fields[-1][0] + fields[-1][1] == 0x30
print("PASS: request layout = 0x30 bytes")
模型与布局检查
$ python checkm8_lifecycle_model.py
PASS: normal=False; interrupted=True
PASS: request layout = 0x30 bytes

两条路径都使用合法的 64 字节长度,唯一关键差别是重启前是否撤销 saved。所以再加一遍 wLength <= 2048 检查仍未触及根因:需要失效的是引用,而不是仅仅重查长度。

模型的结果只说明这种状态组合会形成悬空引用。真实设备上的后续访问是否发生、地址是否被特定对象重用,需要各自独立的证据。

请求对象把数据写入连接到控制流

本例关注的替代对象是 usb_device_io_request。64 位布局恢复为 48 字节;下图展示的是字段范围,不含伪造的运行时字节值。

USB 请求对象的 64 位布局arm64 · LE
偏移名称类型大小
0x00endpointuint32_t4
0x04unknown_04uint32_t4
0x08io_bufferuint8_t *8
0x10statusint32_t4
0x14io_lengthuint32_t4
0x18return_countuint64_t8
0x20callbackvoid *8
0x28nextusb_device_io_request *8
sizeof(struct usb_device_io_request) = 0x30(48 字节)

unknown_04 保留语义未定状态;next 是链表角色标签,而非已确认的厂商字段名。callback 在类型恢复阶段标为 void *,其函数指针角色需要由间接调用点证明:传参寄存器是什么、是否传入当前请求、调用后是否继续访问节点,都应回到指令层核对。

若新请求对象确实占据旧缓冲区的地址,旧引用写入就可能影响这些字段。关键区别是:io_buffer 描述数据去向,callback 描述控制转移,next 描述后续遍历。仅仅证明内存被重用,尚未证明后三者可控。

reset 回调链与复核顺序

reset 清理挂起请求时会涉及请求遍历和回调,因此需要把数据损坏与后续控制流连接起来。下图是条件链,不是一次已经采集完成的设备执行轨迹。

flowchart TD
  A["USB 侧保留旧指针"] --> B["DFU 释放旧缓冲区"]
  B --> C["新请求对象重用同一地址"]
  C --> D["旧引用写入影响请求字段"]
  D --> E["reset 到达请求遍历"]
  E --> F["读取 callback 并间接调用"]
  F --> G["依据 next 继续处理节点"]

控制一次回调目标与维持一条可继续遍历的请求链,是两个不同成果。把这里概括成传统“覆盖栈返回地址的 ROP”会掩盖第一层控制来自对象回调;更后的 gadget 组合、寄存器和缓存同步条件,还需另行验证。

复核最好按最早分歧的位置排序,避免越过未证实的环节:

  • 状态层:正常与中断请求分别走过哪些清理分支。
  • 对象层:释放时,哪些保存引用的位置同步失效。
  • 分配层:同一地址后来由什么对象占据。
  • 数据层:写入实际影响哪些字段、偏移和宽度。
  • 控制流层:reset 是否取到该节点并执行间接调用。
  • 结果层:区分崩溃、单次回调控制和完整后续执行。

可迁移的审计原则是:跨模块保存指针,就要跨模块管理引用失效。 正常路径再完整,也抵消不了异常重启漏掉的一次清理。

参考资料

NORMAL~/posts/mobile/checkm8-dfu-object-lifetime.md§--
0%zh-CN