~/posts/mobile/xnu-arm64-pac-context-state-authentication.md

XNU PAC: context switches and thread-state authentication

Trace PAC state across boot, shared regions, scheduling, and exceptions. Separate hardware keys from software values and discriminators, and verify jophash inputs, carry masking, and accepted helper keys.

date[31:24]
read[23:16]
7 min
cat[15:8]
Mobile
Contents
  1. 0x00Three kinds of value answer different questions
  2. 0x01Read boot constants in write order
  3. 0x02Reuse a shared-region entry before selecting a new value
  4. 0x03jophash covers an explicit set of fields
  5. 0x04Scheduling and exception transitions are different switches
  6. 0x05Helper instructions do not define the public input set
  7. 0x06ABI aliases do not promise identical values in every process
  8. 0x07Offline checks cover arithmetic and dependencies only
  9. 0x08Official references

One PACIA instruction describes an authentication-code calculation, not why a pointer remains verifiable across a process switch. XNU pointer authentication also depends on boot configuration, shared-region state, scheduling, exception transitions, and controlled helper interfaces that preserve compatible signing and checking contexts.

This analysis follows state flows from a 2021 iPhone 12 kernel study, cross-checked against Apple XNU 7195.60.75 and its design notes. The sample's complete build identity is missing. Offsets, private control bits, and boot constants therefore remain sample-specific; public fast and slow switching paths should not be spliced into a single universal instruction sequence.

Three kinds of value answer different questions

Registers, software state, and discriminators3 rows
Object Question answered Distinction to preserve
APIAKey / APIBKey / APDAKey / APDBKey and related registers Which hardware authentication material does the operation select? Low and high writes do not describe the entire hardware derivation
jop_pid / rop_pid / srk_jop_key software fields Which context does a task or shared region require? A CPU cache copy is not an additional PAC instruction key
Modifier / discriminator For which use or storage location is the signature valid? It is not the complete signing key

A fixed software constant does not reveal all effective signing material. Apple's fixed-version design notes describe an optional implementation with a per-boot diversifier and KERNKey differentiation between kernel and userspace. Constant architectural-register writes can coexist with other signing inputs; the applicable implementation still determines a particular chip's behavior.

Read boot constants in write order

Boot register sequence / K = 0xFEEDFACEFEEDFACF6 rows
Register pair Low High
APIBKey K K+1
APDBKey K+2 K+3
Private pair K+4 K+5
APIAKey K+6 K+7
APDAKey K+8 K+9
APGAKey K+10 K+11

The middle private pair lacks confirmed complete semantics and is left unnamed. K+6 is 0xFEEDFACEFEEDFAD5, also seen as a default software state value on later paths.

For S3_4_C15_C0_4, AND #0x2 tests bit 1, while ORR #0x1 and ORR #0x4 set bits 0 and 2. Bit numbering starts at zero; confusing the third bit with bit 3 changes the control condition.

0x3454593d | 0x40000000 = 0x7454593d, with added bit 30 corresponding to SCTLR_EL1.EnIB. That describes this write only. Later paths or platform extensions may control other authentication classes, so it does not establish that they remain disabled.

Reuse a shared-region entry before selecting a new value

shared_region_key_alloc looks up shared_region_id. A match reuses the software entry and increments its reference count. An inheriting caller must also match the stored value against inherited_key. This is separate from selecting a value for a newly allocated entry.

New-entry choices observed in the sample4 rows
Condition Stored value
diversify_user_jop disabled Default 0xFEEDFACEFEEDFAD5
Enabled with inheritance inherited_key
Enabled, no inheritance, nonempty identifier Retry random generation until nonzero
Other observed default path Retain the default

The 0x28 entry stride accommodates links, an identifier pointer, a 64-bit software value, and reference-count storage. It is not a stable ABI. Shared regions allow eligible processes to use compatible signing contexts; they do not expose kernel registers to ordinary userspace.

Fixed modifiers 0x2ABE and 0x9BF6 are useful landmarks for tracing authenticated indirect calls. Repeated constants alone establish neither authentication collisions nor predictable randomness. Check the signing schema, target pointer, and reachable call path together.

jophash covers an explicit set of fields

Saved-state authentication — operand dependenciestext
h = PACGA(pc, state_address)
h = PACGA(cpsr & 0xFFFFFFFFDFFFFFFF, h)
h = PACGA(lr, h)
h = PACGA(x16, h)
h = PACGA(x17, h)
state.jophash = h

This is a PACGA operand-dependency chain, not its cryptographic implementation. PC and the state address begin the chain, followed by CPSR, LR, X16, and X17. Other fields do not automatically receive the same protection merely because they occupy the same structure.

The CPSR mask clears bit 29, the carry flag. Both public signing and checking macros apply this transformation so a system-call return can change carry without re-signing. Omitting the mask creates the false expectation that a carry-only change must mismatch.

arm_kernel_saved_state — observed fields onlyarm64 · LE
OffsetNameTypeSize
0x00padding0x68
0x68lr8
0x70padding8
0x78pc8
0x80cpsr4
0x84padding4
0x88jophash8
size = 0x90 (144 bytes) · padding 0x74

Only identified fields from the sample are shown; gaps are not filled with a guessed complete structure. ml_check_kernel_signed_state recomputes with the same field order and state address. machine_stack_attach signs initial kernel-stack state, supplying a valid value for later context-switch checks.

