ba26b52421 [libpas] Add a guard page to the front of the compact-heap reservation
Triage note: Memory-safety hardening making null/zero compact-pointer dereferences fault instead of hitting neighboring VM.
Contents
The bug at a glance
This is a defense-in-depth hardening change, not the fix for a specific exploitable bug, so its intrinsic severity is low; but the failure mode it closes is high-value. Before the patch a zeroed libpas compact pointer decoded to the memory immediately preceding the compact-heap reservation, and any unchecked load/store through a decoded compact pointer (e.g. pas_segregated_directory_data_ptr_load_non_null) could silently read or corrupt whatever mapped VM region happened to sit there. Converting that class of corruption into a deterministic PROT_NONE fault removes a stealthy, exploitation-friendly primitive that any compact-pointer-zeroing bug elsewhere in the engine could otherwise weaponize. It does not fix a memory-safety bug in itself; it degrades one from silent corruption to a reliable crash.
The interesting angle is that libpas’ compact pointers are only 32-bit-ish indices into a fixed VA reservation, so a NULL (zeroed) compact pointer is not an unmapped address the way a NULL machine pointer is; it decodes to a perfectly valid, mapped address at the base of the reservation. This patch recognizes that ‘compact-nullptr’ is a real, dereferenceable location and moves a real guard page there so the ubiquitous ’load_non_null’ fast paths, which by contract skip null checks, fault instead of corrupting a neighbor.
Root cause
libpas maintains a compact heap for metadata objects whose overhead must be minimized. Rather than storing full 64-bit pointers, it reserves a fixed virtual-address range (pas_compact_heap_reservation_size = 1 << PAS_COMPACT_PTR_BITS << PAS_INTERNAL_MIN_ALIGN_SHIFT) and represents each object as an 8-byte-aligned index into that range. A compact pointer is decoded to a machine address roughly as base + (index << align_shift). Indices 0 and 1 are never handed out; because the bump starts at pas_compact_heap_reservation_guard_size, the first real byte of compact memory is reached via index 2.
The pre-patch code created the reservation with pas_page_malloc_try_allocate_without_deallocating_padding and then set pas_compact_heap_reservation_base = page_result.result - pas_compact_heap_reservation_guard_size, with a fixed guard_size of 16. Critically this ‘guard’ was purely arithmetic: base was moved 16 bytes before the actually-allocated, fully-accessible region. There was no PROT_NONE page. So a decoded index of 0 (a zeroed compact pointer) resolved to base + 0, which pointed 16 bytes in front of the real allocation – into whatever adjacent VM mapping existed there. Any of the non-null-checked accessors, the commit names pas_segregated_directory_data_ptr_load_non_null, would then read or write that neighboring memory.
The patch replaces the arithmetic offset with a genuine guard mapping. pas_compact_heap_reservation_guard_size is now set at runtime to pas_page_malloc_alignment() (a full page), and allocation goes through a new pas_page_malloc_try_allocate_with_guard_pages_without_deallocating_padding, which allocates normally and then calls mprotect(result.result, guard_size, PROT_NONE) (VirtualProtect / PAGE_NOACCESS on Windows) over the first guard_size bytes. base is now set directly to page_result.result (no negative offset), the bump still starts at guard_size so real objects live after the guard page, and reservation_end is computed from the full pas_compact_heap_reservation_size. The now-redundant pas_compact_heap_reservation_available_size global is deleted everywhere, including from pas_root and the crash-enumeration path, and the pas_root serialization version (pas_crash_report_version) is bumped 6 -> 7 to keep ReportCrash in sync with the changed layout.
The net effect: index 0/1 (and thus any zeroed compact pointer) now decode into the PROT_NONE guard page, so a wild access faults deterministically at a well-known address instead of corrupting a mapped neighbor. This is a mitigation that turns a silent OOB-relative-to-metadata-base primitive into a crash.
Key code
New guard-page allocator: a real PROT_NONE mapping over the front of the reservation (pas_page_malloc.c)
pas_aligned_allocation_result
pas_page_malloc_try_allocate_with_guard_pages_without_deallocating_padding(
size_t size, pas_alignment alignment, bool may_contain_small_or_medium,
size_t guard_size)
{
pas_aligned_allocation_result result;
PAS_ASSERT(pas_is_aligned(guard_size, pas_page_malloc_alignment()));
PAS_ASSERT(guard_size <= size);
result = pas_page_malloc_try_allocate_without_deallocating_padding(
size, alignment, may_contain_small_or_medium);
if (!result.result || !guard_size)
return result;
#if PAS_OS(WINDOWS)
{
DWORD old_protect;
PAS_ASSERT(VirtualProtect(result.result, guard_size, PAGE_NOACCESS, &old_protect));
}
#else
PAS_SYSCALL(mprotect(result.result, guard_size, PROT_NONE));
#endif
return result;
}
Patch walkthrough
Source/bmalloc/libpas/src/libpas/pas_compact_heap_reservation.c— Changes guard_size from a compile-time 16 to a runtime page size, allocates the reservation via the new guard-page helper (which PROT_NONEs the first page), sets base to the true allocation start instead of base-minus-guard, computes reservation_end from the full size, and removes pas_compact_heap_reservation_available_size.Source/bmalloc/libpas/src/libpas/pas_page_malloc.c— Adds pas_page_malloc_try_allocate_with_guard_pages_without_deallocating_padding: allocates as before, asserts guard_size is page-aligned and <= size, then mprotect(PROT_NONE) / VirtualProtect(PAGE_NOACCESS) the leading guard_size bytes.Source/bmalloc/libpas/src/libpas/pas_page_malloc.h— Declares the new guard-page allocation helper with a comment documenting that the first guard_size bytes become inaccessible to catch decodes to the base of the allocation.Source/bmalloc/libpas/src/libpas/pas_report_crash_pgm_report.h— Bumps pas_crash_report_version 6 -> 7 so ReportCrash and libpas stay in sync after the reservation-layout / pas_root field change.Source/bmalloc/libpas/src/libpas/pas_root.c and pas_root.h— Removes the compact_heap_reservation_available_size pointer from the pas_root introspection struct and its construction, since that global no longer exists.Source/bmalloc/libpas/src/test/IsoHeapChaosTests.cpp— Updates the enumerable page-range assertion to reflect that base already accounts for the guard and reservation_size spans the whole region (drops the extra + guard_size on the end bound).
Background
libpas compact heap — libpas is WebKit’s memory allocator (bmalloc’s successor engine). It maintains a dedicated ‘compact heap’ for internal metadata objects such as segregated-directory data. To cut per-object pointer overhead these objects are addressed by a compact pointer that is a narrow index into a single, fixed virtual-address reservation rather than a full 64-bit machine pointer. The reservation is sized as (1 << PAS_COMPACT_PTR_BITS) << PAS_INTERNAL_MIN_ALIGN_SHIFT.
Compact pointers and compact-nullptr — A compact pointer stores an 8-byte-aligned index; decoding multiplies the index by the minimum alignment and adds the reservation base. Because the base already carries a guard offset, indices 0 and 1 are never allocated and index 2 addresses the first real byte. The subtle consequence is that a zeroed compact pointer is not an unmapped machine NULL: it decodes to reservation base + 0, a valid mapped address, so a null compact pointer silently aliases the front of the reservation.
load_non_null fast paths — Accessors such as pas_segregated_directory_data_ptr_load_non_null are contractually allowed to skip null checks for speed, assuming the caller guarantees a non-null compact pointer. If a bug elsewhere zeroes a compact pointer, these accessors happily decode index 0 and dereference it. Pre-patch that dereference hit memory 16 bytes before the reservation – a neighboring VM region – giving an attacker a corruption or disclosure primitive relative to a fixed, allocator-controlled base.
PROT_NONE guard pages — A guard page is a mapping set to PROT_NONE (POSIX mprotect) or PAGE_NOACCESS (Windows VirtualProtect) so any access faults. The pre-patch libpas guard was only arithmetic – base was shifted before an otherwise-readable/writable region – so it caught nothing. The patch installs a real inaccessible page at the reservation front, converting decodes of index 0/1 from silent neighbor access into a deterministic SIGSEGV/access violation at a predictable address, which is far easier to detect and far harder to exploit.
pas_root and pas_crash_report_version — pas_root is an introspection structure holding pointers to libpas globals so out-of-process tools (notably Apple’s ReportCrash and the PGM/probabilistic-guard-malloc crash reporter) can enumerate heap state from a crashed process. Because this patch removes the compact_heap_reservation_available_size global, its pointer must be dropped from pas_root, and pas_crash_report_version is bumped from 6 to 7 to signal the incompatible extraction-layout change to ReportCrash.
Vulnerability window
- Original design — Compact heap uses a fixed VA reservation with a purely arithmetic 16-byte guard offset: base = allocation - 16, with no page protection. Indices 0/1 are reserved by convention only.
- Latent exposure — Any code path that can zero a live compact pointer (a use-after-free reuse, an uninitialized field, an OOB write into metadata) yields a decode to base+0 that non-null accessors dereference into the mapped region just before the reservation.
- Recognition — Maintainers (rdar://177469163, webkit.org bug 315126) identify that ‘compact-nullptr’ accesses corrupt neighboring memory rather than crashing, an exploitation-friendly failure mode worth hardening.
- Fix — Add pas_page_malloc_try_allocate_with_guard_pages_without_deallocating_padding and mprotect the first real page of the reservation PROT_NONE; set guard_size to a full page and base to the true allocation start.
- Bookkeeping — Delete pas_compact_heap_reservation_available_size, prune pas_root and its construction, bump pas_crash_report_version 6->7, and fix the IsoHeapChaosTests range assertion.
- Result — Zeroed-compact-pointer dereferences now fault deterministically on the guard page; the change ships without a new test as ‘an implementation detail’ (mitigation).
Triggering
No PoC or new test is added; the commit states ‘No new tests as this is an implementation detail.’ To exercise the guard, one would need an independent primitive that zeroes a live compact pointer to a segregated-directory metadata object and then triggers a load via pas_segregated_directory_data_ptr_load_non_null; pre-patch this corrupts memory ~16 bytes before pas_compact_heap_reservation_base, post-patch it faults on the PROT_NONE page at the reservation base.
Exploitation
- Nature of the change (mitigation) — This commit fixes no reachable bug on its own; it hardens a failure mode. Exploitation here means what an attacker LOSES: the ability to convert an unrelated compact-pointer-zeroing bug into silent, base-relative metadata corruption.
- Pre-patch primitive — Given any bug that zeroes a compact pointer, a subsequent non-null load decodes index 0 to base+0 and touches the neighboring VM region. Since the reservation base is allocator-fixed, the corrupted target is at a stable relative offset, potentially useful for shaping metadata that governs later allocations.
- Post-patch outcome — The same bug now lands on a PROT_NONE page and crashes deterministically at the reservation base – no corruption, no disclosure, just a reliable, easily-triaged fault. The primitive is neutralized as a corruption vector.
- Residual — Only index-0/1 decodes are covered; a bug producing a small-but-nonzero bogus index still lands inside live metadata. The guard defends specifically against the compact-nullptr case, so attackers with a controllable-index corruption are unaffected.
Detection & hunting
For defenders and SOC / detection engineers:
- Crash at compact-heap reservation base — Watch for SIGSEGV/EXC_BAD_ACCESS (or Windows access violations) whose faulting address equals pas_compact_heap_reservation_base or its first page. Post-patch such crashes are the guard firing on a compact-nullptr decode and strongly indicate an upstream memory-safety bug being exploited or fuzzed.
- ReportCrash version 7 PGM reports — Detection/triage tooling parsing libpas crash reports must handle pas_crash_report_version == 7 and the removal of compact_heap_reservation_available_size; a spike of version-7 guard-page faults is a signal to hunt for the zeroing bug.
- *Faults in _load_non_null accessors — Stack traces terminating in pas_segregated_directory_data_ptr_load_non_null (or sibling load_non_null helpers) with a base-of-reservation fault address pinpoint a null compact pointer reaching a fast path that assumed non-null.
Audit directions
- Compact-pointer zeroing sites — Audit all writers of pas_compact_*_ptr fields for paths that can leave a live compact pointer zero (partial init, error unwinding, freed-object reuse) since those are precisely what this guard now catches at runtime.
- Other non-null fast paths — Enumerate every *_load_non_null / assumed-non-null compact accessor and confirm each caller truly guarantees non-null; the guard converts violations to crashes but the underlying contract gaps remain latent bugs.
- Guard coverage vs index range — Verify the single-page guard actually covers every index that a zeroed or small-corrupted compact pointer can decode to given PAS_INTERNAL_MIN_ALIGN_SHIFT; a small nonzero index can still skip past one page into live metadata.
- Cross-tool layout sync — Confirm all out-of-process consumers of pas_root (ReportCrash, PGM tooling, enumerators) were updated for the removed field and version bump; a stale reader could misparse crash state and mask the very corruption this guard surfaces.