Honestly, I started this article several times and never managed to write anything useful.

BinDiff

First, take the two AGX firmware images from iOS 17.3.1 and the fixed release, iOS 17.4. Import both b000.bin files into Diaphora. It immediately identifies a difference in the symbolized function _arm_arch_resume_uat:

image-20260728205236675

The source of TTBR1 and VBAR changed. The old version restored them from the hibernation context. The new version obtains them from a helper and installs a firmware-owned VBAR_EL1. Here is the decompilation:

iOS 17.4

iOS 17.3.1 or earlier

This tells us that the iOS 17.3.1 resume path trusts the TTBR1 and VBAR values saved in arm_hb_context_addr. If we can modify that hibernation context and make GFX complete one hibernate/resume cycle, we can install our own address space.

Apple's GPU stack looks roughly like this:

代码
kernel -> IOGPU/AGX driver -> on-chip bus -> RTKit firmware -> GPU

RTKit is an independent ARM64 real-time system. The host arranges the state through an IOGPU submission, then uses a framebuffer power transition to drive the GFX power thread through hibernate/resume. That leaves two problems to solve: hijack the power thread's exception context, then use SROP to modify arm_hb_context_addr.

SROP

Now for the ROP chain. Yes, we are building a ROP chain inside this tiny RTOS to modify its hibernation state. And somehow it is SROP. That rules.

What is SROP? Read this article.

Done? Great. You now understand userland SROP on Linux, so it is time for SROP running at EL1 inside RTKit.

We construct multiple 848-byte records and place them in memory accessible to the RTOS. Each 848-byte block is an EL1 exception frame. Restoring one of these frames loads the general-purpose registers, SP, ELR_EL1, and the other architectural state back into the CPU, then ERETs to an address we control.

Continuous execution is provided by recordDispatch. It restores one frame from the current SP, advances SP by 0x350, and ERETs into that frame's gadget. When the gadget finishes, it uses the address in X30 to RET back into recordDispatch, which consumes the next frame from the new SP.

The important fields are:

Offset Meaning
0x00/0x08/0x10 X0/X1/X2
0xf0 X30/LR, the gadget's return target
0xf8 SP_EL0, the next record
0x100 ELR_EL1, the current gadget
0x108 SPSR_EL1
0x110 CPACR_EL1
0x350 Record size

The basic loop is:

代码
recordDispatch
    -> restore one 0x350-byte frame
    -> SP += 0x350
    -> ERET to gadget
    -> gadget RET through X30
    -> recordDispatch

The chain uses several different gadgets. The addresses below come from this iOS 17.3.1 b000; different chips and builds move them around.

  1. Spin
代码
__text:FFFFFF800000008C                 B               loc_FFFFFF800000008C

This keeps the core spinning while the hibernation transition completes. The resume path loads a new SP from the hibernation context, so execution wakes up directly on a later record.

  1. Synchronization barrier
代码
__text:FFFFFF800000070C                 DSB             SY
__text:FFFFFF8000000710                 ISB
__text:FFFFFF8000000714                 RET

The DSB SY; ISB pair makes the earlier hibernation-context and control-register writes visible before the chain requests hibernation.

Immediately after it is a real global TLB invalidate gadget:

代码
__text:FFFFFF8000000718                 TLBI            VMALLE1IS
__text:FFFFFF800000071C                 DSB             SY
__text:FFFFFF8000000720                 ISB
__text:FFFFFF8000000724                 RET

Record 7 in the current chain uses the first DSB SY; ISB; RET sequence as an ordering barrier. The hibernate/resume path handles the address-space switch by reinstalling TTBR1.

  1. EL1 write
代码
__text:FFFFFF8000000744                 STR             X1, [X0,X2]
__text:FFFFFF8000000748                 RET

After setting X0, X1, and X2, this gadget gives us:

代码
*(uint64_t *)(x0 + x2) = x1;

The chain uses it to restore thread state, modify the hibernation context, clear control state, and finally modify a PTE.

  1. Generic exception return: finalReturn

image-20260728220600053

This routine obtains a thread's saved context from TPIDRRO_EL0, restores its general-purpose registers, SIMD registers, SP_EL0, ELR_EL1, and SPSR_EL1, and ends with ERET. On the first trigger it restores our forged root frame. The last record returns here again to restore the original power-thread context.

  1. SROP dispatcher: recordDispatch

image-20260728220724045

