Updating read-only process and task metadata
Key structures · Process and proc_ro · Task and shared read-only state · Read-only and special zones
Read-only metadata can change. In this XNU snapshot, ordinary kernel loads read the proc_ro allocation, while selected updates go through typed macros, allocator checks and an architecture-specific write path. This chapter follows two concrete updates: copying a process identity version and atomically setting task flags. It documents xnu-12377.121.6, source commit ac9718fb1af618d5ce8678d0dc6e8a58f252216f.
The prerequisite RO-zone chapter explains registration, allocator geometry and the other special zone families. Here the focus is the relationship between the actual process/task fields, their update APIs and the ARM64 implementation. Read-only mapping protection, atomicity, object lifetime and permission to perform a particular process operation are separate contracts.
Other callers use the same update machinery with different ownership rules: thread_ro stores protected associations and credential pointers, ucred combines immutable identity with a protected holding count, and MAC labels update owner/slot representation. Follow their companions for the caller-side locking, reference transfer and cleanup that the RO helper itself does not supply.
1. The ordinary process/task pair shares one RO record
For the ordinary associated pair, proc.p_proc_ro and task.bsd_info_ro point to the same struct proc_ro. That structure contains pr_proc and pr_task backlinks, a process-data group and a task-data group. There is no separate struct task_ro allocation in this path. Bootstrap and task-only/corpse cases require the qualifications in the task companion. Process pointer, task pointer, shared structure.
__xnu_struct_group() overlays each named group with anonymous member access. Thus ro->t_flags_ro and ro->task_data.t_flags_ro name the same storage; ro->p_idversion and ro->proc_data.p_idversion do likewise. The groups are not duplicate copies. Their named forms permit copying one logical group during initialization. CONFIG_MACF changes fields in the task group, so a byte layout must come from the selected build's C types. Group macro, conditional task fields.
Mermaid source
flowchart TD
P["Mutable proc P"] -->|p_proc_ro| R["One ZONE_ID_PROC_RO element<br/>Backlinks: pr_proc = P, pr_task = T"]
T["Mutable task T"] -->|bsd_info_ro| R
R --> PD["proc_data<br/>Identity, credential pointer,<br/>flags, filters and platform data"]
R --> TD["task_data<br/>Task flags, control-port options,<br/>conditional filter pointers"]
The diagram shows the ordinary paired object. Its arrows identify fields, not independent reference counts for every pointer. For example, keeping the RO allocation alive does not automatically retain everything a field points to.
2. Initialization also uses the update API
The reserved zone is declared for struct proc_ro with ZC_READONLY | ZC_ZFREE_CLEARMEM. proc_ro_alloc() requests Z_WAITOK | Z_NOFAIL | Z_ZERO, constructs a zero-initialized stack pr_local, fills whichever process/task arguments are present, then copies the complete local structure through zalloc_ro_update_elem(). It does not temporarily grant ordinary writable access to the returned object for initialization. Zone declaration, allocation and initial copy.
In the normal creation sequence, process creation builds proc_ro_data and assigns the new RO pointer to child_proc->p_proc_ro. Task creation later supplies task_ro_data to proc_ro_ref_task(). Despite its name, this helper does not increment a proc_ro reference counter: it checks that pr_task is null, copies the current element into a local structure, installs the task backlink/data and writes the whole element back. new_task->bsd_info_ro receives that same allocation. Process-side creation, task-side attachment, whole-element attachment helper.
This is staged construction under the creation protocol. A whole-element copy preserves the other fields from the local snapshot, but it is not an atomic publication operation that can safely merge arbitrary concurrent writers. A caller cannot use this initialization pattern as a substitute for a shared object's later synchronization rules.
3. Reads validate the relationship and then load normally
proc_get_ro(p) reads p->p_proc_ro, calls zone_require_ro(ZONE_ID_PROC_RO, sizeof(struct proc_ro), ro), then checks ro->pr_proc == p. task_get_ro(t) similarly follows bsd_info_ro and checks the task backlink. A mismatch panics. These accessors neither acquire a process/task reference nor lock the object. Process accessor, task accessor.
Task accessor and flag read — task.c, lines 10360–10377.
proc_ro_t
task_get_ro(task_t t)
{
proc_ro_t ro = (proc_ro_t)t->bsd_info_ro;
zone_require_ro(ZONE_ID_PROC_RO, sizeof(struct proc_ro), ro);
if (__improbable(proc_ro_task(ro) != t)) {
panic_proc_ro_task_backref_mismatch(t, ro);
}
return ro;
}
uint32_t
task_ro_flags_get(task_t task)
{
return task_get_ro(task)->t_flags_ro;
}
After validation, task_ro_flags_get() is the ordinary C load shown above; proc_pidversion() likewise returns proc_get_ro(p)->p_idversion. These read paths do not enter PPL, request an SPTM mutation or change page permissions. Process identity read.
With RO checking enabled, zone_require_ro() tests membership in the RO virtual-address range, alignment using the protected zone-size parameters, and the exact metadata zone ID. The body uses the recorded parameters; its passed elem_size is marked unused. It does not consult an allocation generation or acquire a lifetime reference. A valid zone address and matching backlink are useful identity checks, but the caller still must keep the object alive. When this checking is compiled out, the accessors retain their backlink checks while the allocator range check becomes a no-op. Validation body and fallback.
4. Plain field copy: process identity version
During the selected exec identity update, exec_handle_sugid() increments the global identity-version source until it differs from the process's previous value, calls proc_setpidversion() and then assigns a new task unique ID with task_set_uniqueid(). The version update itself is a plain field copy. The global increment being atomic does not make this sequence of process and task updates one atomic transaction. Exec caller, task unique-ID assignment.
Process setter — kern_proc.c, lines 2114–2119.
void
proc_setpidversion(proc_t p, int idversion)
{
zalloc_ro_update_field(ZONE_ID_PROC_RO, proc_get_ro(p), p_idversion,
&idversion);
}
The field macro types the supplied pointer against the actual field, derives its offset from the whole pointed-to element type and copies sizeof(field) bytes. The address &idversion refers to a local parameter whose bytes remain available during the synchronous call. Field macro.
Field macro — zalloc.h, lines 872–876.
#define zalloc_ro_update_field(zone_id, elem, field, value) ({ \
const typeof((elem)->field) *__value = (value); \
zalloc_ro_mut(zone_id, elem, offsetof(typeof(*(elem)), field), \
__value, sizeof((elem)->field)); \
})
Conceptually, for R = proc_get_ro(p), the expansion supplies:
| Argument | Selected value |
|---|---|
| Zone | ZONE_ID_PROC_RO |
| Element base | R, the beginning of the shared RO allocation |
| Offset | offsetof(struct proc_ro, p_idversion) |
| Source | Address of the local idversion value |
| Copy size | sizeof(R->p_idversion) |
zalloc_ro_mut() asserts a reserved RO ID and, when enforcement is enabled, checks the source range before calling pmap_ro_zone_memcpy(). The pmap backend checks the destination and performs the copy. Invalid source/zone/bounds produce panic/assertion behavior in these bodies, not a recoverable setter error; the setter returns void. Allocator copy path, legacy destination checks, SPTM destination checks.
Which source buffers are accepted?
The source-side predicate accepts any of the following alternatives: the whole range fits in the current kernel stack; it fits in the RO map and its starting address belongs to the requested zone ID; or it qualifies as constant memory. The stack case permits this setter's local parameter and the whole-element construction temporaries. The same-zone RO-source alternative does not check that the source begins at an element boundary or lies within one live element. Source predicate, stack bounds.
Accepted source alternatives — zalloc.c, lines 6700–6710.
zalloc_ro_mut_validate_src(zone_id_t zid, void *elem,
const vm_offset_t src, vm_size_t src_size)
{
if (from_current_stack(src, src_size) ||
(from_ro_map(src, src_size) &&
zid == zone_index_from_ptr((void *)src)) ||
from_const_memory(src, src_size)) {
return;
}
zalloc_ro_mut_validation_panic(zid, elem, src, src_size);
}
The constant-memory alternative depends on configuration. With the listed KTRR/CTRR/PV_CTRR defines, it calls rorgn_contains(); otherwise that helper returns true, so the same source-location restriction is not enforced. SPTM's stack case includes the optional mapped stack redzone. The separate AMFI-entitlements exception in zalloc_ro_mut() is not selected for ZONE_ID_PROC_RO. These are checks on where bytes come from, not on whether a new identity version or policy value is semantically authorized. Constant-memory variants, AMFI-specific exception.
5. Atomic field update: task flags
task_ro_flags_set(task, flags) passes the shared allocation, t_flags_ro, ZRO_ATOMIC_OR_32 and the value to zalloc_ro_update_field_atomic(). Clearing flags uses AND32 with the complemented mask. These are operations on the 32-bit word; neither helper takes the task lock. A concrete caller, task_set_filter_msg_flag(), selects set/clear for TFRO_FILTER_MSG. Flag helpers, selected caller.
Task flag setter — task.c, lines 10379–10384.
void
task_ro_flags_set(task_t task, uint32_t flags)
{
zalloc_ro_update_field_atomic(ZONE_ID_PROC_RO, task_get_ro(task),
t_flags_ro, ZRO_ATOMIC_OR_32, flags);
}
The atomic macro infers the field's base type, checks at compile time that its size equals the width encoded in the operation's low nibble, and passes the full element base plus offsetof(struct proc_ro, t_flags_ro) to zalloc_ro_mut_atomic(). OR32 is encoded as 0x10 | 4, so backend destination validation covers four bytes. There is no source-buffer pointer here: the operand is passed by value. Operation encoding, atomic macro, allocator dispatch.
After the selected pmap backend resolves the destination aperture address, __zalloc_ro_mut_atomic() dispatches OR32 to os_atomic_or_orig((uint32_t *)dst, (uint32_t)value, seq_cst). That operation returns the old word. The generic macro preserves that return value; task_ro_flags_set() ignores it. The destination's ordinary RO address does not become writable for a later unrelated store. Atomic implementation.
For illustration, if the word initially contains bits A, setting mask B performs the one-word operation old = A; new = A | B. Two concurrent atomic OR operations update the word through the atomic primitive rather than competing plain read/copy sequences. This says nothing about whether either caller may change the chosen bits or whether associated fields are ready when readers see them.
Mermaid source
flowchart TD
P["proc_setpidversion: local int"] --> PC["Typed field macro: element, offset, sizeof field"]
PC --> C["zalloc_ro_mut: source-range validation"]
T["task_ro_flags_set: mask value"] --> TA["Atomic field macro: offset and width assertion"]
TA --> A["zalloc_ro_mut_atomic: OR32 operation"]
C --> V["Selected pmap backend: destination zone and bounds"]
A --> V
V --> CP["Copy bytes through aperture VA"]
V --> AT["Atomic OR32 through aperture VA"]
CP --> R["Ordinary RO address observes updated storage"]
AT --> R
The two terminal operations correspond to their respective incoming API, not a runtime choice to turn an ordinary copy into an atomic update. The detailed backend branches follow below.
6. Object offsets, physical addresses and aperture addresses
Let R denote the ordinary kernel virtual address of a live proc_ro, O the compiler-derived field offset, and N the selected copy/atomic width. The target byte range is [R + O, R + O + N). If E is the zone's recorded element size, the destination checks require a valid aligned element in this zone and an in-bounds offset/extent. E comes from zone_ro_size_params, rather than trusting a caller-supplied structure size. Recorded size parameters, zone creation, backend bounds.
The two selected updates differ only in the field-specific arithmetic before dispatch:
| Update | O |
N |
Operation |
|---|---|---|---|
| Process version | offsetof(struct proc_ro, p_idversion) |
sizeof(R->p_idversion) |
Copy local value bytes |
| Task flags | offsetof(struct proc_ro, t_flags_ro) |
4, checked against the actual field type | Atomic OR32 |
| Whole initial object | 0 | sizeof(struct proc_ro) |
Copy the local initialized structure |
This symbolic example intentionally does not fix one sizeof(struct proc_ro) for every configuration. Group overlays and conditional fields determine the real offsets. It also needs no assumed page size: the allocator's RO geometry confines elements to a single PAGE_SIZE chunk. Whole-element macro, RO chunk geometry.
Both ARM64 backend families translate R + O with kvtophys_nofail(), producing physical address PA. They then obtain W = phystokv(PA) and perform the memory operation at W. W is a kernel virtual address in the physical aperture, not the numeric physical address used as a C pointer. R and W address the same underlying bytes through different mappings. Neither is a userspace address or a device IOVA. Legacy translation and copy, SPTM translation and copy.
Legacy phystokv() uses the physical-to-virtual table with a base-offset fallback. The SPTM implementation calls the sptm_phystokv() translation interface and returns its aperture-address result. That call is evidence of address translation through the supplied interface, not evidence of an SPTM write-permission request for each field mutation. Legacy aperture translation, SPTM aperture translation.
Mermaid source
flowchart TD
O["R: ordinary RO object VA"] -->|add compiler-derived field offset O| F["R + O: field VA"]
F -->|kvtophys_nofail| P["PA: physical byte address"]
P -->|phystokv| W["W: kernel physical-aperture VA"]
W -->|validated copy or atomic update| B["Same physical field storage"]
F -->|ordinary load through RO mapping| B
The diagram separates address representations. Whether a particular mapping permits writes and which execution context can use that permission are backend properties, not consequences of the arithmetic alone.
7. The mapping and mutation mechanisms differ by ARM64 backend
RO mapping is established before these field updates
When ZSECURITY_CONFIG(READ_ONLY) is enabled, RO zone creation selects Z_SUBMAP_IDX_READ_ONLY. Zone population supplies VM_PROT_READ and PMAP_MAPPING_TYPE_ROZONE to VM. The allocation therefore arrives with the ordinary RO mapping discipline already established; zalloc_ro() uses the common allocator and validates the result. Creation configuration, mapping/protection selection, population call, RO allocation.
Legacy ARM pmap rejects a non-read-only protection for the kernel RO-zone VA and, after successful mapping, calls pmap_phys_write_disable(). With XNU_MONITOR, that helper locks down the page with PVH_FLAG_LOCKDOWN_RO; without it, the body calls pmap_page_protect(..., VM_PROT_READ). Mapping validation, post-enter protection, physical protection variants.
SPTM associates the RO mapping with its frame type and gives the relevant leaf tables XNU_PAGE_TABLE_ROZONE type. The RO submap is aligned to the required twig boundary. These mapping/type operations provide the context for the ordinary address's protection; their existence does not insert a type-changing call into the field-write helper. RO mapping type, RO table typing, alignment.
Legacy ARM64 with PPL
Under XNU_MONITOR, the public copy/atomic wrappers call their *_ppl entrypoints. The generated stub puts the RO-copy or atomic operation index into x15 and branches to _aprr_ppl_enter; the handler table associates those indices with the internal RO mutation functions. The available dispatch assembly checks the index/state, switches to the per-CPU PPL stack and selects the handler. Public wrappers, stub generation, RO indices and declarations, handler entries, dispatch fragment.
Inside the mutation function, destination validation precedes the actual store. The physical-page helper takes the page's PV-head lock and, on PPL builds, verifies PVH_FLAG_LOCKDOWN_RO. Copy uses memcpy() through the aperture; atomic update uses the same aperture with __zalloc_ro_mut_atomic(); both then release the PV lock. The lock helper also checks its passed range against a page boundary. Physical lock/check, copy body, atomic body.
PPL's PV locking selects hw_lock_bit_nopreempt(): it relies on the protected execution context already having preemption disabled. The visible PPL exit checks and decrements the thread's preemption count. The _aprr_ppl_enter body and full protected crossing are absent from this checkout, so this chapter does not invent the missing entry instructions or label PPL unconditionally as an EL2 transition. The monitor chapter documents the available stack/state fragments. PV-head locking, preemption-specific lock selection, disabled-preemption requirement, visible preemption release.
SPTM implementation
The build selects the SPTM pmap source separately from the legacy implementation. Here the public RO wrapper calls the internal helper directly. The helper resolves the target physical address, disables interrupts, validates the destination, calls the prepare hook, updates through phystokv(PA), calls the complete hook and restores the saved interrupt state. The atomic helper uses the same sequence with its encoded access width. Build selection, copy path, atomic path.
SPTM atomic operation body — pmap.c, lines 11846–11857.
const pmap_paddr_t pa = kvtophys_nofail(va + offset);
vm_size_t value_size = op & 0xf;
const boolean_t istate = ml_set_interrupts_enabled(FALSE);
pmap_ro_zone_validate_element_dst(zid, va, offset, value_size);
pmap_ro_zone_prepare_write();
value = __zalloc_ro_mut_atomic(phystokv(pa), op, value);
pmap_ro_zone_complete_write();
ml_set_interrupts_enabled(istate);
return value;
}
Both pmap_ro_zone_prepare_write() and pmap_ro_zone_complete_write() have empty bodies in this checkout. The local path shows neither a permission toggle in those hooks nor an SPTM mutation RPC. It also does not take the legacy PV-head lock or use the PPL entry stub. Interrupt masking protects this local execution interval; it does not make a multi-byte structure copy atomic to another CPU. Empty hooks.
Non-PPL and disabled-feature cases
The legacy backend with XNU_MONITOR == 0 calls the internal helper directly and uses the regular hw_lock_bit()/unlock variants for its PV lock. Those variants disable preemption on lock and enable it on unlock. The PPL-only lockdown-flag check is compiled out. Its visible implementation still validates and writes through the aperture, but it does not have the PPL entry boundary. Wrapper conditions, lock variants, conditional lockdown check, lock preemption, unlock preemption.
With RO enforcement disabled, zalloc_ro_mut() uses a direct memcpy(R + O, source, N), and zalloc_ro_mut_atomic() calls the atomic helper at R + O; the pmap mutation path is bypassed. This checkout selects that mode under KASAN_CLASSIC. The source/API shape remains, but its protection boundary differs. The prerequisite's fallback section also covers x86 and the opt-in adoption shim; neither is silently included in the PPL/SPTM examples above. Feature condition, copy/atomic fallback.
8. Checked mutation leaves object synchronization with its callers
The low-level API checks the destination zone/range and, for copies under the applicable configuration, the source range. It does not decide whether a syscall, exec transition or policy component is allowed to change a particular field. Nor does a physical-page lock or interrupt mask automatically acquire proc_lock, task_lock, credential synchronization or a reference to a pointed-to object.
The process flag helpers demonstrate the distinction. proc_csflags_set() reads p_csflags, ORs a mask and passes the result to a plain field-copy helper; it is not the task helper's OR32 atomic operation. The selected CS_OPS_MARKHARD caller holds proc_lock() across that update and related validity check. The caller's lock protects that larger process operation; the RO allocator only supplies the checked write. Plain process flag helpers, selected locking caller.
Likewise, proc_setplatformdata() prepares a local three-field proc_platform_ro_data and copies it as one field range. The API performs a byte copy, not a three-field atomic snapshot transaction. task_ro_flags_set() provides a sequentially consistent atomic update of its one word; that alone does not publish a consistent snapshot with control-port options, filters or process metadata. Platform-data update, atomic primitive ordering.
The setters shown here do not promise to lock automatically. The exec version caller runs within the exec lifecycle, task construction initializes private state before normal use, and operations on already shared objects must obey their own locking/publication rules. The process and task chapters connect those rules to ownership and visibility. Read-only storage keeps ordinary stores out of the documented write path; it does not replace those protocols.
9. Clearing a backlink and freeing the object are different operations
Near final task destruction, proc_ro_erase_task() atomically exchanges only pr_task with TASK_NULL using ZRO_ATOMIC_XCHG_LONG; it leaves the process group and the rest of the task data untouched. Task teardown then releases its hold on the combined proc/task allocation. This backlink operation is neither a free of the shared RO record nor a blanket clear of task_data. Backlink erase, task teardown order.
The process/task hold protocol determines when proc_free() can run. Each release clears its hold bit and frees only when the other hold was already absent. proc_free() releases the credential reference, calls zfree_ro() for the shared RO record and then frees the combined mutable allocation. The allocation has no independent proc_ro reference count to increment with every read. Hold-release bodies, final free.
The public zfree_ro macro erases the supplied pointer variable. The underlying function obtains the recorded zone element size, zeroes the whole element through pmap_ro_zone_bzero() when enforcement is enabled, then enters ordinary zfree_ext() slot retirement. zalloc_ro_clear() similarly clears a chosen range through the backend without freeing it. The clear/free operations therefore use the protected write path too; neither repairs other stale pointers or waits for arbitrary readers. Free macro, whole-element zero and free, range clear, SPTM clear backend.
10. Evidence limits
The selected chains were followed from concrete proc/task accessors and writers through macros, allocator checks, mapping setup and the two ARM64 backend bodies. The checked-in PPL entry/return crossing is incomplete; the production SPTM implementation and translation-library internals are external. Those boundaries prevent reconstructing monitor internals or equating PPL and SPTM enforcement from their common exported API names.
This chapter does not enumerate every writer of every field, infer a single binary layout across configurations, or claim that a source-level const view proves immutability. It explains how the selected updates work in this source snapshot. Kernel changes, live-memory manipulation and runtime kernel tests are outside this documentation work.