---
title: "Applying CR0.WP to Audit Integrity: What It Improves, and Where the Gaps Are"
slug: cr0wp-making-kernel-memory-tamper-proof
date: 2026-08-11
category: THE_LEDGER
depth: into_the_code
author: Stephane
abstract: CR0.WP is a decades-old x86 mechanism that Linux, Windows, and macOS all use to protect kernel code. We're applying it to a different problem — making a write-once audit ledger tamper-resistant even against the kernel that wrote it. Here's what that looks like, what it actually improves, and where it still falls short.
---

# Applying CR0.WP to Audit Integrity: What It Improves, and Where the Gaps Are

CR0.WP is not new. Linux has used it to protect kernel text and rodata pages for over a decade — `mark_rodata_ro()` at boot, `STRICT_KERNEL_RWX` as a config option since 2016. Windows PatchGuard depends on it. macOS/XNU uses it for kernel code pages. OpenBSD is more aggressive about it than any of them.

We're not inventing the mechanism. What we're doing is applying it to a different problem: making a write-once audit ledger tamper-resistant even against the kernel that wrote it. This post is about what that application looks like in practice, what it actually achieves, and where it still falls short.

---

## Why This Matters More for Agents

When a human operator does something unexpected, there's a fallback: you can ask them. They have memory, intent, context. They can be questioned, and their answers add signal. When an AI agent takes an action — spending budget, calling an API, deploying a change, deleting data — none of that fallback exists. The audit log is not a backup record of what happened; it *is* the record. If it can be corrupted, post-hoc oversight becomes reconstruction from nothing.

The second difference is scale. A human engineer might make fifty significant decisions in a day. An agent might make fifty thousand. That volume means the log is the only tractable oversight mechanism — there's no practical way to reconstruct history from interviews or memory at agent execution rates. The audit record is how a principal knows what its agents did.

The third is timing. Humans have a real-time oversight loop during execution: they notice something is wrong and stop. Autonomous agents don't. The first place anyone looks after an unexpected outcome is the audit log. If the agent's own execution corrupted that log — through a bug, through a memory error, through anything — the only interpretable record of the event is gone.

In a multi-agent system like Noesis, there's a fourth issue: agent-to-agent trust. If Agent A can modify its own ledger, Agent B has no reliable basis to trust what Agent A claims to have done. Tamper-resistant audit is what makes signed, federated agent records meaningful — not just a claim, but a property the architecture enforces.

Noesis makes this concrete: every credential use is a ledger event written at the kernel level, at the moment of injection, before the agent ever sees the key. An agent that could erase that event could deny having used a credential — or having exceeded its authorization scope. Hardware-enforced audit isn't an audit framework bolted on for compliance. It's the property that makes agent authorization legible after the fact.

---

## The Problem: Audit Logs That Can Be Erased by the Audited System

Every major OS audit framework — Linux `auditd`, Windows Event Log, macOS BSM — has the same structural problem: the kernel it's auditing is also the kernel it relies on to store and protect the audit records. A sufficiently privileged attacker who controls the kernel can modify or erase the logs. The audit system and the threat model share the same trust boundary.

The standard response to this is to ship logs out of the system as fast as possible — remote syslog, SIEM forwarding, write-once object storage. That works, but it's a race condition at boot and it depends on network availability. If an attacker moves fast enough, or the system is air-gapped, logs can be lost.

We want a different property for Noesis's ledger: once an event is written and sealed, it cannot be erased or modified by any code path — including kernel code — without that attempt itself being recorded. The question is how close we can get to that goal, and CR0.WP is one tool in that direction.

---

## How Other OSes Use CR0.WP

The standard application is protecting static, always-immutable data: kernel code and read-only data.

At boot, Linux marks kernel text pages read-only by clearing the PTE Writable bits, then sets CR0.WP so the kernel itself cannot accidentally (or deliberately) overwrite its own instructions. The pages that get this treatment never need to be written again after boot. The protection is permanent and unconditional.

This is a well-understood use case and the hardware primitive fits it perfectly: immutable data, sealed once at boot, and the CPU enforces it from that point forward.

---

## The Different Application: Data That Starts Mutable and Gets Sealed

Audit log entries are not immutable from the start. An event has to be written somewhere first. The ledger segment starts writable, accumulates events as the kernel runs, and then at some point is full and should never change again.

This is a different lifecycle: the data is mutable during construction, then permanently read-only once sealed. The seal is a one-way transition, and after it happens, we want the same hardware guarantee Linux gets for kernel code — but for a data structure that was being written to minutes earlier.

In Noesis, each ledger segment is a `#[repr(C, align(4096))]` struct occupying complete 4 KiB pages. When a segment is full, `seal_resident_ledger_segment_page()` runs a sequence:

1. Validates the segment hasn't already been sealed
2. Copies every event into the page-aligned struct with sequence-number continuity validation
3. Sets `page.sealed = 1` as a logical flag
4. Calls `protect_resident_page_aligned_region()` — walks the PTE for every page of the struct, clears `PTE_WRITABLE`, then calls `enable_write_protect()`
5. Returns an error and refuses to complete if `write_protect_enabled` comes back false

Step 5 matters: if the hardware didn't take the CR0.WP write — because a hypervisor intercepted it, or something else went wrong — the segment is not considered sealed and the operation fails. We do not silently degrade to a software-only flag.

Here is `enable_write_protect()` from `arch/x86_64.rs`:

```rust
pub fn enable_write_protect() -> bool {
    unsafe {
        let mut cr0 = read_cr0();
        cr0 |= 1 << 16; // CR0.WP: supervisor writes respect read-only PTEs.
        write_cr0(cr0);
        read_cr0() & (1 << 16) != 0
    }
}
```

And `clear_writable_for_page()` from `memory.rs`, which does the PTE walk for each page of the sealed region:

```rust
fn clear_writable_for_page(
    root_physical: u64,
    direct_map: u64,
    virtual_address: u64,
) -> Result<bool, PageTableProtectError> {
    let pml4 = page_table_mut(root_physical, direct_map);
    let pml4e = unsafe { &mut *pml4.add(page_table_index(virtual_address, 39)) };
    if *pml4e & PTE_PRESENT == 0 {
        return Err(PageTableProtectError::MissingMapping);
    }

    let pdpt = page_table_mut(*pml4e & PTE_ADDR_MASK, direct_map);
    let pdpte = unsafe { &mut *pdpt.add(page_table_index(virtual_address, 30)) };
    if *pdpte & PTE_PRESENT == 0 { return Err(PageTableProtectError::MissingMapping); }
    if *pdpte & PTE_HUGE_PAGE != 0 {
        return clear_large_page_writable(pdpte, virtual_address, 1 << 30);
    }

    let pd = page_table_mut(*pdpte & PTE_ADDR_MASK, direct_map);
    let pde = unsafe { &mut *pd.add(page_table_index(virtual_address, 21)) };
    if *pde & PTE_PRESENT == 0 { return Err(PageTableProtectError::MissingMapping); }
    if *pde & PTE_HUGE_PAGE != 0 {
        return clear_large_page_writable(pde, virtual_address, 1 << 21);
    }

    let pt = page_table_mut(*pde & PTE_ADDR_MASK, direct_map);
    let pte = unsafe { &mut *pt.add(page_table_index(virtual_address, 12)) };
    if *pte & PTE_PRESENT == 0 { return Err(PageTableProtectError::MissingMapping); }
    let was_writable = *pte & PTE_WRITABLE != 0;
    *pte &= !PTE_WRITABLE;
    Ok(was_writable)
}
```

At boot, `seal_resident_static_regions()` does a bulk seal for the initial protected set — the ledger protection page, the credential ingress buffer, and the service manifest. All PTEs are cleared first across every region, then CR0.WP is set in a single call. This avoids any window where some pages are hardware-protected and others are not.

---

## The Fault Response: Audit-Survivability Over Recovery

After sealing, any write to a protected page raises a hardware #PF. Most OS page fault handlers try to recover — map a new page, expand a stack, resume the faulting thread. Our handler does something different:

```rust
extern "C" fn noesis_page_fault_interrupt_rust(error_code: u64) {
    let address = crate::arch::read_page_fault_address();
    let count = crate::memory::record_resident_memory_violation(address, error_code);
    let ledger_recorded =
        crate::os_boot::append_resident_memory_violation_ledger_event(
            address, error_code, count
        );
    let _ = crate::boot_diagnostics::emit_stage_beacon(
        "SERVICE_MEMORY_VIOLATION", address, error_code
    );
    crate::arch::halt();
}
```

Read the faulting address from CR2. Record it atomically. Then `append_resident_memory_violation_ledger_event()` does the heavier work — it writes a ledger event describing the violation, seals the segment containing that event, and submits the NVMe write. The handler doesn't do those steps directly; they happen inside that call before it returns. `emit_stage_beacon()` records a diagnostic trace. Then halt permanently.

The sealing and NVMe submission should happen synchronously before `halt()` is reached — though whether the NVMe controller completes the write before the CPU stops is the open question in the gaps section below.

The design choice is audit survivability over continued execution: an auditor reading the NVMe partition after a suspicious halt should find a signed ledger event describing the tamper attempt, not a corrupted log and a mystery.

---

## What This Actually Improves

Against **accidental writes** — a kernel bug writing to the wrong address, a pointer error, uninitialized memory stomping into a sealed region — the protection should be effective. The fault fires, the event is recorded, the system halts. That said, the SMP TLB gap means another CPU core could write through a stale cached entry in the window immediately after sealing — so "complete" would be the wrong word until TLB shootdown is in place.

Against **ordinary kernel code** running normally without deliberately manipulating control registers — same story. Normal code respects CR0.WP. A normal write to a sealed page should fault.

