其实这篇文章开了好几遍都没有写出来点啥东西。

bindiff

首先取17.3.1 和修复版本 17.4 的 两个 agx-firmware 做一下bindiff,直接用 Diaphora 把两边都b000.bin 全导入进去,可以识别到带符号的函数_arm_arch_resume_uat有区别:

image-20260728205236675

这里TTBR1 / VBAR 的来源从原来内核中的休眠上下文变成从 helper 获取,同时安装固件拥有的 VBAR_EL1。看看反编译:

ios 17.4

17.3.1 or lower

这说明17.3.1的resume路径信任arm_hb_context_addr里保存的TTBR1/VBAR。只要能改写这份休眠上下文,再让GFX完成一次hibernate/resume,就可以安装自己的地址空间。

按照苹果的逻辑,这里GPU的交互是这样的:kernel -> IOGPU/AGX driver -> 片上总线 -> RTKit固件 -> GPU。这个RTKit是一个独立运行的ARM64实时系统;host通过IOGPU submit布置状态,再借framebuffer power transition驱动GFX的power thread进入hibernate/resume。后面要解决的就是两件事:先劫持power thread的exception context,再用Exception ROP修改arm_hb_context_addr。

Exception ROP

接下来是关于 ROP chain了。对,我们要在这个小的RTOS上构造出来rop chain来修改 休眠环境。居然还是类似SROP的一套异常恢复逻辑,太牛逼了。

SROP是什么?请看文章。

看完了?现在你已经学会了linux下的用户态SROP了,该开始看RTKit里运行在EL1上的Exception ROP了。

我们会构造多个848 byte 的record送到 RTOS中。每个848 byte都是一个EL1上的异常帧,被恢复时,处理程序会把寄存器、SP、ELR_EL1之类的值还原到CPU里,然后ERET到我们指定的地方开始运行。

连续执行依赖的是recordDispatch。它从当前SP恢复一帧寄存器,把SP增加0x350,再ERET到这一帧的gadget;gadget执行完以后通过X30里的地址RET回recordDispatch,于是下一轮会从新的SP继续恢复下一帧。

其中:

Offset 含义
0x00/0x08/0x10 X0/X1/X2
0xf0 X30/LR, gadget返回目标
0xf8 SP_EL0,下一条record
0x100 ELR_EL1,当前gadget地址
0x108 SPSR_EL1
0x110 CPACR_EL1
0x350 record大小

所以这里的基本循环长这样:

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

这里用了如下几种不同的gadget,下面的地址以17.3.1这份b000为准,芯片和构建变化时地址会整体漂移:

  1. 自旋
代码
__text:FFFFFF800000008C                 B               loc_FFFFFF800000008C

它会停在原地等待hibernate切换完成。恢复路径会从休眠上下文里装入新的SP,所以醒来以后直接从后面的record继续运行。

  1. 同步屏障
代码
__text:FFFFFF800000070C                 DSB             SY
__text:FFFFFF8000000710                 ISB
__text:FFFFFF8000000714                 RET

这里的DSB SY; ISB负责保证前面写入的休眠上下文和控制寄存器已经对后面的hibernate操作可见。

紧接着还有一组真正的全局TLB invalidate:

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

当前chain的第7条record选择前一组DSB SY; ISB; RET作为顺序屏障;地址空间切换则交给hibernate/resume重新安装TTBR1。

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

这个gadget非常好用,给X0/X1/X2放好参数以后,就可以完成:

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

后面的恢复线程状态、修改休眠上下文、清控制寄存器、修改PTE,全部使用这个gadget。

  1. 通用异常返回器finalReturn

image-20260728220600053

它从TPIDRRO_EL0取得线程的saved context,恢复通用寄存器、SIMD寄存器、SP_EL0、ELR_EL1和SPSR_EL1,最后ERET。第一次触发时它恢复我们伪造的root frame;最后一条record又回到这里恢复原来的power thread现场。

  1. SROP调度器recordDispatch

image-20260728220724045

