merge: security track group 4 — SMEP, the SYSRET guard, and SMAP

# Conflicts:
#	docs/security-track-plan.md
This commit is contained in:
Daniel Samson 2026-08-01 12:20:54 +01:00
commit a85f29edc9
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
13 changed files with 757 additions and 38 deletions

View File

@ -182,6 +182,26 @@ test for "is this an abbreviation I must expand" is simply: *is there a longer w
is a clipped form of?* If yes, write the word. If it's an initialism standing in for a
phrase, leave it.
## Kernel code touches user memory only through `user-memory`
A syscall argument is an attacker-controlled integer. Kernel code never
dereferences one: every read of a process's memory goes through
`copyFromUser` and every write through `copyToUser`
(`system/kernel/user-memory.zig`), which walk that address space's page tables
and move the bytes through the physmap, with the permissions ring 3 itself
would face. A bad pointer then fails the call instead of faulting the kernel,
and a struct pulled in once cannot change underneath the checks that follow it.
This is not a review convention — CR4.SMAP enforces it in hardware
(`docs/os-development/smep-smap.md`), so a raw dereference of a user address is
a #PF with a kernel instruction pointer the first time the QEMU suite reaches
it. Which is also why **there is no `stac` in this tree, and never should be**:
`stac` suspends exactly that enforcement, the copy layer needs no such window
by construction, and a change that adds one has removed the guarantee rather
than worked around a limitation. The same goes for the boot-time `clac` patch
at the interrupt entry — it exists so that ring 3 cannot suspend SMAP either,
by taking an interrupt with `EFLAGS.AC` set.
## Zen of Zig
* Communicate intent precisely.

View File

@ -1,6 +1,7 @@
# SMEP and SMAP — supervisor-mode hardening
*Design, 2026-07-31. Not yet implemented. Companion to
*Design, 2026-07-31. H1 (the copy layer), H2 (SMEP), HS (the SYSRET guard) and
H3 (SMAP) have all landed; the track is complete. Companion to
[protocol-namespace.md](protocol-namespace.md) on the security track — this is
the hardware half; that is the namespace half.*
@ -136,6 +137,11 @@ Broadwell+ for SMAP; AMD Zen+ for both). The test images should run with
hardening bucket, independent fix (validate RCX before `sysretq`, fall
back to `iretq`), should ride the same branch as H2/H3 but is not
SMEP/SMAP.
**Status — landed 2026-08-01 (HS).** The syscall exit sign-extends the
return RIP from bit 47 (danos is 4-level only; nothing sets CR4.LA57) and
falls back to `iretq` when that changes it, counting each refusal for the
`sysret-canonical` case. Ring 3 could reach it: `syscall` as the last two
bytes of the last canonical page returns to `user_half_end`.
- **KPTI / Meltdown-class leaks are out of scope.** SMEP/SMAP police
architectural accesses, not speculative ones. danos runs one kernel
mapping in every address space and accepts that on affected hardware;

View File

@ -36,11 +36,11 @@ plain `main` checkout always tells the truth about where the work is.**
| | |
|---|---|
| Working on | **H3** — SMAP (the last phase) |
| Branch carrying it | `feat/security-group-4` (pushed to origin) |
| On `main` | everything through P4c — groups 1, 2 and 3 merged |
| Awaiting merge | H2, HS — land with the group 4 merge |
| Suite | 113 cases, all passing |
| Working on | nothing — the track is complete |
| Branch carrying it | |
| On `main` | every phase — groups 1, 2, 3 and 4 merged |
| Awaiting merge | nothing |
| Suite | 114 cases, all passing |
| Last updated | 2026-08-01 |
A checkbox below means the phase met its definition of green and was
@ -95,8 +95,27 @@ group boundary.
counter, not the outcome: measured with the guard's branch removed, TCG does
not model Intel's ring-0 #GP and every outcome check still passed. Suite
113/113)
- [ ] **H3** — SMAP + boot-patched `clac`; `-cpu max` in the harness; negative tests
- [ ] **merge** group 4 → main, push
- [x] **H3** — SMAP on every core, and the boot-patched `clac` that makes it
hold. Hardware does not clear `EFLAGS.AC` on interrupt delivery and ring 3
sets it with `popfq`, so without the patch a hostile process suspends SMAP
for the length of any handler it can provoke; `clac` is #UD without the
feature, so the image ships the 3-byte canonical NOP at the first byte of
`isr_common` — ahead of the CPL test, because a fault nested in ring 0
inherits AC just as readily — and the boot processor overwrites it through
the physmap, the same door `smp.arm` and `process.run` already use to write
a page the executing mapping holds read-only. The two halves are wired
together rather than merely ordered: `initHardening` refuses CR4 bit 21
until the patch has been written *and* read back through the text address it
will be fetched from, so no core can turn SMAP on ahead of it and a
translation that lied leaves the machine unhardened and saying so instead of
enforcing over an entry path that cannot clear AC. The `smp` case now
requires SMAP on every core it lands on, which is the ordering checked from
the far end. New `fault-smap` case reads a mapped user page from ring 0 and
requires the fault: error code `0x1` — present, and nothing else, since a
SMAP violation has no bit of its own and U/S reports the ring of the access.
The suite is now the standing enforcement test, and it found nothing left to
find: H1's conversion of the nine stragglers was complete. Suite 114/114
- [x] **merge** group 4 → main, push
---
@ -547,6 +566,11 @@ review-level proof plus the existing fault cases regression. Suite 112.
**Test:** new QEMU case `fault-smap`: ring-0 deliberate read of a mapped
user page; expect vector 14 + `error code : 0x1` + kernel IP. And the
whole suite becomes the tripwire — any missed straggler now fails loudly.
*(Landed: the patch site sits at the very first byte of `isr_common`, and
CR4.SMAP is refused on every core until the patch has been read back through
the text mapping it will execute from — so the ordering is enforced, not
merely documented. Error code observed: `0x1` exactly, present and nothing
else. The tripwire found no missed straggler: H1 had converted them all.)*
Suite 114 (HS added one).
---

View File