What this means in practice: the sealed ledger should be robust against the entire class of accidental and unintentional corruption. Any kernel path that could inadvertently clobber a sealed page should instead produce a permanent, attributed fault record. That's a real improvement over a purely software audit flag, which a bug could silently overwrite.

---

## Where It Still Falls Short

We want to be direct about the limits, because they're significant.

### Ring-0 can disable CR0.WP in three instructions

```rust
write_cr0(read_cr0() & !(1 << 16));  // disable WP
*sealed_page_ptr = tampered_value;    // write succeeds
write_cr0(read_cr0() | (1 << 16));   // re-enable WP
```

This is the standard technique used by Linux kernel rootkits to patch read-only kernel memory — the same memory Linux protects with the same mechanism. It's well-documented and trivial to execute from ring-0. Against a deliberate attacker who already has kernel code execution, CR0.WP provides no protection at all.

The analog in Linux is that `STRICT_KERNEL_RWX` doesn't stop an attacker with kernel code execution either — it stops bugs and makes exploitation harder, not impossible. We're in the same position.

What we're investigating: whether a hypervisor trap on CR0 writes could provide the next layer, at the cost of unconditional hypervisor trust. AMD SEV-SNP and Intel TDX change the hypervisor trust model in ways that might be relevant here. If you've worked through this, we'd like to hear what you found.

### DMA bypasses the MMU entirely

CR0.WP controls CPU writes through the MMU. A DMA-capable device — our GVNIC NIC, the NVMe controller, any PCIe peripheral — writes directly to physical memory without going through the MMU at all. Sealed page PTE flags mean nothing to a device doing DMA.

Closing this gap requires IOMMU (Intel VT-d or AMD-Vi) with sealed pages' physical address ranges excluded from device-accessible memory. We have not implemented this yet. We're investigating what GCP bare metal exposes in terms of IOMMU control and what the implementation cost looks like.

### SMP: `invlpg` only flushes the local CPU's TLB

After `clear_writable_for_page()` returns, the wrapper `protect_resident_page_aligned_region()` issues `invlpg` for that virtual address. On a multi-core system this only invalidates the TLB entry on the CPU running the invalidation. Other cores may retain a cached translation with the Writable bit still set and continue writing to the page without faulting until their TLBs are also invalidated.

This is a correctness gap on every multi-core machine we run on. Proper TLB shootdown — an IPI to all CPUs, each running `invlpg`, confirmation before proceeding — is on our near-term list. It's a known, well-understood fix; we just haven't landed it yet.

### NVMe write completion before halt

The fault handler triggers an NVMe write before halting. NVMe is asynchronous — a write command submitted to the submission queue may not have been processed by the controller when `halt()` executes. If the CPU halts before the DMA transfer completes, the fault record may not reach the medium.

We need an explicit completion poll before halt. We're not certain the current implementation handles this correctly. If you've solved this pattern in a bare metal context — synchronous NVMe flush before halt, without a full async completion stack — we'd like to know how you did it.

### The hypervisor boundary

On GCP bare metal, a hypervisor controls the physical memory the guest kernel believes it owns. It can modify guest physical pages directly, bypassing everything in the guest. It can also intercept and no-op the CR0 write.

This is an unconditional trust boundary. Everything above assumes the hypervisor is honest. For a cloud deployment that assumption is load-bearing and we're not pretending otherwise. Confidential computing environments (AMD SEV-SNP, Intel TDX) address parts of this, but they don't change the DMA picture and they don't eliminate side-channel exposure. We're watching this space closely.

---

## What We've Verified

The build includes a fault injection path enabled by `NOESIS_PAGE_PROTECTION_FAULT_INJECTION`. After sealing, the kernel deliberately attempts a volatile write to a sealed page:

```rust
pub unsafe fn deliberate_resident_ledger_static_overwrite_for_validation() {
    let base = if resident_ledger_segment_page_is_sealed(0) {
        resident_ledger_sealed_segment_page_ptr(0) as *mut u8
    } else {
        core::ptr::addr_of_mut!(RESIDENT_LEDGER_PROTECTION_PAGE) as *mut u8
    };
    core::ptr::write_volatile(base, 0xa5);
}
```

When CR0.WP is active and PTEs are cleared, this raises a #PF, the handler runs, and the machine halts with a ledger record. This confirms the mechanism works against the class of normal writes. It says nothing about the gaps above.

---

## Where We're Going

The near-term list: SMP TLB shootdown, synchronous NVMe completion before halt. The medium-term investigation: IOMMU protection for DMA, and whether a hypervisor trap on CR0 writes is worth the added trust dependency.

The larger question — whether hardware-enforced audit with meaningful adversarial guarantees is achievable at all in a cloud deployment without confidential computing primitives — is something we're still working through.

If you've built something in this space, seen a gap we've missed, or have a different approach to any of the open problems above, we'd like to hear it. The contact is below.

---

*— Stephane, Pensiv Inc.*
*Contact: stephane@pensiv.work*