它来自fast interrupt的异常恢复尾部。利用直接从handler调用之后的位置进入,所以它会把SP当作一份完整异常帧,恢复寄存器并执行SP += 0x350; ERET。

  1. trigger helper

它承担的是定位工作。固件初始化power thread时存在下面这样的代码:

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

扫描到这四条指令以后,解码ADRP + STR就可以找到保存power thread指针的全局变量。读取线程指针再加0x48,得到:

代码
triggerSlot = powerThread + 0x48;

thread+0x48保存的正是这个线程的exception context指针。固件启动时会正常执行helper;利用阶段只借这段指令找到triggerSlot。

  1. stateDispatch

image-20260728220820635

这是一段给arm_arch_hibernate_uat准备好X0/X1后直接跳过去的stub。第一次进入时它按正常流程保存GFX当前的UAT上下文;后面的record再修改这份上下文里的TTBR1、TPIDRRO_EL0和SP。

Trigger

到这里还缺一个问题:第一条SROP是怎么跑起来的?

利用先准备一个root exception frame,然后把它放在SPTM拥有的一张空白页里:

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

接下来临时修改几个IOGPU/AGX对象字段,让selector 0x1a处理一个特制submit:

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

AGX处理submit时会在triggerSlot+1做一次非对齐32-bit写。最终线程上下文指针变成:

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

这里刻意保留了指针最低8 bit和最高24 bit。root frame的最低字节又取自oldContext,所以拼出来的64-bit值正好等于recordKernelAddress。selector 0x1a到这里完成的是arm,host会一直读triggerSlot,确认它已经指向root frame以后再进入下一步。

真正的执行开关来自framebuffer power-off:

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

这个调用会让GFX的power thread变成runnable。RTKit调度线程时执行SVC #0,scheduler读取新线程的thread+0x48并写入TPIDRRO_EL0,随后走通用exception-return。于是第一次pivot是:

代码
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负责替换saved context,framebuffer selector 0x0c负责唤醒power thread。第一次“Exception ROP”来自RTKit调度器处理SVC后的线程上下文恢复。

构造链

root frame后面一共有十条正常record,顺序如下:

Record X0/X1 ELR_EL1 作用
root fixedRange / 0 recordReturn + 4 建立SP和X30,进入recordDispatch
1 triggerSlot / original context recordReturn 先恢复power thread原来的saved context指针
2 由stub加载 stateDispatch 正常调用arm_arch_hibernate_uat,保存当前UAT状态
3 stateContext + 0x108 / fake L1 PA recordReturn 把伪造页表根写进休眠上下文的TTBR1字段
4 stateContext + 0xd8 / original context recordReturn 设置恢复后的TPIDRRO_EL0
5 stateContext + 0xc8 / record 9 recordReturn 设置恢复后的SP,让唤醒从PTE写入record继续
6 controlFunction + 8 / 0 recordReturn 清理L2C/GXF控制状态
7 - barrier DSB SY; ISB,同步上面的上下文写入
8 controlData / 0xffffffff80000000 recordReturn,X30=selfBranch 请求进入hibernate并自旋等待
9 aliasControlTargetKva / desired PTE recordReturn 醒来后在伪造TTBR1下修改受保护PTE
10 0 / 0 finalReturn 从TPIDRRO_EL0恢复原power thread

第2条record先让固件自己生成一份格式完全正确的休眠上下文,后面三条只改其中最关键的字段:

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

第8条record写控制寄存器并进入自旋。GFX再次醒来时,arm_arch_resume_uat会从这份上下文恢复系统寄存器,于是TTBR1换成伪造页表,TPIDRRO_EL0重新指向原power thread现场,SP则直接落到第9条record。

修改页表

前面说“伪造TTBR1”,具体伪造的是什么?

host会先申请四张普通内核页:

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

fake L1复制了一份GFX原来的root template,再挂入我们自己的L2。这个地址空间至少保证两个地址可用:

  1. 第9、10条record所在的SPTM空白页;
  2. aliasControlTargetKva,它映射到AP页表中那个受SPTM/PPL保护的PTE槽。

