Memory Hardening

July 16, 2026 ยท View on GitHub

Beyond mapping memory correctly, the memory subsystem enforces a set of safety invariants: no page is ever both writable and executable, guard pages catch access that runs off the end of a region, stack canaries catch overflow, and allocation tracking catches double-free and use-after-free. The write-execute rule is enforced on the mapping path itself; the rest is managed by a single hardening manager with a running tally of every violation it has seen. This page documents each, and in particular it is where the W^X enforcement point named on the paging manager page is actually located. The code is under src/memory/hardening/, and the enforcement gate is in the paging manager.

Write-execute exclusion, and where it is enforced

The invariant is that no page is both writable and executable. It is enforced at the moment a mapping is installed, in map_page_in_asid (src/memory/paging/manager/mapping/map_in_asid.rs:37):

  map_page_in_asid(asid, va, pa, permissions, size, stats):
      if not initialized                    -> NotInitialized
      if permissions.is_wx_violation()      -> WXViolation
      pte_flags = permissions.to_pte_flags()
      install the mapping, record the stat

The check runs before the page-table entry is computed, so a permission set that is both WRITE and EXECUTE is rejected with PagingError::WXViolation and no PTE is ever written for it. This is the real gate: the paging manager will not install a W+X page into any address space, so the invariant holds by construction of every mapping rather than by a scan after the fact. The permission-model side of it, the is_wx_violation predicate, is on the paging page; this is the enforcement.

The hardening manager carries a second form of the same check for callers that reason in terms of writable and executable booleans rather than a permission set, validate_wx_permissions (src/memory/hardening/manager/validation.rs:18):

  validate_wx_permissions(addr, writable, executable):
      if writable and executable:
          increment the W^X violation counter
          return Err("W^X violation: memory cannot be both writable and executable")

It is exposed as validate_memory_permissions (hardening/manager/api.rs:20), and a rejection here also bumps the wx_violations statistic so the manager can report how many attempts it has refused.

Guard pages

A guard page is an address the manager marks so that any access to it is a detected violation rather than a silent read or write. They are added and removed through the API (hardening/manager/api.rs:50):

  add_guard_page(addr, guard_type)     insert a GuardPage of one page at addr
  remove_guard_page(addr)              remove it, error if not present
  check_guard_page_access(addr)        true if addr is a guard page (+ counter)

add_guard_page records a GuardPage { addr, size: PAGE_SIZE, protection_type } in a map keyed by address. check_guard_page_access (api.rs:28) tests whether a faulting address is in that map, and if it is, increments the guard-violation counter and returns true, so the fault path can treat a guard-page hit as a deliberate boundary being crossed rather than an ordinary fault. Guard pages are how the manager turns the region just past a stack or a buffer into a tripwire.

Stack canaries

Stack overflow is caught with canaries (hardening/manager/api.rs:78):

  setup_stack_canary(stack_base, stack_size):
      value = generate_stack_canary()
      record StackCanary { value, stack_base, stack_size }
      write value volatile at stack_base + stack_size - 8
      return value
  check_stack_canary(stack_base)   verify the canary is intact
  clear_stack_canary(stack_base)   remove it

setup_stack_canary generates a canary value, records it, and writes it with a volatile store eight bytes below the top of the stack, the location a write that ran off the end of the stack would overwrite first. check_stack_canary (check_stack_integrity) reads it back and fails if it has changed, which is the signal that the stack overflowed into its guard word. The volatile write ensures the compiler cannot elide the canary store as dead.

Allocation tracking

The manager tracks allocations to catch lifetime errors (api.rs:37):

  track_allocation(addr, size)     record a live allocation
  track_deallocation(addr)         retire it, detecting a double free
  validate_heap_integrity(addr, size)   detect heap corruption over a range

track_allocation records an allocation and track_deallocation retires it; a deallocation of an address that is not currently tracked is a double free, and an access to a retired address is a use-after-free, both of which the tracker can detect and count. validate_heap_integrity runs detect_heap_corruption over a range to find metadata that has been overwritten.

The violation tally

Every check above feeds one running snapshot (api.rs:64):

  HardeningStatsSnapshot
    guard_violations  wx_violations  stack_overflows  heap_corruptions
    double_frees      use_after_free
    total_guard_pages  active_canaries  tracked_allocations

get_hardening_stats returns it, so the number of W^X mappings refused, guard pages hit, canaries broken, and lifetime errors caught is observable at runtime, alongside the count of guard pages, canaries, and allocations currently tracked.

The CPU protection bits

The software checks above are backed by hardware enforcement the manager turns on at init. init_module_memory_protection (hardening/manager/verify/protection.rs:20) sets three control-register bits and is called from init_all_memory_subsystems (memory/unified/system.rs:32):

  init_module_memory_protection():
      enable_write_protection()          set CR0.WP  (bit 16)
      cr4 |= CR4_SMEP                    set CR4.SMEP (bit 20)
      cr4 |= CR4_SMAP                    set CR4.SMAP (bit 21)