Changing signed state is more than writing a field and re-signing. The public design disables interrupts before loading and checking sensitive values, mutates and re-signs them in registers, writes them back, and restores interrupts. An unintended spill during that window would reintroduce a memory-tampering opportunity.

Scheduling and exception transitions are different switches

Two classes of transition3 rows
Path Main observation in the sample Source-comparison limit
Scheduling a new thread Compare software state with the CPU cache and update B keys if needed Track rop_pid state and its cache together
Entering the kernel from userspace Prepare kernel-side A context and sign saved state Actual registers depend on fast or slow A-key switching
Returning to userspace Check saved state before restoring user context Keep this separate from kernel-internal exception returns

B-key writes place value through value+3 into the low and high APIB and APDB parts. Matching cached state skips redundant writes. The sample's exception path shows direct A-register writes, while the fixed source's REPROGRAM_JOP_KEYS also supports a fast path through KERNKey registers. Configuration selects these implementations; every exception does not necessarily rewrite all architectural keys.

Saved-state signing and checking must use paired contexts. Collapsing scheduling, user exceptions, and kernel exceptions into one abstract key update hides the execution state in which a signature was produced.

Helper instructions do not define the public input set

pmap_sign_user_ptr saves interrupt and authentication state, selects a userspace context within a controlled window, performs the operation, and restores state. The sample clears or sets private-register bit 2, not bit 1. Its role in kernel/userspace signing should be derived from the implementation rather than calling it a lock bit from the function name alone.

Signing dispatch accepts key number 0 for PACIA and 2 for PACDA. An authentication assembly helper may also contain AUTIB and AUTDB, but instruction presence does not establish that a higher-level interface accepts those keys. The checked XNU 7195.60.75 pmap_auth_user_ptr_internal accepts only ptrauth_key_asia and ptrauth_key_asda before calling its helper.

Trace accepted parameters through the complete call path. Reachability of B-key branches in another historical build needs its own entry checks. Seeing B-key authentication also does not prove that the call has just rewritten B-key registers.

ABI aliases do not promise identical values in every process

Four basic ABI aliases4 rows
ABI name Base key Number
ptrauth_key_process_independent_code ptrauth_key_asia 0
ptrauth_key_process_dependent_code ptrauth_key_asib 1
ptrauth_key_process_independent_data ptrauth_key_asda 2
ptrauth_key_process_dependent_data ptrauth_key_asdb 3

A keys have process-independent aliases and B keys process-dependent aliases. A helper that updates only A keys does not reverse those definitions. C function pointers and C++ vtable pointers use the corresponding A code and data keys; return addresses use the B code-key role.

These names describe ABI roles, not an unconditional promise that arbitrary processes use identical values. Shared regions, inheritance, and platform implementation still matter. Current Clang documentation explicitly limits the cross-process interpretation. A signing schema also includes its discriminator and address-diversification rule.

Offline checks cover arithmetic and dependencies only

The model represents PACGA as nested tuples to track inputs. It generates no real PAC and proves nothing about cryptographic collisions. Addresses and random outputs are illustrative; new-entry selection omits queue reuse and concurrency.

pac-state-model.pypython
MASK64 = (1 << 64) - 1
K = 0xFEEDFACEFEEDFACF
values = [(K + i) & MASK64 for i in range(12)]
assert values[6] == 0xFEEDFACEFEEDFAD5
assert values[11] == 0xFEEDFACEFEEDFADA
assert (0x3454593D | 0x40000000) == 0x7454593D
assert [i for i in (0, 1, 2) if ((1 | 4) >> i) & 1] == [0, 2]
CARRY = 1 << 29
assert MASK64 & ~CARRY == 0xFFFFFFFFDFFFFFFF

def transcript(address, pc, cpsr, lr, x16, x17):
    h = ("PACGA", pc, address)
    for value in (cpsr & ~CARRY, lr, x16, x17):
        h = ("PACGA", value, h)
    return h

state = [0x1000, 0x2000, 0x40000000, 0x3000, 4, 5]
baseline = transcript(*state)
for i in range(6):
    changed = state.copy()
    changed[i] ^= 1
    assert transcript(*changed) != baseline
changed = state.copy()
changed[2] ^= CARRY
assert transcript(*changed) == baseline

def fresh_value(diversify, inherit, identifier, inherited, random_values):
    if not diversify: return values[6]
    if inherit: return inherited
    if not identifier: return values[6]
    return next(x for x in random_values if x != 0)

assert fresh_value(False, True, "example", 7, iter(())) == values[6]
assert fresh_value(True, True, "example", 7, iter(())) == 7
assert fresh_value(True, False, "", 7, iter(())) == values[6]
assert fresh_value(True, False, "example", 7, iter((0, 0, 9))) == 9
print("PASS: constants; 6 transcript dependencies; carry exclusion; 4 new-entry choices")

The checks pass for boot increments, six dependency inputs, carry exclusion, and four new-entry choices. They establish which values participate and which branch selects state, not hardware behavior on arm64e.

Keep architectural registers, saved software values, signing schemas, and call reachability distinct. A complete authentication explanation shows that signing and checking use compatible fields, addresses, and execution contexts.

Official references

Fixed-version files document the public design and implementation. Current Clang and Apple developer documentation cross-check terminology and ABI roles, not sample-specific offsets.

NORMAL~/posts/mobile/xnu-arm64-pac-context-state-authentication.md§--
0%en