~/posts/mobile/ios-ppl-entry-state-page-ownership.md

iOS PPL:入口状态与物理页所有权

沿 ARM64 内核的启动、服务派发与页交接路径,区分每 CPU 状态、物理页所有权和权限编码,核对 16 KiB 步长、原子置位以及异常恢复边界。

date[31:24]
read[23:16]
7 分钟
cat[15:8]
移动端
目录
  1. 0x00先算清结构尺寸和页粒度
  2. 0x01所有权检查要覆盖输出缓冲区
  3. 0x02登记入口不等于进入受保护执行
  4. 0x03正常调用和异常恢复走不同分支
  5. 0x04一次交接包含多层验证
  6. 0x05权限编号、索引和字段值分开解释
  7. 0x06用离线模型检查算术,不冒充硬件验证
  8. 0x07官方参考

普通内核拥有页表写入能力,并不意味着它能任意修改 PPL 所保护的页。页保护层(Page Protection Layer)的关键,是把所有权转移、权限更新和异常恢复收束到受控入口,再由硬件权限配置限制其他执行路径。

这里沿一份 ARM64 kernelcache 的反汇编分析这条边界:先定位保护对象,再跟随服务派发,最后检查一次物理页交接。设备型号与构建号缺失,因此 0x108、0x180、CPU 数量及服务上限都只描述该样本;Apple 的 XNU 7195.60.75 源码用于对照公开逻辑,不视为这份二进制的精确源码。

先算清结构尺寸和页粒度

pmap_bootstrap 把空闲物理地址经 phystokv 转成内核虚拟地址,再建立 pmap 数组。分配尺寸先乘结构步长 0x108,再向上对齐到 0x4000;初始化循环让新元素指向前一个元素,因此空闲链表头最终位于数组末端。这只说明建链次序,分配策略还要看摘链路径。

flowchart TD
  A["空闲物理区域"] --> B["phystokv"]
  B --> C["pmap 数组 / 步长 0x108"]
  B --> D["每 CPU 数据 / 步长 0x180"]
  B --> E["PPL 栈与保存区"]
  B --> F["物理页属性与反向映射元数据"]

这是对象关系图,不是按比例的连续内存布局。每 CPU 数据在该片段中初始化 6 项,已识别前缀如下;未展示的尾部不补成猜测字段。

per-CPU PPL data — observed prefixarm64 · LE
偏移名称类型大小
0x00saved_kernel_sp8
0x08ppl_stack8
0x10save_area8
0x18ppl_state4
大小 = 0x1c(28 字节)

ADD ..., #4, LSL #12 增加的是 16 KiB,#8, LSL #12 则是 32 KiB。把立即数 4 当成 4 KiB 会同时误读栈尺寸和页推进。额外预留空间是否确实未映射,还需核对建表结果,单看地址差不足以证明 guard page 属性。

所有权检查要覆盖输出缓冲区

pp_attr_table16 位
PP_ATTR_NO_MONITORPP_ATTR_MONITORother_attributes
16 位位 0 为最低位
位字段值
15PP_ATTR_NO_MONITOR0
14PP_ATTR_MONITOR1
13–0other_attributes

pp_attr_table 每页保存 16 位属性。MONITOR 表示页已归 PPL 所有;NO_MONITOR 用于阻止页在受约束期间转交 PPL。上面的置位值只演示一页已被 PPL 接管,不是现场内存转储。

第二个标记用于关闭检查时与使用时之间的竞态窗口:当服务向普通内核提供的输出缓冲区写数据时,该页的所有权应保持稳定,否则合法输出也可能变成受保护页的写入通道。公开 pmap.c 中的输出参数固定逻辑与所有权设置路径支持这一解释。

flowchart TD
  P["物理页"] --> A["pp_attr_table 属性"]
  P --> H["pv_head_table 条目与锁"]
  H --> V1["映射该页的 PTE"]
  H --> V2["其他地址空间的 PTE"]
  V1 --> M1["虚拟地址视图 A"]
  V2 --> M2["虚拟地址视图 B"]

反向映射不是另一张虚拟地址列表,而是从物理页追踪映射关系的元数据。所有权位、映射检查和 PVH 锁需要一起看;设置属性位不等于所有 PTE 已完成权限更新。

