kernel: ring 0 cannot execute a user page

SMEP turns the classic escalation — divert kernel control flow into a page
the attacker wrote — from a silent takeover into an immediate fault with
the offending address in the log. The bit is per-core state, so it is set
where the syscall MSRs already are: in the per-CPU bring-up both the boot
processor and every application processor run on their way in. A core that
climbed the trampoline without it would be a hole no boot log would show,
which is why the SMP case now reads CR4 on each core it lands on and
requires every one of them to be hardened, not just the one that printed
the banner.

Enabling it that early is only safe because nothing ring 0 executes is
mapped for ring 3, and that had to be established rather than assumed:
kernel text carries only its ELF flags, the physmap is no-execute, the
trampoline page is mapped supervisor and the core running it has not
enabled the bit yet, and the boot processor turns it on while still on the
loader's tables — which map nothing user-accessible at all. The one
indirect call in the kernel takes a kernel address.

The CPUID probing that was scattered across the timer code becomes a small
shared helper, since the feature question is now asked from two places and
each wanted the same maximum-leaf guard. Absence is tolerated and reported,
like the IOMMU: danos still boots on a machine without the feature, and
says which one it is.

The test harness starts asking QEMU for a CPU that has the bit at all —
its default model has neither SMEP nor SMAP, so the code would otherwise
have been unreachable in every run. No case behaved differently under the
richer model.

Suite 112/112, with a new case that maps an executable user page, calls
into it from the kernel, and requires the fault the CPU is supposed to
raise.
This commit is contained in:
Daniel Samson 2026-08-01 10:05:58 +01:00
parent b3011fbb40
commit 36a7cc5fe9
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
10 changed files with 231 additions and 29 deletions

View File

@ -1,6 +1,6 @@
# SMEP and SMAP — supervisor-mode hardening
*Design, 2026-07-31. Not yet implemented. Companion to
*Design, 2026-07-31. H2 (SMEP) has landed; HS and H3 are the remaining work. Companion to
[protocol-namespace.md](protocol-namespace.md) on the security track — this is
the hardware half; that is the namespace half.*

View File

@ -36,11 +36,11 @@ plain `main` checkout always tells the truth about where the work is.**
| | |
|---|---|
| Working on | **H2** — SMEP (group 4) |
| Branch carrying it | `feat/security-group-4` (cut next) |
| Working on | **HS** — SYSRET canonical-RIP guard |
| Branch carrying it | `feat/security-group-4` (pushed to origin) |
| On `main` | everything through P4c — groups 1, 2 and 3 merged |
| Awaiting merge | nothing |
| Suite | 111 cases, all passing |
| Awaiting merge | H2 — lands with the group 4 merge |
| Suite | 112 cases, all passing |
| Last updated | 2026-08-01 |
A checkbox below means the phase met its definition of green and was
@ -80,7 +80,7 @@ group boundary.
nobody holds. New `badge-scope` case, two processes of one fixture, every
refusal paired with a control; suite 111/111)
- [x] **merge** group 3 → main, push
- [ ] **H2** — SMEP on every core
- [x] **H2** — SMEP on every core (shared CPUID helper; CR4 bit 20 set in the per-CPU bring-up both the BSP and every AP run, asserted per core by the smp case; ring-0-executes-user-pages audit clean incl. the pre-paging window on the loader's tables; fail-open with a posture line; `-cpu max` added to the harness since QEMU's default model has neither bit; new `fault-smep` case; suite 112/112)
- [ ] **HS** — SYSRET canonical-RIP guard
- [ ] **H3** — SMAP + boot-patched `clac`; `-cpu max` in the harness; negative tests
- [ ] **merge** group 4 → main, push

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,26 @@ 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();
// 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.
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();
}
/// Build the kernel's own page tables (with real permissions) and switch onto

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

@ -11,9 +11,16 @@
//! 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.
const std = @import("std");
const io = @import("io.zig");
const cpuid = @import("cpuid.zig");
const parameters = @import("parameters");
const ia32_gs_base = 0xC000_0101;
@ -75,3 +82,57 @@ pub fn initSystemCall() void {
io.wrmsr(ia32_lstar, @intFromPtr(entry));
io.wrmsr(ia32_sfmask, 0x4_0700); // clear IF, TF, DF, AC on entry
}
// --- 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;
}
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 {
if (!smepSupported()) return;
writeCr4(readCr4() | cr4_smep);
}
/// 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;
}

View File

@ -179,6 +179,10 @@ 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.
pcpu.initHardening(); // CR4.SMEP on this core, if the CPU has it
apic.initSecondary(); // software-enable this core's LAPIC
apic.initTimer(apic.frequencyHz()); // arm its timer (still masked: interrupts off)

View File

@ -279,6 +279,15 @@ 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 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

@ -140,6 +140,8 @@ 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, "usermem")) {
userMemTest();
} else if (eql(case, "user-memory")) {
@ -765,6 +767,11 @@ 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;
var smp_running: bool = true;
/// A worker that, while running, records which core it's executing on. Spread across
@ -773,7 +780,10 @@ 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();
}
}
scheduler.exit();
}
@ -803,6 +813,22 @@ 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);
}
// 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();
@ -4296,6 +4322,51 @@ 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 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,18 @@ 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"},
# 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",