enable_write_protection (paging/tlb/write_protect.rs:18) sets CR0.WP, so a read-only page is read-only even to ring 0: the kernel cannot accidentally write through a mapping it marked read-only, which is what makes the read-only mappings the manager installs actually enforced. CR4.SMEP stops the kernel from fetching instructions out of any user page, and CR4.SMAP stops it from reading or writing user pages except through an explicit stac/clac window. SMAP is why the usercopy boundary reaches user bytes through the direct map rather than dereferencing the user pointer: with SMAP on, a direct kernel-mode load from a user address faults. The bits are set only if not already set, so re-init is safe.

Security analysis

Hardening is defence in depth: the mapping path and the allocators are already correct, and this subsystem adds invariants that turn a bug that slips past them into a caught, counted event rather than a silent corruption. Four properties carry that.

W^X holds by construction. The real gate is map_page_in_asid (mapping/map_in_asid.rs:37), which rejects any permissions.is_wx_violation() set with PagingError::WXViolation before it computes a page-table entry. No page in any address space is ever both writable and executable, because no such PTE is ever written, not because a scan catches it after the fact. The validate_wx_permissions form (validation.rs:18) is the same rule for callers reasoning in booleans, and both feed the wx_violations counter. Combined with SMEP and NX on the device and DMA helpers, a writable page is never a code-injection target.

Kernel writes respect read-only, kernel fetches respect user. CR0.WP, CR4.SMEP, and CR4.SMAP move three of these guarantees from convention into the hardware. Without WP the kernel could write through its own read-only mappings; without SMEP it could be tricked into executing a user page; without SMAP it could dereference an unvalidated user pointer. Turning all three on is what makes the W^X and usercopy boundaries enforced rather than merely intended. The honest boundary: the code assumes the CPU supports these bits and sets them unconditionally, it does not branch on a CPUID probe here, so on hardware lacking SMEP/SMAP the write to CR4 is a no-op for those bits and the software checks are the only line left.

Guard pages and canaries are overrun tripwires. A guard page (api.rs:50) marks an address so that a fault on it is reported as a deliberate boundary crossing (check_guard_page_access, api.rs:28) rather than an ordinary miss, and the page-fault path checks it first (page_fault.rs:63). A stack canary (api.rs:78) is a value written with a volatile store eight bytes below the top of a stack, the first thing an overflow overwrites, and check_stack_canary fails if it changed. Both catch an off-the-end write that the mapping permissions alone would not.

Lifetime errors are detectable and counted. track_allocation / track_deallocation (api.rs:37) let the manager name a double free (deallocating an untracked address) and a use-after-free (touching a retired one), and the frame allocator has its own hard DoubleFree check underneath. The honest limit throughout this subsystem is that the tracker and canaries are a best-effort layer the caller must actually invoke; they are not automatic on every allocation, so they catch the paths that opt in. The W^X gate and the CPU bits, by contrast, are unconditional.

Debugging hardening

The hardening subsystem is designed to make a violation visible rather than fatal-and-silent, so the first tool is the running tally. get_hardening_stats returns the HardeningStatsSnapshot (api.rs:64) with one counter per class:

  guard_violations   a guard page was hit          (add_guard_page had marked it)
  wx_violations      a W+X mapping was refused      (map_in_asid or validate_wx_permissions)
  stack_overflows    a canary came back changed
  heap_corruptions   detect_heap_corruption fired over a tracked range
  double_frees       a deallocation of an untracked address
  use_after_free     an access to a retired address
  total_guard_pages  active_canaries  tracked_allocations   what is currently live

A non-zero wx_violations with the system still running means the gate did its job: a caller tried to map a W+X page and was refused with WXViolation, no PTE was written, and the count is the evidence. On the fault side, the exact strings come from the page-fault handler, not this module: Guard page violation detected (page_fault.rs:64) is printed when a faulting address matches a registered guard page, and the "Attempted to execute from non-executable page" line on a KERNEL PANIC is an NX or W^X hit reaching hardware. So a guard-page overrun shows up twice, as a console line at the fault and as an increment of guard_violations, and reading them together distinguishes a deliberate tripwire from an ordinary miss. The W^X refusal string itself, "W^X violation: memory cannot be both writable and executable" (validation.rs:26), is what a caller sees when it asks for a writable-executable mapping.

Where this connects

The W^X gate sits on the paging manager's install path, so it applies to every mapping the manager makes, including the ones the fault handlers install, and the guard-page check is consulted first by the page-fault handler. The guard pages and canaries protect the per-process kernel stacks the page allocator carves and the heap. The SMAP bit set here is what forces the usercopy boundary to go through the direct map. The W^X invariant is also one of the properties the verification stack proves at the encoding level, in the kernel isolation proofs.

Source map

  src/memory/paging/manager/mapping/map_in_asid.rs    the W^X enforcement gate
  src/memory/hardening/manager/validation.rs           validate_wx_permissions, guard test
  src/memory/hardening/manager/api.rs                  the hardening surface
  src/memory/hardening/manager/verify/protection.rs    CR0.WP / SMEP / SMAP enablement
  src/memory/paging/tlb/write_protect.rs               enable_write_protection (CR0.WP)
  src/memory/hardening/types/                          GuardPage, StackCanary, snapshots
  src/memory/hardening/stats/record.rs                 the violation counters

Every reference above is verified against those trees. The W^X predicate is on the paging manager page, the guard-page consumer is the page-fault handler, and the SMAP-gated copy boundary is on the usercopy page.