登记入口不等于进入受保护执行

启动入口的交接4 行
阶段 可观察动作 解释边界
bootstrap 将 bootstrap handler 写入 S3_6_C15_C8_1 安装入口,不是调用入口
早期异常入口 将 deadloop 写入 S3_6_C15_C8_2 不把早期占位处理当成最终异常处理
lockdown 置 pmap_ppl_locked_down = 1,进入 gxf_enable 软件标记与后续硬件操作分开追踪
首次受保护入口 安装常规 PPL handler 与异常向量 从启动配置切换到服务派发配置

样本的进入与退出路径分别出现机器字 0x00201420 和 0x00201400。前后控制流支持把它们解释为该实现的受保护进入/退出操作,但仅凭未解码机器字,不足以推出完整的私有硬件语义。

首次处理还依据 MPIDR_EL1 选择当前 CPU 数据,准备保存区并调整相关寄存器。对私有寄存器低位的测试只证明入口要求该状态位成立;对 SPSR_EL1 的掩码操作也不是标准 EL3 切换的证据。不要把 GXF 的保护状态直接等同于一个新增的标准异常级别。

正常调用和异常恢复走不同分支

服务包装函数把编号放入 X15。CMP X15, #0x47; B.CS ... 是无符号上界检查,接受 0x00–0x46 共 71 个编号,0x47 已越界。表项位置是 table_base + X15 * 8;BLRAA X10, X9 还将表项地址作为指针认证的修饰值。

尚未 lockdown 时,bootstrap dispatch 直接经表调用;lockdown 后,正常入口携带 W10 = 0,并要求当前 CPU 处于 KERNEL 状态。通过检查才保存调用者 SP、切换 PPL 栈并进入服务。

stateDiagram-v2
  KERNEL --> DISPATCH: W10 = 0 / 保存内核 SP
  DISPATCH --> KERNEL: 正常返回 / 恢复内核 SP
  DISPATCH --> EXCEPTION: 保存受保护现场
  EXCEPTION --> DISPATCH: W10 = 3 / 恢复保存区

图中状态分别是 0、1、3,仅保留本次分析的路径。它们属于当前 CPU;遇到 DISPATCH 时,应检查重入或异常路径,而不是直接推断其他 CPU 正在调用服务。

同步异常路径把现场保存到偏移 0x10 指向的区域,借助普通内核的 fleh_synchronous 处理,并用 X26 = 1 传递来源标记。返回时 W10 = 3 必须与 EXCEPTION 状态相符,然后恢复被中断的执行现场。它不是一次新的服务调用,也不应从偏移 0x08 的正常服务栈凭空重建现场。

一次交接包含多层验证

pmap_mark_page_as_ppl_page_internal 的关键更新是 old_attr | 0x4000,通过 16 位比较交换完成置位。LDRSH 对属性作符号扩展,便于识别 0x8000;CASALH 不是普通写入,竞争失败还需要回到检查流程。公开源码还检查页是否已经归 PPL 所有、是否存在不允许的映射。

随后,样本对直接映射地址调用 pmap_set_range_xprr_perm(va, va + 0x4000, 3, 1),把预期旧权限作为验证条件,而不是无条件覆盖页表。

权限更新的检查顺序4 步
  1. 1

    校验范围

    起止地址按 16 KiB 对齐,起点不大于终点,并落在允许的直接映射或静态区域。具体端点比较以该构建的分支为准。

  2. 2

    定位叶子项

    逐级读取页表描述符,区分表描述符与其他类型;有效物理地址还要转换为可访问的内核地址。

  3. 3

    加锁后重新检查

    锁住对应物理页的 PVH 条目,重新读取 PTE,验证有效性、hint 位及预期旧权限。

  4. 4

    更新并收尾

    保留不相关位,只写入新的权限组合;执行屏障、释放锁,再推进 PTE 与虚拟地址。

旧权限的提取片段如下,X8 是 PTE,X26 是预期权限。这里的 0x35 为十进制 53。

