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

checkm8: where a stale DFU pointer meets USB callbacks

Follow the DFU buffer, USB transfer state, and reset cleanup in t8015 SecureROM. Use a lifetime model and request layout to separate dangling references, memory reuse, and callback control despite valid transfer lengths.

date[31:24]
read[23:16]
6 min
cat[15:8]
Mobile
Contents
  1. 0x00Bound the boot-chain claim
  2. 0x01Match the ROM mapping to bytes and version
  3. 0x02What the normal path cleans up
  4. 0x03An incomplete transfer changes lifetime
  5. 0x04The request object connects data writes to control flow
  6. 0x05Reset callbacks and the order of verification
  7. 0x06References

checkm8 is fundamentally a lifetime mismatch, not an oversized copy. DFU can release a buffer while the USB layer still retains its address. A valid length establishes a bound on an earlier allocation; it does not establish that the object is still alive.

The focus here is the historical t8015 SecureROM associated with iPhone 8 / X: where the pointer is saved, where the object is freed, and who uses the pointer next. Absolute addresses refer to iBoot-3332.0.0.1.23, not a cross-device symbol table.

Bound the boot-chain claim

The two simplified boot paths should remain distinct. Apple's description includes LLB for A9 and earlier A-series chips; later generations connect Boot ROM to iBoot. These diagrams show only the application processor's main stages. The OS node summarizes startup after the kernel, rather than naming a separate signed object.

Simplified path for A9 and earlier:

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

Simplified path for later generations:

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

Boot ROM is the hardware root of trust laid down during chip fabrication. Its early position makes a flaw significant to subsequent boot verification, but other isolation boundaries remain separate questions. Secure Enclave has its own secure boot process. Code execution during boot does not, by itself, prove access to decrypted user data or persistent execution. Apple boot process

axi0mX published checkm8, and the project selects configurations by chip and ROM version. Similar device names do not establish identical function addresses, globals, or memory layouts. Official ipwndfu project

Match the ROM mapping to bytes and version

This image uses little-endian AArch64, a base of 0x100000000, and an inclusive end of 0x1000fffff: a 0x100000-byte mapping. The first two entry instructions are:

SecureROM entry excerpt
0x10000000000 00 00 90adrpx0, 0x100000000
0x10000000400 00 00 91addx0, x0, #0

Check the entry bytes against branches and data references, rather than judging whether the decompiler emits plausible C. Function names may be analyst-assigned labels, which carry different evidentiary weight from vendor symbols.

Analysis anchors in the t8015 image5 rows
Address Functional label
0x10000B24C USB core
0x10000BCCC DFU request handler
0x10000BEF4 DFU data handler
0x10000B84C USB core reset
0x100004A44 USB driver reset

The project's device_platform.py lists this t8015 ROM version, base, and size. In checkm8.py, t8015_handle_interface_request also points to 0x10000BCCC. This cross-check establishes the configuration and one important anchor; it does not revalidate every function body in the table. Platform configuration, t8015 configuration

What the normal path cleans up

DFU initialization allocates a 2048-byte buffer and registers handlers with the USB core. The normal transfer can be summarized in five states:

Object and state relationships during a normal transfer5 rows
Step Action Required relationship
1 Check the request length wLength fits the buffer capacity
2 Save the buffer pointer and expected length The pointer references the current DFU object
3 Receive data The destination object is still alive
4 Check the length and deliver the content Actual length agrees with transfer state
5 Clear transfer pointer and length The USB layer retains no stale reference

A test that always completes setup, data, and normal teardown repeatedly establishes that step five exists. It says nothing about an interrupted path. Termination, failure, reset, and reinitialization are separate exits: the module freeing the object and the module retaining its address must agree at each one.

An incomplete transfer changes lifetime

The failure path leaves normal data processing after transfer state has been established. DFU teardown frees the old buffer and initialization starts again, while the USB layer's saved pointer remains unchanged. A later access through that pointer meets the conditions for use-after-free (UAF).

The following is an object-generation model. Each initialization creates a new identity, and saved represents a reference retained by another module. It sends no USB requests and models neither the real allocator nor address reuse or cancellation timing.

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")
Lifetime-model and layout checks
$ python checkm8_lifecycle_model.py
PASS: normal=False; interrupted=True
PASS: request layout = 0x30 bytes

Both paths use a valid 64-byte length. The decisive difference is whether saved is revoked before restart. Repeating wLength <= 2048 would miss the same issue: the reference needs invalidation, not just another bounds check.

The model establishes a dangling reference under that state transition. Actual subsequent access on a device, and reuse of the address by a particular object, require separate evidence.

The request object connects data writes to control flow

The replacement object of interest is usb_device_io_request. Its recovered 64-bit layout spans 48 bytes. This view shows field ranges, not invented runtime byte values.

64-bit USB request layoutarm64 · LE
OffsetNameTypeSize
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 bytes)

unknown_04 explicitly leaves one field's semantics unresolved. next is a linked-list role label, not a confirmed vendor field name. Although callback was initially typed as void *, its function-pointer role must be established at the indirect call: which register supplies the argument, whether it points to the current request, and whether the node is accessed again afterward.

If a new request occupies the former buffer address, a write through the stale reference can affect those fields. Their roles differ: io_buffer directs data, callback directs control flow, and next directs later traversal. Establishing memory reuse alone does not establish control over all three.

Reset callbacks and the order of verification

Reset cleanup involves pending-request traversal and callbacks, providing a path from corrupted data to later control flow. This diagram shows a chain of necessary conditions, not a fully captured device trace.

flowchart TD
  A["USB retains the old pointer"] --> B["DFU frees the old buffer"]
  B --> C["A new request reuses the address"]
  C --> D["A stale-reference write changes fields"]
  D --> E["Reset reaches request traversal"]
  E --> F["Read callback and call indirectly"]
  F --> G["Follow next to another node"]

Controlling one callback target and preserving a traversable request chain are different achievements. Describing this simply as stack-return-address ROP obscures that the first control transfer comes from an object callback. Later gadget composition, register state, and cache synchronization require their own validation.

Work outward from the earliest divergence:

  • State: identify cleanup branches taken by normal and interrupted requests.
  • Object: identify which retained references are invalidated on free.
  • Allocation: establish what later occupies the same address.
  • Data: record the fields, offsets, and widths actually affected.
  • Control flow: establish that reset reaches the node and indirect call.
  • Outcome: distinguish a crash, one controlled callback, and complete subsequent execution.

The reusable audit rule is simple: shared pointers require shared invalidation rules. Complete cleanup on the normal path does not compensate for a missing reference revocation during an exceptional restart.

References

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