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:
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.
| 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:
| 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.
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 bytesBoth 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.
| Offset | Name | Type | Size |
|---|---|---|---|
| 0x00 | endpoint | uint32_t | 4 |
| 0x04 | unknown_04 | uint32_t | 4 |
| 0x08 | io_buffer | uint8_t * | 8 |
| 0x10 | status | int32_t | 4 |
| 0x14 | io_length | uint32_t | 4 |
| 0x18 | return_count | uint64_t | 8 |
| 0x20 | callback | void * | 8 |
| 0x28 | next | usb_device_io_request * | 8 |
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.