This entry point sits in the fast-interrupt restore epilogue. The exploit enters immediately after the handler call, so the code treats SP as a complete exception frame, restores it, and executes SP += 0x350; ERET.

  1. Trigger helper

This is a locator. Firmware initialization creates the power thread with a sequence like:

代码
MOV     W5, #0x3e
BL      thread_create
ADRP    X19, power_thread_global
STR     X0, [X19,#offset]

Once this four-instruction pattern has been found, decoding ADRP + STR reveals the global variable holding the power-thread pointer. Reading the pointer and adding 0x48 gives:

代码
triggerSlot = powerThread + 0x48;

thread+0x48 stores the thread's exception-context pointer. Firmware executes the helper during normal startup. The exploit uses its instruction sequence to locate triggerSlot.

  1. stateDispatch

image-20260728220820635

This stub prepares X0 and X1 for arm_arch_hibernate_uat and branches into it. The first call follows the normal path and saves the current GFX UAT state. Later records modify the TTBR1, TPIDRRO_EL0, and SP fields inside the resulting context.

Trigger

One question remains: how does the first SROP frame start running?

The exploit first prepares a root exception frame in an empty SPTM-owned page:

代码
recordKernelAddress =
    emptyKernelAddress
    + (uint8_t)originalPowerContext
    + 0xc00;
 
encodedRecordAddress = recordKernelAddress >> 8;

It then temporarily patches several IOGPU/AGX object fields and submits a crafted command through selector 0x1a:

代码
submitAddress = triggerSlot + 1 - stateAddressBias;
stateLength = encodedRecordAddress - 256;
submitHeader = 256;

While processing the submission, AGX performs an unaligned 32-bit write at triggerSlot+1. The resulting thread-context pointer is:

代码
newContext =
    (oldContext & 0xffffff00000000ff)
    | ((uint64_t)encodedRecordAddress << 8);

The write preserves the pointer's lowest eight bits and highest 24 bits. The root frame was deliberately placed at an address whose low byte matches oldContext, so the reconstructed 64-bit value is exactly recordKernelAddress. Selector 0x1a has now armed the trigger. The host polls triggerSlot until it observes the forged root-frame pointer.

Execution starts with a framebuffer power-off request:

代码
IOConnectCallScalarMethod(framebufferConnection, 0x0c, { 0 });

This makes the GFX power thread runnable. RTKit performs an SVC #0 during scheduling, the scheduler loads the selected thread's thread+0x48 into TPIDRRO_EL0, and execution falls into the generic exception-return path. The first pivot is:

代码
power thread scheduled
    -> TPIDRRO_EL0 = forged root frame
    -> finalReturn restores root frame
    -> ELR_EL1 = recordReturn + 4       // RET
    -> X30 = recordDispatch
    -> SP_EL0 = first normal record
    -> ERET -> RET -> recordDispatch

Selector 0x1a replaces the saved-context pointer. Framebuffer selector 0x0c wakes the power thread. The first "Exception ROP" therefore comes from RTKit's thread-context restore after the scheduler handles the SVC.

Building the Chain

Ten normal records follow the root frame:

Record X0/X1 ELR_EL1 Purpose
root fixedRange / 0 recordReturn + 4 Establish SP and X30, then enter recordDispatch
1 triggerSlot / original context recordReturn Restore the power thread's original saved-context pointer
2 Loaded by the stub stateDispatch Call arm_arch_hibernate_uat normally and save the current UAT state
3 stateContext + 0x108 / fake L1 PA recordReturn Write the forged page-table root into the saved TTBR1 field
4 stateContext + 0xd8 / original context recordReturn Set the post-resume TPIDRRO_EL0
5 stateContext + 0xc8 / record 9 recordReturn Set the post-resume SP so execution continues at the PTE-write record
6 controlFunction + 8 / 0 recordReturn Clear the L2C/GXF control state
7 - barrier Execute DSB SY; ISB to order the context writes
8 controlData / 0xffffffff80000000 recordReturn, X30=selfBranch Request hibernation and spin
9 aliasControlTargetKva / desired PTE recordReturn Modify the protected PTE after waking under the forged TTBR1
10 0 / 0 finalReturn Restore the original power thread through TPIDRRO_EL0

Record 2 asks the firmware to generate a correctly formatted hibernation context. The next three records only replace the critical fields:

代码
stateContext + 0x108 = TTBR1_EL1
stateContext + 0x0d8 = TPIDRRO_EL0
stateContext + 0x0c8 = SP

Record 8 writes the hibernation control value and enters the spin loop. When GFX wakes again, arm_arch_resume_uat restores its system registers from this context. TTBR1 becomes the forged page table, TPIDRRO_EL0 points back to the original power-thread context, and SP lands directly on record 9.

Modifying the Page Tables

What does the forged TTBR1 actually contain?

The host first allocates four ordinary kernel pages:

代码
page 0: fake L1 root
page 1: fake L2 table
page 2: PTE storage virtual page
page 3: writable alias virtual page

The fake L1 starts from a copy of the original GFX root template and gains a new entry pointing at our L2. This address space needs to keep at least two locations accessible:

  1. The SPTM-owned empty page containing records 9 and 10.
  2. aliasControlTargetKva, mapped to the AP page-table slot protected by SPTM/PPL.

Record 9 wakes up under this TTBR1, so the ordinary:

代码
STR X1, [X0,X2]

can now write aliasControlReplacementPte through aliasControlTargetKva, crossing the AP-side page-table protection. This replacement makes page 2's kernel virtual address point at the page-table page containing page 3's leaf PTE:

代码
page 2 KVA + page 3 PTE index
    -> page 3's real leaf PTE

That is the self-referential PTE/alias window used by the rest of the primitive. Once this protected write has completed, the host can directly modify page 3's leaf PTE and repeatedly retarget page 3 to arbitrary physical pages:

代码
kernel virtual address
    -> translate to physical page
    -> replace page 3 leaf PTE
    -> write through page 3 KVA
    -> read the original kernel address back for verification

The expensive GFX SROP sequence only installs the alias-control PTE once. Every later physical write reuses the alias window and only needs to update the leaf PTE, wait for the mapping to take effect, perform the write, and read the real target back.

GFX has a broad view of physical memory, with its security boundary implemented by the GFX μPPL and UAT page tables. The forged TTBR1 gives RTKit a mapping of the AP page-table page, and record 9 writes the AP PTE from GFX EL1. That crosses the AP-side SPTM/PPL enforcement boundary.

Synchronization and Restoration

Three different synchronization boundaries are easy to mix up here.

The first is record 7's DSB SY; ISB. It orders memory and instruction execution so the hibernation context and L2C/GXF state have been committed before the hibernation-control write.

The second happens during GFX resume. On iOS 17.3.1 and earlier, arm_arch_resume_uat writes TTBR0/TTBR1, TCR, MAIR, and VBAR back from the hibernation context, then executes another DSB SY; ISB. Address translation after wake therefore uses the forged TTBR1, allowing record 9 to access aliasControlTargetKva.

The third applies to repeated page 3 leaf-PTE retargets after the alias has been installed. The recovered Coruna sequence writes the PTE, yields for 10 ms, and reads through the real target address. Success means that the readback resolves to the newly selected physical page.

After record 9 writes the protected PTE, record 10 enters finalReturn. The resumed hibernation context has already restored TPIDRRO_EL0 to the original power-thread saved context, so firmware continues normal execution. Once the host observes the expected PTE value, it sends a framebuffer power-on request:

代码
IOConnectCallScalarMethod(framebufferConnection, 0x0c, { 1 });

The complete execution boundary is:

代码
selector 0x1a
    -> arm forged thread context
framebuffer OFF
    -> execute SROP
    -> hibernate with forged TTBR1
    -> wake at record 9
    -> install alias-control PTE
    -> restore power thread
framebuffer ON
    -> host keeps alias window for arbitrary writes

Releasing the primitive requires the reverse operation: restore page 3's original leaf PTE, then run another SPTM transaction to restore the original aliasControlPte descriptor. That returns the power thread, AP page tables, and GFX UAT state to their original configuration.

What's More?

In iOS 17.4, the resume path starts obtaining TTBR1 from firmware boot-patched private data and installs the firmware's own VBAR_EL1. The attacker-controlled hibernation context remains, but arm_arch_resume_uat skips its TTBR0, TTBR1, and VBAR fields, killing the Rocket path described above.

The interesting part is that the underlying SROP machinery survives until iOS 17.6. With recordDispatch, the EL1 store, and the other gadgets still present, could the chain bypass the resume data source and reach a different TTBR setter directly?

For example, if X0 and X1 are prepared before the call shown below, could a previous SROP frame jump here and continue into the next stage?