@ -11,6 +11,7 @@
const boot_handoff = @import("boot-handoff");
const io = @import("io.zig");
const cpuid = @import("cpuid.zig");
const paging = @import("paging.zig");
/// The ACPI PM timer, as a calibration reference: an I/O port or MMIO counter.
@ -331,8 +332,8 @@ fn calibratePit() void {
/// TSC frequency from CPUID leaf 0x15 (crystal_hz * numerator / denominator), or
/// null if the CPU doesn't enumerate it (common under QEMU, and on AMD).
fn cpuidTscHz() ?u64 {
if (cpuid(0).eax < 0x15) return null;
const r = cpuid(0x15);
if (!cpuid.supports(0x15)) return null;
const r = cpuid.leaf(0x15);
if (r.eax == 0 or r.ebx == 0 or r.ecx == 0) return null; // ratio/crystal not given
return @as(u64, r.ecx) * r.ebx / r.eax;
}
@ -342,26 +343,8 @@ fn cpuidTscHz() ?u64 {
/// at a constant rate across P/C-states and never stops. Requires the extended-leaf
/// range to reach 0x80000007 first.
fn tscIsInvariant() bool {
if (cpuid(0x80000000).eax < 0x80000007) return false;
return (cpuid(0x80000007).edx & (1 << 8)) != 0;
}
const CpuidRegs = struct { eax: u32, ebx: u32, ecx: u32, edx: u32 };
fn cpuid(leaf: u32) CpuidRegs {
var a: u32 = undefined;
var b: u32 = undefined;
var c: u32 = undefined;
var d: u32 = undefined;
asm volatile ("cpuid"
: [a] "={eax}" (a),
[b] "={ebx}" (b),
[c] "={ecx}" (c),
[d] "={edx}" (d),
: [leaf] "{eax}" (leaf),
[sub] "{ecx}" (@as(u32, 0)),
);
return .{ .eax = a, .ebx = b, .ecx = c, .edx = d };
if (!cpuid.supports(0x8000_0007)) return false;
return (cpuid.leaf(0x8000_0007).edx & (1 << 8)) != 0;
}
// HPET registers: capabilities at +0x00 (period in the high dword, in fs; bit 13 =

View File

@ -141,11 +141,41 @@ pub fn debugconWrite(bytes: []const u8) void {
/// stack for double faults), then the IDT with exception handlers. After this a
/// CPU fault is reported instead of triple-faulting. Install the fault handler
/// (setFaultHandler) first so early faults are caught.
///
/// This is the **boot processor's** half of per-core bring-up; smp.apEntry is the
/// other half and must keep the per-CPU steps in step with it (the system_call
/// MSRs and the CR4 hardening bits are per-core state, so every core sets its own).
pub fn init() void {
gdt.init();
tss.init();
idt.init();
pcpu.initSystemCall();
// The machine-wide half of SMAP, and it must precede every CR4 write: the
// interrupt entry has to be able to clear EFLAGS.AC before any core claims the
// bit. This is the boot processor and the application processors are still
// parked, so "before every core" is simply "here".
_ = pcpu.armSupervisorAccessPrevention();
// Safe this early, before the kernel is on its own page tables: the loader's
// bootstrap tables (boot/efi.zig) map with present|writable and never set the
// U/S bit, so no page the BSP executes from is user-accessible and, for SMAP,
// no page it *reads* is either, so there is nothing for the new bit to refuse
// between here and the switch to the kernel's own tables.
pcpu.initHardening();
}
/// Whether ring 0 is barred from executing user-mapped pages on this core (CR4.SMEP
/// on x86_64; the privileged-execute-never behaviour elsewhere). False means the CPU
/// doesn't offer it and the machine is running unhardened see per-cpu.zig.
pub fn supervisorExecutePreventionEnabled() bool {
return pcpu.supervisorExecutePreventionEnabled();
}
/// Whether ring 0 is barred from *reading or writing* user-mapped pages on this core
/// (CR4.SMAP on x86_64; the privileged-access-never behaviour elsewhere). False means
/// either the CPU doesn't offer it or the interrupt entry could not be armed to clear
/// AC see per-cpu.zig; the machine boots either way, unhardened and saying so.
pub fn supervisorAccessPreventionEnabled() bool {
return pcpu.supervisorAccessPreventionEnabled();
}
/// Build the kernel's own page tables (with real permissions) and switch onto
@ -300,6 +330,20 @@ pub fn userExit() noreturn {
user_exit_to_kernel();
}
/// The counter the syscall exit stub bumps when it refuses to return the fast way
/// (isr.s, `sysret_non_canonical_count`).
const non_canonical_returns = @extern(*u64, .{ .name = "sysret_non_canonical_count" });
/// How many times this boot's system_call returns took the slow, safe exit because
/// the return address was not a canonical address (the SYSRETQ canonical-RIP guard
/// in isr.s; an architecture without the hazard reports 0 forever). A correct
/// program cannot produce one it could not have executed at a non-canonical
/// address in the first place so a nonzero count is exactly the number of times
/// a process tried to make the kernel fault on its way out.
pub fn nonCanonicalReturnCount() u64 {
return @atomicLoad(u64, non_canonical_returns, .monotonic);
}
/// Register the handler for the user system_call gate (int 0x80, vector 128). The
/// handler may write the trap frame (see `setSystemCallResult`).
pub fn setSystemCallHandler(handler: *const fn (*CpuState) void) void {

View File

@ -0,0 +1,42 @@
//! CPUID the CPU describing itself.
//!
//! One helper for the whole architecture layer (the timer's TSC leaves, the
//! supervisor-hardening feature bits), rather than a private copy per module.
//! `leaf` always executes with ECX = 0, which is what every leaf danos reads
//! wants: leaf 7's feature words live in sub-leaf 0, and leaves that ignore ECX
//! don't care. A sub-leaf-taking caller would add its own entry point here.
//!
//! **Always gate on `supports` first.** CPUID does not fault on an out-of-range
//! leaf it returns the data of the highest supported leaf instead, which would
//! be read as a feature bit that isn't there. The maximum lives in leaf 0 (basic
//! range) and leaf 0x80000000 (extended range).
pub const Registers = struct { eax: u32, ebx: u32, ecx: u32, edx: u32 };
/// Execute CPUID for `number` at sub-leaf 0.
pub fn leaf(number: u32) Registers {
var a: u32 = undefined;
var b: u32 = undefined;
var c: u32 = undefined;
var d: u32 = undefined;
asm volatile ("cpuid"
: [a] "={eax}" (a),
[b] "={ebx}" (b),
[c] "={ecx}" (c),
[d] "={edx}" (d),
: [leaf] "{eax}" (number),
[sub] "{ecx}" (@as(u32, 0)),
);
return .{ .eax = a, .ebx = b, .ecx = c, .edx = d };
}
/// Whether `number` is inside the range this CPU actually enumerates the basic
/// range for a leaf below 0x80000000, the extended range above it. Every read of
/// a leaf beyond 0 or 0x80000000 must pass through here first (see the module doc).
pub fn supports(number: u32) bool {
const maximum = if (number >= 0x8000_0000)
leaf(0x8000_0000).eax
else
leaf(0).eax;
return maximum >= number;
}

View File

@ -187,11 +187,9 @@ user_exit_to_kernel:
# CS/SS from STAR, masks RFLAGS with SFMASK (so IF is already clear), and jumps
# here with RSP still the *user* stack. We swap in the kernel GS, switch to the
# task's kernel stack via the per-CPU block, build a CpuState frame identical to
# the interrupt path's, and reuse interruptDispatch (vector 128) then SYSRET.
#
# Hazard (acceptable while init is the only, trusted, user program): SYSRETQ #GPs
# in ring 0 if the return RIP (RCX) is non-canonical. A hostile user could arrange
# that; hardening (canonical check / iretq fallback) is a later security-track item.
# the interrupt path's, and reuse interruptDispatch (vector 128) then SYSRET,
# unless the return RIP is non-canonical, in which case the canonical-RIP guard at
# the exit below returns through IRETQ instead (docs/os-development/smep-smap.md).
.global syscall_entry
syscall_entry:
swapgs # kernel GS base
@ -249,16 +247,79 @@ syscall_entry:
pop %rax
add $16, %rsp # drop vector + error_code -> rsp at rip
popq %rcx # rip -> RCX (SYSRETQ restores RIP from RCX)
# --- canonical-RIP guard ---
# SYSRETQ with a non-canonical RCX raises #GP *in ring 0* on Intel: on the
# kernel stack, after the swapgs below has already installed the user's GS
# base a fault in the trusted base, on user-influenced state, which is the
# classic escalation primitive (CVE-2012-0217). Ring 3 gets to choose that RIP
# without any kernel bug: the CPU saves the address of the instruction *after*
# `syscall`, so a program executing `syscall` as the last two bytes of the last
# canonical page returns to 0x0000_8000_0000_0000. Nothing between entry and
# here rewrites the frame's rip (no signal or context-restore path exists that
# could), so this is the whole attack surface and one compare closes it.
#
# Canonical means bits 63:47 all equal bit 47, so sign-extending from bit 47
# and comparing is the complete test. Bit 47 is the right pivot because danos
# is 4-level only: paging.zig builds a PML4 and nothing anywhere sets CR4.LA57
# (bit 12), so a linear address is 48-bit on every core, on every machine we
# boot. A future 5-level port must pivot on bit 56 instead and should patch
# this shift pair at boot rather than branch on a feature flag, to keep the
# return path of every syscall in the system free of loads.
#
# Cost: four register-only ALU ops and one forward branch the predictor sees
# taken exactly never (a correct program cannot have a non-canonical return
# address it could not have executed there). R11 is free scratch: SYSCALL
# already destroyed the user's copy, and the fast path overwrites it with the
# saved RFLAGS two instructions further on.
movq %rcx, %r11
shlq $16, %r11
sarq $16, %r11 # sign-extend from bit 47
cmpq %rcx, %r11 # changed by the round trip => non-canonical
jne .Lnon_canonical_return
addq $8, %rsp # skip the cs slot (SYSRETQ loads CS from STAR)
popq %r11 # rflags -> R11 (SYSRETQ restores RFLAGS from R11)
popq %rsp # user rsp (the ss slot below is abandoned)
swapgs # user GS base
sysretq # -> ring 3: RIP=RCX, RFLAGS=R11, CS/SS from STAR
# The guard's cold path: return through IRETQ, which is safe where SYSRETQ is not.
# IRETQ loads CS committing the privilege change to ring 3 before the new RIP
# is fetched, so the #GP arrives *from ring 3*: through the IDT, onto this task's
# kernel stack, with a user CS in the frame, where isr_common swaps GS back and the
# kernel kills the process like any other user fault. That ordering is why IRETQ is
# the standard fallback for this exact case; the sysret-canonical test asserts it
# on the machine we run on (the process dies, the kernel does not).
#
# State handed to ring 3 is identical to what the fast path would have produced.
# IRETQ consumes the same 5-word frame the CPU pushes for an interrupt rip, cs,
# rflags, rsp, ss which is exactly the frame syscall_entry built and the fast
# path is part-way through dismantling, so un-popping the rip slot makes it whole:
# same user RIP, same user RSP, same RFLAGS, and CS/SS = 0x23/0x1B, the very
# selectors SYSRETQ would have loaded from STAR. R11 is reloaded from the frame's
# rflags slot so even the register SYSRET synthesizes matches. The swapgs sits in
# the same place relative to the ring change as the fast path's, so the swapgs
# discipline is untouched: kernel GS while we still touch kernel data, user GS for
# the instant before ring 3.
.Lnon_canonical_return:
# Cold-path diagnostic: how many hostile return addresses this boot refused.
# `lock` because every core shares the counter, and it costs nothing here a
# process that reaches this line is about to die.
lock incq sysret_non_canonical_count(%rip)
movq 8(%rsp), %r11 # rflags -> R11, exactly as the fast path leaves it
subq $8, %rsp # un-pop the rip slot: rsp back at the iretq frame
swapgs # user GS base
iretq # -> ring 3, where the bad RIP faults harmlessly
.section .bss
.balign 8
user_saved_rsp:
.skip 8
# Times the canonical-RIP guard above refused a SYSRETQ this boot. Read through the
# architecture layer (cpu.zig nonCanonicalReturnCount); zero on any machine no
# process has attacked.
.global sysret_non_canonical_count
sysret_non_canonical_count:
.skip 8
.text
# --- user-mode test program --------------------------------------------------
@ -282,6 +343,24 @@ user_pf_start:
1: jmp 1b
user_pf_end:
# The SYSRET-guard program: two harmless system calls, the second placed so that
# its *return address* is not canonical. The caller (tests.zig) copies these bytes
# to the very end of the last canonical user page, so the final `syscall` occupies
# the last two bytes of address space ring 3 can execute, and the RIP the CPU saves
# into RCX for it is 0x0000_8000_0000_0000 the first non-canonical address.
# The first call proves the ordinary SYSRETQ path still works (the program only
# reaches the second instruction pair by returning correctly from the first).
# 39 is abi.SystemCall.current_core: no arguments, no side effects, always
# succeeds; the test asserts the immediate below still matches that enum.
.global user_sysret_start
.global user_sysret_end
user_sysret_start:
mov $39, %eax # current_core
syscall # canonical return address (mid-page): the fast path
mov $39, %eax # current_core
syscall # return address = the end of the page = non-canonical
user_sysret_end:
.text
# Stub for a vector the CPU does NOT push an error code for: push a dummy 0.
@ -363,7 +442,27 @@ STUB_NOERR 128
# If the interrupt came from ring 3 the GS base holds the user's value, so swap
# in the kernel's before anything reads per-CPU data (swapgs discipline; see
# percpu.zig). CS sits at offset 24 here (vector@0, error@8, RIP@16, CS@24).
#
# The first three bytes are the SMAP guard, and they come before everything
# before the CPL test, before the swapgs. Interrupt delivery does not clear
# EFLAGS.AC (SYSCALL does, through SFMASK; an IDT gate does not), and ring 3 sets
# AC freely with popfq, so without this a hostile process could take an interrupt
# with AC=1 and have the whole handler run with SMAP suspended. Ahead of the CPL
# test because ring 0 inherits AC just as readily: a fault or IRQ nested inside
# kernel code carries whatever AC the interrupted context had, and that context
# may itself be an entry that has not reached its own guard yet. Clearing first,
# unconditionally, means no path into the kernel is ever a path in with AC set.
#
# `clac` is #UD on a CPU without SMAP, so the image ships the 3-byte canonical NOP
# (`nopl (%rax)`) and per-cpu.zig overwrites it with `clac` (0f 01 ca) at boot,
# on the boot processor, only when CPUID says the instruction exists a one-time
# patch rather than a branch in the hottest path in the kernel. Neither encoding
# touches the flags the `testb` below sets, so the guard is invisible to the code
# that follows it either way.
.global isr_smap_patch
isr_common:
isr_smap_patch:
.byte 0x0f, 0x1f, 0x00 # nopl (%rax) -> patched to `clac` when the CPU has SMAP
testb $3, 24(%rsp)
jz 1f
swapgs

View File

@ -11,9 +11,24 @@
//! transition is always an exit (the kernel starts in ring 0), the swap pairs
//! keep the invariant without seeding KERNEL_GS_BASE. `scheduler()` is therefore
//! valid in any ring-0 context and never sees a user-controlled base.
//!
//! The file has since become the home of **per-core CPU state set at bring-up**
//! generally, not just the GS block: the fast-system_call MSRs and the CR4
//! hardening bits live here too, because each is state a core owns and must set
//! for itself. Both bring-up paths `cpu.init` on the boot processor and
//! `smp.apEntry` on every application processor call the same functions here.
//!
//! One deliberate exception to "per-core": `armSupervisorAccessPrevention` patches
//! a machine-wide instruction into the shared interrupt entry, once, on the boot
//! processor. It lives here anyway because it is the other half of CR4.SMAP
//! same feature probe, same fail-open posture and splitting a hardening measure
//! across two files is how the halves drift apart.
const std = @import("std");
const io = @import("io.zig");
const cpuid = @import("cpuid.zig");
const paging = @import("paging.zig");
const boot_handoff = @import("boot-handoff");
const parameters = @import("parameters");
const ia32_gs_base = 0xC000_0101;
@ -73,5 +88,171 @@ pub fn initSystemCall() void {
io.wrmsr(ia32_star, (@as(u64, 0x08) << 32) | (@as(u64, 0x10) << 48));
const entry = @extern(*const anyopaque, .{ .name = "syscall_entry" });
io.wrmsr(ia32_lstar, @intFromPtr(entry));
io.wrmsr(ia32_sfmask, 0x4_0700); // clear IF, TF, DF, AC on entry
// Clear IF, TF, DF, AC and NT on entry. The first four are the usual
// hygiene; NT is here because SYSCALL, unlike an interrupt gate, does not
// clear it for us, so without this the kernel runs every system call with
// whatever nested-task bit ring 3 last chose and the canonical-RIP guard's
// cold path (isr.s) leaves through IRETQ, whose behaviour with NT set is a
// corner of the manuals not worth depending on either way. Masking it costs
// one bit and removes the question: the kernel is never nested, and ring 3
// still gets its own NT back, from R11 on the fast path and from the frame
// on the cold one.
io.wrmsr(ia32_sfmask, 0x4_4700);
}
// --- supervisor-mode hardening (CR4) ---------------------------------------
//
// CR4 is per-core state, so these bits are set during *every* core's bring-up
// the BSP in cpu.init, each AP in smp.apEntry and not in the AP trampoline,
// which stays minimal and would only cover the APs anyway.
/// CR4.SMEP: an instruction fetch in ring 0 from a page whose U/S bit says *user*
/// raises #PF. This is what makes the classic ret2usr shape (a kernel bug steered
/// into attacker-prepared user code) a loud, attributable fault instead of a
/// silent compromise. danos never executes user-mapped memory in ring 0 kernel
/// text lives in the higher half, the ring-3 entry paths are kernel code, and the
/// AP trampoline page is a supervisor mapping so nothing legitimate is refused.
const cr4_smep: u64 = 1 << 20;
/// SMEP's feature bit: CPUID leaf 7, sub-leaf 0, EBX bit 7.
fn smepSupported() bool {
if (!cpuid.supports(7)) return false;
return cpuid.leaf(7).ebx & (1 << 7) != 0;
}
/// CR4.SMAP: a ring-0 data *read or write* to a page whose U/S bit says *user*
/// raises #PF, unless EFLAGS.AC is set. It is the standing enforcement behind
/// system/kernel/user-memory.zig: that layer never dereferences a user virtual
/// address it walks the process's tables and moves bytes through the physmap,
/// kernel mappings throughout so nothing legitimate in this kernel is refused,
/// and any future code that reaches for a user pointer directly faults the first
/// time it runs. danos therefore opens no `stac` window anywhere; there is no
/// correct reason to have one, and adding one is how the guarantee is lost.
const cr4_smap: u64 = 1 << 21;
/// SMAP's feature bit: CPUID leaf 7, sub-leaf 0, EBX bit 20.
fn smapSupported() bool {
if (!cpuid.supports(7)) return false;
return cpuid.leaf(7).ebx & (1 << 20) != 0;
}
/// `clac` the three bytes that replace the NOP at `isr_smap_patch` once the
/// interrupt entry is allowed to execute them.
const clac_opcode = [_]u8{ 0x0f, 0x01, 0xca };
/// Set only once the interrupt entry really clears AC, and read by every core
/// before it turns SMAP on. Nothing turns SMAP on until this is true, so there is
/// no window not even on the boot processor, not even before ring 3 exists
/// in which the bit is live while an interrupt could still be taken with AC set.
var interrupt_entry_clears_ac = false;
fn readCr3() u64 {
return asm volatile ("mov %%cr3, %[out]"
: [out] "=r" (-> u64),
);
}
/// Patch the `clac` into the shared interrupt entry, and by doing so authorize
/// CR4.SMAP. **Boot processor only, once, before any core sets the bit and before
/// the application processors are woken** `initHardening` refuses SMAP until
/// this has run, so the order is enforced rather than merely documented, and an AP
/// climbing the trampoline cannot get ahead of it.
///
/// The write goes through the physmap, not through the kernel's own view of its
/// text: once `paging.init` has run, kernel `.text` is mapped read-only under W^X,
/// and a store to it would fault (or, worse, silently need CR0.WP cleared). The
/// physmap alias of the same frame is an ordinary supervisor RW mapping the same
/// door `smp.arm` uses to write the AP trampoline and `process.run` uses to fill a
/// read-only user code frame. Going through it also makes this correct under
/// *either* set of tables: the loader's bootstrap tables map the image writable,
/// the kernel's own do not, and this runs before the switch.
///
/// Three bytes, translated one at a time, so a patch site that straddles a page
/// boundary is not a special case. A translation that fails leaves the NOP in
/// place and returns false, and the machine then boots without SMAP rather than
/// with SMAP and an entry path that cannot clear AC.
pub fn armSupervisorAccessPrevention() bool {
if (!smapSupported()) return false;
const site = @intFromPtr(@extern([*]const u8, .{ .name = "isr_smap_patch" }));
const root = readCr3() & 0x000F_FFFF_FFFF_F000;
// MUST run with interrupts masked, and does: this is reached from cpu.init,
// long before the kernel's `sti`, and before any application processor exists.
// The reason is that the three bytes go in one at a time, and the middle state
// 0f 01 00, once the second byte lands is `sgdt (%rax)`, a ten-byte write
// to wherever RAX points, sitting at the first instruction of every interrupt
// entry. Nothing can take that entry here, so nothing can execute it. A future
// change that moves this after interrupts are enabled has to close that window
// first (one 16-bit store covering both changed bytes is the shape, but it
// needs the two to be physically contiguous and 2-byte aligned a naive
// version of exactly that triple-faulted this kernel).
for (clac_opcode, 0..) |byte, i| {
const physical = paging.translateIn(root, site + i) orelse return false;
const alias: *volatile u8 = @ptrFromInt(boot_handoff.physicalToVirtual(physical));
alias.* = byte;
}
// The bytes were written through a different linear address than the one they
// will be fetched from, so serialize before anyone can execute them: CPUID is
// the architecturally sanctioned way to discard whatever the core prefetched or
// decoded of the old encoding.
_ = cpuid.leaf(0);
// Read back through the *text* address, not the alias just written: that is the
// view the CPU will fetch from, so this is what proves the two are the same
// physical page and the patch landed where it will actually execute. A mismatch
// means the translation lied, and SMAP stays off rather than being enabled over
// an entry path that cannot clear AC.
const installed: [*]const volatile u8 = @ptrFromInt(site);
for (clac_opcode, 0..) |byte, i| {
if (installed[i] != byte) return false;
}
interrupt_entry_clears_ac = true;
return true;
}
fn readCr4() u64 {
return asm volatile ("mov %%cr4, %[out]"
: [out] "=r" (-> u64),
);
}
fn writeCr4(value: u64) void {
asm volatile ("mov %[in], %%cr4"
:
: [in] "r" (value),
: .{ .memory = true });
}
/// Turn on the supervisor-mode hardening this CPU offers, on the calling core.
/// Called once per core, next to `initSystemCall`, from both bring-up paths.
///
/// **Fail-open, like the IOMMU and the clocksource:** an absent feature is a
/// machine that boots unhardened, not a machine that refuses to boot. danos has
/// to run on any VM, on real Intel and on real AMD; the boot log states the
/// posture either way (see the platform block in system/kernel/kernel.zig), so
/// an unhardened boot is visible rather than assumed.
pub fn initHardening() void {
var bits: u64 = 0;
if (smepSupported()) bits |= cr4_smep;
// SMAP only after the boot processor has armed the interrupt entry: with the
// NOP still in place an interrupt inherits ring 3's AC and suspends SMAP for
// the length of the handler, which is worse than not claiming the bit at all.
if (smapSupported() and interrupt_entry_clears_ac) bits |= cr4_smap;
if (bits == 0) return;
writeCr4(readCr4() | bits);
}
/// Whether supervisor-mode execution prevention is live on *this* core. Read
/// straight out of CR4 rather than a remembered probe result, so the answer is
/// the state the hardware is actually in which is what both the boot log and
/// the `fault-smep` test case want to assert.
pub fn supervisorExecutePreventionEnabled() bool {
return readCr4() & cr4_smep != 0;
}
/// Whether supervisor-mode *access* prevention is live on *this* core. Read from
/// CR4 for the same reason as its neighbour: the question the boot log and the
/// `fault-smap` case are asking is what the hardware is doing, not what a probe
/// once concluded.
pub fn supervisorAccessPreventionEnabled() bool {
return readCr4() & cr4_smap != 0;
}

View File

@ -179,6 +179,13 @@ fn apEntry(percpu: usize) callconv(.c) noreturn {
idt.loadOnThisCpu(); // the shared IDT
pcpu.setLocal(cpu, percpu); // per-CPU block via GS base *after* the GDT reload
pcpu.initSystemCall(); // enable system_call/sysret on this core
// CR4 is per-core: this core starts from the trampoline's CR4 (PAE + SSE only),
// so it sets its own hardening bits here rather than in the trampoline one
// Zig code path shared with the BSP (cpu.init), and the trampoline stays minimal.
// CR4.SMEP and CR4.SMAP on this core, if the CPU has them. The `clac` the SMAP
// bit depends on was patched into the shared interrupt entry by the BSP long
// before this core was woken, so an AP only ever finds it already armed.
pcpu.initHardening();
apic.initSecondary(); // software-enable this core's LAPIC
apic.initTimer(apic.frequencyHz()); // arm its timer (still masked: interrupts off)

View File

@ -279,6 +279,22 @@ fn kmain(boot_information: *const BootInformation) noreturn {
log.print(" cpus : {d} usable core(s); 1 running (BSP), {d} AP(s) parked (SMP bring-up pending)\n", .{ cores.len, if (cores.len > 0) cores.len - 1 else 0 });
if (platform.cpusDropped() > 0)
log.print(" cpus : WARNING {d} core(s) beyond pool cap dropped\n", .{platform.cpusDropped()});
// The supervisor-execution posture, stated plainly at every boot. Every core
// sets the bit during its own bring-up (architecture/x86_64/per-cpu.zig); the
// BSP answers for the machine here, because the feature is a property of the
// CPU model, not of an individual core. Fail-open like the IOMMU: a CPU without
// it still boots, it just boots unhardened, and says so.
if (architecture.supervisorExecutePreventionEnabled())
log.write(" smep : enabled - ring 0 cannot execute user pages\n")
else
log.write(" smep : absent - ring-0 execution of user pages unprevented\n");
// The supervisor-access posture, the same way. Its second half is a boot-time
// patch (the `clac` at the interrupt entry), so "absent" here covers both a
// CPU without the feature and a patch that could not be applied.
if (architecture.supervisorAccessPreventionEnabled())
log.write(" smap : enabled - ring 0 reaches user memory only through the checked copy layer\n")
else
log.write(" smap : absent - ring-0 access to user pages unprevented\n");
// The DMA-isolation posture, stated plainly at every boot. When a unit exists,
// iommu.init already logged its enable block above; here we only state the
// fail-open case, so a boot without the line is a boot with translation on.

View File

@ -124,15 +124,26 @@ pub const maximum_mount_rewrite = 32;
const auxiliary_vector_null: u64 = 0; // AT_NULL end of the vector
const auxiliary_vector_page_size: u64 = 6; // AT_PAGESZ
// The hand-assembled user program blob (isr.s, .rodata) the isolation probe.
// The hand-assembled user program blobs (isr.s, .rodata) the isolation probes.
const pf_start = @extern([*]const u8, .{ .name = "user_pf_start" });
const pf_end = @extern([*]const u8, .{ .name = "user_pf_end" });
const sysret_start = @extern([*]const u8, .{ .name = "user_sysret_start" });
const sysret_end = @extern([*]const u8, .{ .name = "user_sysret_end" });
/// The isolation-proof program: reads a kernel-only page, must #PF.
pub fn pfBlob() []const u8 {
return pf_start[0 .. @intFromPtr(pf_end) - @intFromPtr(pf_start)];
}
/// The SYSRET-guard program: two system calls, the second of which must be copied
/// so that it ends at the last executable byte of the user half its return
/// address is then the first non-canonical address. Ends with `syscall`, so the
/// caller places it at `page_size - len` inside the page at `user_half_end -
/// page_size`; anywhere else and it proves nothing.
pub fn nonCanonicalReturnBlob() []const u8 {
return sysret_start[0 .. @intFromPtr(sysret_end) - @intFromPtr(sysret_start)];
}
/// What debug_write syscalls produced (accumulated), and the exit system_call's code.
pub var write_buffer: [256]u8 = undefined;
pub var write_len: usize = 0;

View File

@ -140,6 +140,12 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
faultNoExecute();
} else if (eql(case, "fault-null")) {
faultNull();
} else if (eql(case, "fault-smep")) {
faultSmep();
} else if (eql(case, "fault-smap")) {
faultSmap();
} else if (eql(case, "sysret-canonical")) {
sysretCanonicalTest();
} else if (eql(case, "usermem")) {
userMemTest();
} else if (eql(case, "user-memory")) {
@ -765,6 +771,15 @@ fn sleepTest() void {
// --- SMP parallelism ------------------------------------------------------
var seen_core = [_]bool{false} ** 8;
/// Whether the core this worker ran on had SMEP on in its own CR4. Recorded per
/// core because CR4 is per-core state: the boot processor enabling it says
/// nothing about the ones the trampoline brought up, and the whole hardening is
/// only as wide as its narrowest core.
var seen_core_smep = [_]bool{false} ** 8;
/// The same, for SMAP. A core that came up without it would still be able to read
/// and write user pages from ring 0 while every other core could not a hole
/// whose only symptom is that the tripwire never trips there.
var seen_core_smap = [_]bool{false} ** 8;
var smp_running: bool = true;
/// A worker that, while running, records which core it's executing on. Spread across
@ -773,7 +788,11 @@ fn smpWorker() void {
const p: *volatile bool = &smp_running;
while (p.*) {
const c = scheduler.currentCpuIndex();
if (c < seen_core.len) seen_core[c] = true;
if (c < seen_core.len) {
seen_core[c] = true;
seen_core_smep[c] = architecture.supervisorExecutePreventionEnabled();
seen_core_smap[c] = architecture.supervisorAccessPreventionEnabled();
}
}
scheduler.exit();
}
@ -803,6 +822,36 @@ fn smpTest() void {
log("DANOS-SMP: workers ran on {d} distinct core(s)\n", .{cores_seen});
check("tasks ran on multiple cores in parallel", cores_seen >= 2);
// Every core that ran work must have had SMEP on, not just the one that
// booted: an application processor climbs through the trampoline with CR4
// bare and sets the bit itself on the way in, so a hardening that reached
// only the boot processor would leave every other core able to execute a
// user page in ring 0 and would look identical from the boot log. Only
// asserted where the platform has SMEP at all, so a machine without it
// still passes rather than failing for the one reason that is not a bug.
if (architecture.supervisorExecutePreventionEnabled()) {
var hardened: u32 = 0;
for (seen_core, seen_core_smep) |ran, smep| {
if (ran and smep) hardened += 1;
}
log("DANOS-SMP: {d} of {d} core(s) had supervisor execute prevention\n", .{ hardened, cores_seen });
check("every core that ran work had SMEP enabled", hardened == cores_seen);
}
// And the same for SMAP, for the same reason and with one more of its own: the
// bit is refused until the boot processor has patched the `clac` into the shared
// interrupt entry, so a core reporting SMAP on is also a core confirming it came
// up after that patch the ordering the whole scheme rests on, checked from the
// far end. Gated on the running core the same way, so a CPU without SMAP passes.
if (architecture.supervisorAccessPreventionEnabled()) {
var hardened: u32 = 0;
for (seen_core, seen_core_smap) |ran, smap| {
if (ran and smap) hardened += 1;
}
log("DANOS-SMP: {d} of {d} core(s) had supervisor access prevention\n", .{ hardened, cores_seen });
check("every core that ran work had SMAP enabled", hardened == cores_seen);
}
// Bring-up is done, so the trampoline frame must be inert: zeroed (no stale code)
// and non-executable (W^X restored). It's armed only while a core is climbing.
const tramp = architecture.trampolinePage();
@ -1641,6 +1690,99 @@ fn faultRecoveryTest(boot_information: *const BootInformation) void {
result();
}
/// Spawn a ring-3 process whose second system call has to return to a NON-canonical
/// address the SYSRET hazard, staged the way a hostile program would stage it.
/// The blob is copied to the *end* of the last canonical page of the user half, so
/// its final `syscall` occupies the last two bytes ring 3 can execute and the return
/// RIP the CPU hands the kernel is `user_half_end` itself, the first non-canonical
/// address. Hand-built (address space, code page RO+X, stack RW+NX) like
/// `spawnFaultingProcess` a raw blob is not an ELF `spawnProcess` could load.
/// Returns the process id, or null if any allocation failed.
fn spawnNonCanonicalReturnProcess() ?u32 {
const blob = process.nonCanonicalReturnBlob();
const code_virtual = process.user_half_end - abi.page_size; // the last canonical page
const entry = code_virtual + abi.page_size - blob.len; // ends flush with the page
const flags = sync.enter();
defer sync.leave(flags);
const address_space = architecture.createAddressSpace() orelse return null;
const code_frame = pmm.alloc() orelse {
architecture.destroyAddressSpace(address_space);
return null;
};
// Fill through the physmap (the user mapping is read-only); int3 everywhere the
// blob doesn't cover, so a stray entry traps instead of sliding into it.
const code: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(code_frame));
@memset(code[0..abi.page_size], 0xCC);
@memcpy(code[abi.page_size - blob.len .. abi.page_size], blob);
architecture.mapUserPageInto(address_space, code_virtual, code_frame, false, true); // RO + X
const stack_frame = pmm.alloc() orelse {
architecture.destroyAddressSpace(address_space); // frees code_frame too it's mapped
return null;
};
architecture.mapUserPageInto(address_space, process.stack_base_virtual, stack_frame, true, false); // RW + NX
// Supervised by the calling test task, so exitReasonOf can read the verdict.
return scheduler.spawnUserLocked(address_space, entry, process.stack_base_virtual + abi.page_size, 0, 4, "sysret-probe", scheduler.currentId(), null, 0) orelse {
architecture.destroyAddressSpace(address_space);
return null;
};
}
/// The SYSRET canonical-RIP guard (docs/os-development/smep-smap.md, "Adjacent,
/// deliberately separate"): a process must not be able to make the kernel fault on
/// its own way out of a system call. `sysretq` with a non-canonical RIP in RCX #GPs
/// *in ring 0* on Intel on the kernel stack, with the user's GS base already
/// installed so the exit path checks the return address first and returns through
/// `iretq` instead, which faults in ring 3 where a bad address is just a dead
/// process.
///
/// The probe reaches the hazard the way an attacker would: its `syscall` is the last
/// two bytes of executable address space, so the return address the CPU saves is the
/// first non-canonical one. Three things then have to be true the guard fired, the
/// *process* died of a protection fault (so the fault landed in ring 3, not in the
/// kernel), and this task is still here to say so.
///
/// The counter is what makes this a regression test rather than a decoration.
/// Measured with the guard's branch commented out (2026-08-01): QEMU's TCG does not
/// model Intel's ring-0 #GP `sysretq` simply returns to the bad address and the
/// process dies in ring 3 anyway, so every *outcome* check still passed and only the
/// counter noticed. On real Intel silicon the same run takes the kernel down. Assert
/// the mechanism, not just the outcome, whenever the emulator is the softer machine.
/// The probe's first system call is deliberately ordinary: it only reaches the second
/// one by returning correctly from the first, so the fast path is exercised too.
fn sysretCanonicalTest() void {
log("DANOS-TEST-BEGIN: sysret-canonical\n", .{});
const blob = process.nonCanonicalReturnBlob();
check(
"the probe's system call number still matches the ABI",
blob.len > 1 and blob[0] == 0xB8 and blob[1] == @intFromEnum(abi.SystemCall.current_core),
);
const before = architecture.nonCanonicalReturnCount();
check("no return has been refused yet this boot", before == 0);
process.fault_kill_count = 0;
const probe = spawnNonCanonicalReturnProcess() orelse 0;
check("the probe process spawned", probe != 0);
// Drop below the probe so it gets the core, and wait for the kill.
scheduler.setPriority(1);
const deadline = architecture.millis() + 5000;
while (process.fault_kill_count < 1 and architecture.millis() < deadline) scheduler.yield();
scheduler.setPriority(4);
const refused = architecture.nonCanonicalReturnCount() - before;
check("the guard refused exactly one sysretq", refused == 1);
check("the probe was killed (not the machine)", process.fault_kill_count == 1);
check(
"the probe died of a protection fault — the iretq fallback faulted it in ring 3",
process.exitReasonOf(scheduler.currentId(), probe) == @intFromEnum(abi.ExitReason.protection_fault),
);
log("sysret-canonical: {d} non-canonical return(s) refused; core {d} still running\n", .{ refused, scheduler.currentCpuIndex() });
result();
}
/// Address-space refcount (docs/threading-plan.md M1): every process holds exactly one
/// reference to its address space, released when it dies, so `destroyAddressSpace` runs
/// exactly once per space no leak, no double-free. Spawn and kill several ring-3
@ -4296,6 +4438,105 @@ fn faultNoExecute() void {
log("DANOS-TEST-RESULT: FAIL (NX not enforced)\n", .{});
}
/// Verify SMEP: a ring-0 instruction fetch from a *user*-mapped page must fault.
///
/// The ret2usr shape, staged deliberately a user code page (present, executable,
/// U/S set) mapped into the address space the kernel itself is running on, then
/// called from ring 0. CR4.SMEP makes the fetch a #PF; without it the `ret` simply
/// returns and the FAIL line below is reached.
///
/// The CPU reports it as: present (bit 0) + instruction fetch (bit 4) = error code
/// 0x11, taken in ring 0, at the user address it tried to fetch from a #PF on an
/// instruction fetch is raised *at* the instruction that could not be fetched, so
/// the reported IP is the probe page, not the kernel-half `call` that jumped there.
/// The enabled-check up front stops the case passing vacuously on a CPU that has no
/// SMEP (where nothing would fault and nothing would be proved).
fn faultSmep() void {
log("DANOS-TEST-BEGIN: fault-smep\n", .{});
if (!architecture.supervisorExecutePreventionEnabled()) {
log("DANOS-TEST-RESULT: FAIL (SMEP not enabled in this boot)\n", .{});
return;
}
const frame = pmm.alloc() orelse {
log("DANOS-TEST-RESULT: FAIL (no frame for the probe page)\n", .{});
return;
};
// Fill through the physmap the user mapping is read-only. A lone `ret` so an
// unenforced fetch lands harmlessly back here (and reports FAIL), int3 padding
// so a stray slide traps instead of running into whatever the frame held.
const code: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame));
@memset(code[0..abi.page_size], 0xCC);
code[0] = 0xC3; // ret
// RO + X *and user-accessible*, in the kernel's own tables: this task is a kernel
// task, so it runs on them (a process would have its own address space).
architecture.mapUserPage(process.code_virtual, frame, false, true);
// Launder the address through empty asm so the backend really forms an indirect
// call rather than folding anything about a known constant target.
var target: u64 = process.code_virtual;
target = asm (""
: [ret] "=r" (-> u64),
: [in] "0" (target),
);
const f: *const fn () void = @ptrFromInt(target);
f(); // ring-0 instruction fetch from a user page -> #PF
log("DANOS-TEST-RESULT: FAIL (SMEP not enforced)\n", .{});
}
/// Verify SMAP: a ring-0 data read from a *user*-mapped page must fault.
///
/// The bug shape this case stands in for is the ordinary one a syscall that takes
/// the pointer ring 3 handed it and dereferences it. So the probe does exactly that
/// and nothing more: an ordinary user data page (present, user-accessible, read-only,
/// no-execute) mapped into the address space this kernel task is already running on,
/// then read directly rather than through system/kernel/user-memory.zig. With SMAP on
/// and EFLAGS.AC clear the load is a #PF; without it the byte comes back and the FAIL
/// line below reports the value it should never have seen.
///
/// The CPU reports it as error code **0x1**: present (bit 0) alone. A SMAP violation
/// has no error-code bit of its own bit 1 stays clear because this is a read, and
/// bit 2 (U/S) describes the *access*, which was made in ring 0, not the page. So the
/// report is indistinguishable in its bits from any other supervisor read of a present
/// page; what identifies it is the pairing with `ring 0 (kernel)` and a user CR2.
///
/// The seed write goes through the physmap, which is the point in miniature: that is
/// the route the checked copy layer takes for every legitimate access to this same
/// frame, and SMAP has no objection to it. Only the second access the one through
/// the *user* virtual address is refused.
///
/// The enabled-check up front stops the case passing vacuously on a CPU without SMAP,
/// and fails rather than skips: a suite that quietly stops testing the tripwire is
/// exactly the outcome the tripwire exists to prevent.
fn faultSmap() void {
log("DANOS-TEST-BEGIN: fault-smap\n", .{});
if (!architecture.supervisorAccessPreventionEnabled()) {
log("DANOS-TEST-RESULT: FAIL (SMAP not enabled in this boot)\n", .{});
return;
}
const frame = pmm.alloc() orelse {
log("DANOS-TEST-RESULT: FAIL (no frame for the probe page)\n", .{});
return;
};
const seed: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame));
@memset(seed[0..abi.page_size], 0);
seed[0] = 0x5A; // a sentinel, so an unenforced read is reported as a value, not a guess
// RO + NX *and user-accessible*, in the kernel's own tables: this task is a kernel
// task, so it runs on them (a process would have its own address space).
architecture.mapUserPage(process.code_virtual, frame, false, false);
// Launder the address through empty asm for the same reason `fault-null` does:
// the backend must form a real load from a runtime address rather than reasoning
// about a constant it can see the provenance of.
var target: u64 = process.code_virtual;
target = asm (""
: [ret] "=r" (-> u64),
: [in] "0" (target),
);
const p: *const volatile u8 = @ptrFromInt(target);
const value = p.*; // ring-0 data read from a user page -> #PF
log("DANOS-TEST-RESULT: FAIL (SMAP not enforced; read 0x{x})\n", .{value});
}
/// Verify the null guard: dereferencing address 0 (page 0 left unmapped) faults.
fn faultNull() void {
log("DANOS-TEST-BEGIN: fault-null\n", .{});

View File

@ -65,6 +65,11 @@ ARCHES = {
# Built as a function so we can splice in per-run paths.
"qemu_args": lambda a, boot_volume, vars_fd, serial: [
"-machine", "q35", "-m", "128M",
# The default TCG model (qemu64) predates the supervisor-hardening bits:
# no SMEP, no SMAP, so the kernel's probes would find nothing and the
# enabled paths would never run in CI. `max` is "everything this
# accelerator can emulate", which under TCG includes both.
"-cpu", "max",
"-drive", f"if=pflash,format=raw,readonly=on,file={a['ovmf_code']}",
"-drive", f"if=pflash,format=raw,file={vars_fd}",
# Boot off a FAT USB device: the boot volume is a mass-storage device on
@ -338,6 +343,46 @@ CASES = [
"expect": r"page fault \(vector 14\)",
"fail": r"NX not enforced"},
{"name": "fault-null", "expect": r"page fault \(vector 14\)"},
# SMEP: ring 0 calls into a page that is present, executable and *user*-accessible
# — the ret2usr shape. CR4.SMEP must refuse the instruction fetch: #PF with error
# code 0x11 (present | instruction-fetch; the U/S bit reports the *access* ring, so
# it is 0 for a ring-0 fetch), taken in ring 0, reported at the page it tried to
# fetch from (a fetch #PF is raised at the un-fetchable instruction, so the IP is
# the user address, not the kernel-half call site). The boot line is asserted too:
# on a CPU without SMEP nothing would fault and the case would prove nothing.
{"name": "fault-smep",
"expect": r"(?s)(?=.*smep\s+: enabled)(?=.*page fault \(vector 14\))"
r"(?=.*ring 0 \(kernel\))(?=.*error code : 0x11)"
r"(?=.*fault addr : 0x0000700000000000)",
"fail": r"SMEP not enforced|SMEP not enabled|no frame for the probe page"},
# SMAP: ring 0 reads a page that is present and *user*-accessible, the way a syscall
# that dereferenced its caller's pointer would. CR4.SMAP (plus the `clac` patched
# into the interrupt entry, which keeps ring 3 from suspending it by taking an
# interrupt with AC set) must refuse the load: #PF with error code 0x1 — present,
# and nothing else. A SMAP violation has no bit of its own; bit 1 is clear because
# it is a read and bit 2 (U/S) reports the ring of the *access*, which was 0. Taken
# in ring 0, at the user address it read. The boot line is asserted too: without
# SMAP nothing would fault and the case would prove nothing.
{"name": "fault-smap",
"expect": r"(?s)(?=.*smap\s+: enabled)(?=.*page fault \(vector 14\))"
r"(?=.*ring 0 \(kernel\))(?=.*error code : 0x1(?![0-9a-f]))"
r"(?=.*fault addr : 0x0000700000000000)",
"fail": r"SMAP not enforced|SMAP not enabled|no frame for the probe page"},
# SYSRET canonical-RIP guard: a ring-3 probe whose `syscall` sits on the last two
# bytes of executable address space, so the return RIP the kernel must hand back
# is 0x0000800000000000 — non-canonical, and a ring-0 #GP if it reached SYSRETQ.
# The kernel must return through IRETQ instead, which faults the *process* in
# ring 3: the kill line names a general protection fault at exactly that address,
# and the probe's own result line reports the guard counter. Any "ring 0 (kernel)"
# report, panic or reset here is the hazard itself, so they are failures.
# TCG does not model the ring-0 #GP (measured: with the guard removed these two
# lines are unchanged), so the case's teeth are the counter assertion inside
# DANOS-TEST-RESULT, not the kill line.
{"name": "sysret-canonical",
"expect": r"(?s)(?=.*killed by general protection fault \(vector 13\))"
r"(?=.*IP\s+: 0x0000800000000000)"
r"(?=.*DANOS-TEST-RESULT: PASS)",
"fail": r"DANOS-TEST-RESULT: FAIL|CPU EXCEPTION|KERNEL PANIC"},
# Memory grants: the mmap/munmap path hands out user pages into a process's
# arena, translate resolves them, munmap frees them, and no frames leak.
{"name": "usermem",