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

iOS PPL: entry state and physical-page ownership

Trace boot setup, service dispatch, and page handoff in an ARM64 kernel. Separate per-CPU state, ownership, and permission encodings, and check stack strides, atomic updates, and exception recovery.

date[31:24]
read[23:16]
8 min
cat[15:8]
Mobile
Contents
  1. 0x00Establish sizes before interpreting the layout
  2. 0x01Ownership checks also cover output buffers
  3. 0x02Installing an entry point is not entering it
  4. 0x03Normal dispatch differs from exception recovery
  5. 0x04A page handoff has several validation layers
  6. 0x05Separate permission numbers, indices, and field values
  7. 0x06Check arithmetic without claiming hardware validation
  8. 0x07Official references

A kernel's ability to write page tables does not imply unrestricted access to pages protected by the Page Protection Layer (PPL). The boundary combines controlled entry points for ownership changes, permission updates, and exception recovery with hardware restrictions on other execution paths.

This analysis follows an ARM64 kernelcache from protected objects through service dispatch to a physical-page handoff. The device and build are unidentified, so the strides, CPU count, and service limit belong only to this sample. Apple's XNU 7195.60.75 sources provide a comparison for public software logic, not an exact source match for the binary.

Establish sizes before interpreting the layout

pmap_bootstrap converts available physical memory through phystokv and constructs a pmap array. Allocation multiplies the element count by 0x108 before rounding up to 0x4000. Each initialized element links to its predecessor, leaving the free-list head at the array's high end; allocation policy still requires examining removal from that list.

flowchart TD
  A["Available physical memory"] --> B["phystokv"]
  B --> C["pmap array / stride 0x108"]
  B --> D["Per-CPU data / stride 0x180"]
  B --> E["PPL stacks and save areas"]
  B --> F["Physical attributes and reverse mappings"]

This is an object relationship diagram, not a scaled contiguous memory map. The observed per-CPU initialization covers six records. Only the identified prefix is shown; no fields are invented for the remaining bytes.

per-CPU PPL data — observed prefixarm64 · LE
OffsetNameTypeSize
0x00saved_kernel_sp8
0x08ppl_stack8
0x10save_area8
0x18ppl_state4
size = 0x1c (28 bytes)

ADD ..., #4, LSL #12 adds 16 KiB; #8, LSL #12 adds 32 KiB. Reading the immediate 4 as 4 KiB misidentifies both stack sizing and page advancement. Reserved spacing alone does not establish an unmapped guard page; that requires inspecting the resulting mappings.

Ownership checks also cover output buffers

pp_attr_table16-bit
PP_ATTR_NO_MONITORPP_ATTR_MONITORother_attributes
16 bitsbit 0 is the least significant
BitsFieldValue
15PP_ATTR_NO_MONITOR0
14PP_ATTR_MONITOR1
13–0other_attributes

pp_attr_table holds 16-bit physical-page attributes. MONITOR denotes PPL ownership; NO_MONITOR prevents a page from becoming PPL-owned while that constraint is active. The set bit above illustrates an owned page, not a captured memory value.

The second attribute closes a time-of-check/time-of-use window. While a service writes to an ordinary kernel output buffer, the page's ownership must remain stable; otherwise an ordinary output operation could become a write into protected memory. The output-parameter pinning and ownership paths in public pmap.c support this interpretation.

flowchart TD
  P["Physical page"] --> A["pp_attr_table attributes"]
  P --> H["pv_head_table entry and lock"]
  H --> V1["PTE mapping the page"]
  H --> V2["PTE in another address space"]
  V1 --> M1["Virtual view A"]
  V2 --> M2["Virtual view B"]

Reverse-mapping metadata tracks mappings from a physical page; it is not another flat list of virtual addresses. Read the ownership attributes, mapping checks, and PVH lock together. Setting an ownership bit does not mean every PTE permission has already changed.

Installing an entry point is not entering it

Boot-time entry handoff4 rows
Stage Observable action Interpretation limit
bootstrap Write the bootstrap handler to S3_6_C15_C8_1 Install an entry, rather than call it
Early exception entry Write a deadloop to S3_6_C15_C8_2 Do not treat the placeholder as the final handler
lockdown Set pmap_ppl_locked_down = 1 and enter gxf_enable Separate the software flag from subsequent hardware operations
First protected entry Install the normal PPL handler and exception vectors Switch from boot configuration to service dispatch

Machine words 0x00201420 and 0x00201400 appear on the sample's entry and exit paths. Surrounding control flow supports interpreting them as protected entry and exit operations in this implementation. An undecoded word alone does not establish the complete private hardware semantics.

Initial handling also uses MPIDR_EL1 to locate the current CPU record, prepares the save area, and adjusts related registers. Testing a private register's low bit proves only that the path requires that bit. Masking SPSR_EL1 is not evidence of a standard EL3 transition: GXF protected state should not be equated with an extra architectural exception level.