pmap_set_range_xprr_perm — selected instructionsasm
LSR     X9, X8, #4
AND     X9, X9, #0xC
LSR     X10, X8, #0x35
BFXIL   X9, X8, #0x35, #1
AND     X10, X10, #2
ORR     X9, X9, X10
CMP     X9, X26

循环尾部则同时推进 8 字节的 PTE 指针和 16 KiB 的虚拟地址。LDCLRL 对指定掩码执行原子清位,不是“保留掩码中的位”。

pmap_set_range_xprr_perm — loop tailasm
DSB     ISH
LDCLRL  W21, W8, [X8]
ADD     X20, X20, #8
ADD     X25, X25, #4, LSL #12
CMP     X20, X27

权限编号、索引和字段值分开解释

样本直接将 AP 与两个执行禁止位组合成 4 位值。这段取位公式不是所有 xPRR 实现的统一编码:公开 XNU 的 APRR 路径先使用 PTE_TO_APRR_INDEX,随后经 pte_to_xprr_perm 映射到软件权限编号,两层数字可能不同。

交接语义与样本编号3 行
动作 样本中的转换 应检查的语义
接管普通页 3 → 1 普通内核可写视图转为 PPL 管理的可写视图
释放 PPL 页 1 → 3 解除相应所有权并恢复内核侧用途
保护 PPL 文本 0xA → 0x8 PPL 侧保留执行,普通内核侧限制写入与执行

公开源码把 PPL 文本的目标描述为 PPL 侧 RX、内核侧 RO。这个目标可以解释为何同一物理页需要两套视图,但不应据此假设所有硬件寄存器在任意启动阶段都使用同一组字段值。双视图权限配置的字段值、PTE 导出的索引,以及 XPRR_*_PERM 软件编号应分别记录。

用离线模型检查算术,不冒充硬件验证

下面只验证样本取位与逆变换、无关位保持、分配对齐、服务上界以及属性置位。它不读写页表,不执行私有指令,也不复现 CPU 间并发。

ppl-bit-model.pypython
PAGE = 0x4000
MASK64 = (1 << 64) - 1
PTE_MASK = (3 << 6) | (3 << 53)

def sample_index(pte):
    return ((pte >> 4) & 0xC) | ((pte >> 53) & 3)

def sample_bits(index):
    return ((index & 0xC) << 4) | ((index & 3) << 53)

for index in range(16):
    for seed in (0, MASK64, 0x123456789ABCDEF0):
        original = seed & MASK64
        updated = (original & ~PTE_MASK) | sample_bits(index)
        assert sample_index(updated) == index
        assert (updated & ~PTE_MASK) == (original & ~PTE_MASK)
assert sample_index((2 << 6) | (1 << 53)) == 9
assert sample_index((3 << 6) | (1 << 54)) == 14
assert (4 << 12, 8 << 12) == (PAGE, PAGE * 2)
for count in range(1025):
    size = (count * 0x108 + PAGE - 1) & ~(PAGE - 1)
    assert size % PAGE == 0 and size >= count * 0x108
    assert size - count * 0x108 < PAGE
assert [i for i in range(0x48) if i < 0x47] == list(range(71))
for old in range(0x8000):
    assert (old | 0x4000) & 0x4000
    assert ((old | 0x4000) & ~0x4000) == (old & ~0x4000)
print("PASS: 48 PTE round trips; 1025 alignments; 71 service IDs; 32768 attribute cases")

运行结果为 48 组 PTE 往返、1025 组对齐、71 个合法编号及 32768 组属性输入通过。它能发现位移、掩码和边界笔误,却不证明页表更新顺序、TLB 同步或私有寄存器配置在硬件上正确。

判断 PPL 边界时,应依次回答四个问题:是否经过合法入口、当前 CPU 状态是否匹配、页所有权与映射是否稳定、最终权限配置如何解释该索引。只解释一个状态位或一个 PTE 数字,会漏掉真正约束写入的其他层。

官方参考

Apple 平台安全文档用于说明保护目标;以下固定版本源码用于核对公开的软件逻辑,与所分析 kernelcache 的构建身份分开使用。

NORMAL~/posts/mobile/ios-ppl-entry-state-page-ownership.md§--
0%zh-CN