第9条record醒来时已经运行在这套TTBR1下面,所以普通的:

代码
STR X1, [X0,X2]

现在可以越过AP侧的页表写保护,把aliasControlReplacementPte写到aliasControlTargetKva。这次修改会让page 2的内核虚拟地址指向“保存page 3 leaf PTE的那张页表页”。于是:

代码
page 2 KVA + page 3 PTE index
    -> page 3 的真实leaf PTE

这就是后面一直在用的自引用PTE/alias window。完成这一次受保护写以后,host可以直接改page 3的leaf PTE,把page 3反复指向任意物理页:

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

到这里,复杂的GFX SROP只负责安装一次alias control PTE。后面的任意物理写全部复用这个alias window,成本就只剩改leaf PTE、等待映射生效、写入和读回。

GFX本身拥有很宽的物理内存访问范围,Apple把安全边界放在GFX μPPL和UAT页表里。伪造TTBR1让RTKit获得了指向AP页表物理页的映射,第9条record再从GFX EL1写AP页表,于是AP侧的SPTM/PPL检查整个被跨了过去。

刷新和恢复

这里有三种很容易混在一起的“刷新”:

第一种是record 7里的DSB SY; ISB。它负责内存和指令顺序,保证休眠上下文、L2C/GXF状态都已经写完,再执行hibernate控制写。

第二种发生在GFX resume。17.3.1及以前的arm_arch_resume_uat会把休眠上下文中的TTBR0/TTBR1、TCR、MAIR、VBAR重新写回系统寄存器,后面再执行DSB SY; ISB。因此醒来后的地址翻译直接使用fake TTBR1,第9条record才能访问aliasControlTargetKva。

第三种是安装alias以后反复修改page 3 leaf PTE。恢复出来的Coruna调用序列依次执行写PTE、让出10ms、通过真实目标地址读回,成功条件就是readback已经落到新的物理页。

第9条record写完受保护PTE以后,record 10进入finalReturn。此时TPIDRRO_EL0已经在休眠上下文里恢复为原power thread的saved context,所以固件会继续正常执行。host看到目标PTE变成期望值以后,再发送framebuffer power-on:

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

整个执行边界就是:

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

释放primitive时还要做相反的事情:先把page 3 leaf PTE恢复成原值,再重新跑一次SPTM transaction,把aliasControlPte恢复成最初的descriptor。这样power thread、AP页表和GFX UAT都会回到原来的状态。

What's More?

1. Further Exploit Possibility?

我们在17.4可以看到,resume路径开始从firmware boot-patched的private数据取得TTBR1,同时安装固件自己的VBAR_EL1。攻击者能控制的hibernate context继续保留,arm_arch_resume_uat已经跳过其中的TTBR0/TTBR1/VBAR字段,所以Rocket这条路径到这里失效。

有意思的是,这套SROP基础设施一直到18.0.1才因为PAC指针在RTKit的引入被缓解。既然recordDispatch、EL1 store和其他gadget还在,能不能绕过resume的数据来源,直接走别的TTBR setter?

update 8.2: 经过几天的研究:

我们能够直接走ROP跳到ttbr1 set的位置,但是运行 msr ttbr1_el1 ×30 这样的指令会直接出错。

在RTKit中有两种不同的权限,只有在GENTER #1 之后(进入GXF高权限模式)后,才可以修改页表寄存器。而GENTER / GEXIT的逻辑如下:

代码
0x000000 GENTER #1
0x000004 RET

在调用GENTER之后,pc会跳转到GENTER的处理函数中,这个函数负责dispatch + gexit。而这个函数是在RTKit初始化“启动时”写入的寄存器,我们并没有办法在启动后进行修改。而在调用arm_hibrate_xxx之后,并在恢复后RTKit才会自动进入高权限模式并恢复寄存器内容。

2. Related exploits

最后在调查的时候我发现这一套逻辑实际上和 asahi lina 做的 agx-exploit 非常相似。都是做了IOGPU -> GFX ROP的逻辑从而做到可控页表和物理内存。