Normal dispatch differs from exception recovery

Wrappers place the service number in X15. The unsigned check CMP X15, #0x47; B.CS ... accepts 0x00–0x46, or 71 service numbers; 0x47 is already out of range. The table entry is at table_base + X15 * 8. BLRAA X10, X9 additionally uses the entry address as the pointer-authentication modifier.

Before lockdown, bootstrap dispatch calls through the table directly. After lockdown, normal entry carries W10 = 0 and requires the current CPU to be in KERNEL state. Only then does it save the caller's SP, switch to the PPL stack, and dispatch.

stateDiagram-v2
  KERNEL --> DISPATCH: W10 = 0 / save kernel SP
  DISPATCH --> KERNEL: normal return / restore kernel SP
  DISPATCH --> EXCEPTION: save protected context
  EXCEPTION --> DISPATCH: W10 = 3 / restore save area

The states shown are 0, 1, and 3; the diagram includes only the paths discussed here. They describe the current CPU. Finding DISPATCH calls for examining reentry or exception handling, not assuming that another CPU is using the service.

The synchronous exception path saves context through the pointer at offset 0x10, reuses ordinary kernel fleh_synchronous handling, and passes X26 = 1 as the origin marker. Reentry with W10 = 3 must match EXCEPTION state before restoring the interrupted context. This is not a new service invocation, nor is its context reconstructed from the normal PPL stack at offset 0x08.

A page handoff has several validation layers

The essential attribute update in pmap_mark_page_as_ppl_page_internal is old_attr | 0x4000 through a 16-bit compare-and-swap. LDRSH sign-extends the attribute so the path can recognize 0x8000. CASALH is not a plain store; a failed comparison must return to validation. Public source also checks existing PPL ownership and disallowed mappings.

The sample then calls pmap_set_range_xprr_perm(va, va + 0x4000, 3, 1) for the direct mapping. The expected old permission is a condition to verify, not merely a hint for an unconditional overwrite.

Permission-update checks4 steps
  1. 1

    Validate the range

    Require 16 KiB alignment, a start no greater than the end, and an allowed direct-map or static region. Read endpoint comparisons from the relevant build.

  2. 2

    Find leaf entries

    Walk translation descriptors and distinguish table entries from other types. Convert valid physical addresses into accessible kernel addresses.

  3. 3

    Lock and recheck

    Lock the physical page's PVH entry, reload the PTE, and validate its type, hint bit, and expected old permission.

  4. 4

    Update and finish

    Preserve unrelated bits, insert the new permission combination, issue barriers, release the lock, and advance the PTE and virtual address.

The extraction below uses X8 for the PTE and X26 for the expected permission. The immediate 0x35 is decimal 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

The loop tail advances an 8-byte PTE pointer and a 16 KiB virtual-page address. LDCLRL atomically clears the selected mask bits; it does not retain only those bits.

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

Separate permission numbers, indices, and field values

The sample combines AP and the two execute-never bits directly into a four-bit value. This extraction is not a universal xPRR encoding. The public XNU APRR path first applies PTE_TO_APRR_INDEX and then maps the result through pte_to_xprr_perm to a software permission number. Those two numbers need not be identical.

Handoff semantics and observed numbers3 rows
Operation Sample transition Semantic check
Adopt an ordinary page 3 → 1 Replace the ordinary kernel-writable view with a PPL-managed writable view
Release a PPL page 1 → 3 Remove the corresponding ownership and restore kernel-side use
Protect PPL text 0xA → 0x8 Preserve PPL execution while restricting ordinary kernel writes and execution

Public source describes the PPL text target as RX for PPL and RO for the kernel. That explains the need for two views of one physical page; it does not prove that every hardware register uses identical fields at every boot stage. Record dual-view permission configuration fields, PTE-derived indices, and XPRR_*_PERM software numbers separately.

Check arithmetic without claiming hardware validation

This model checks extraction and insertion for the sample, preservation of unrelated bits, allocation rounding, service bounds, and attribute setting. It neither accesses page tables nor executes private instructions or reproduces CPU concurrency.

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")

The run passes 48 PTE round trips, 1025 alignment cases, 71 valid service numbers, and 32768 attribute inputs. These checks catch shifts, masks, and bounds errors. They do not validate page-table ordering, TLB synchronization, or private register configuration on hardware.

Evaluate the PPL boundary in four stages: valid entry, matching per-CPU state, stable ownership and mappings, and the hardware interpretation of the resulting index. Explaining only one state bit or one PTE number misses the other layers that constrain the write.

Official references

Apple Platform Security describes the protection goal. These fixed-version source files provide public software cross-checks, separate from the unidentified kernelcache build.

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