kernel: ring 0 reaches user memory only through the checked copy

SMAP makes the rule the copy layer has followed since it was written into a
rule the hardware keeps. A ring-0 read or write of a user page now faults,
so any code that reaches for a user pointer directly fails the first time it
runs rather than the first time someone attacks it — and the suite becomes
the enforcement test, because every case exercises the kernel with the bit
on. Nothing had to be fixed to turn it on, which is the retrospective proof
that the nine stragglers converted earlier were all of them.

The interrupt entry needed one instruction first. Hardware does not clear
the alignment-check flag on its way into a handler, and ring 3 sets that
flag freely, so a process could have taken an interrupt with SMAP suspended
for the duration. The system call path was already covered — its flag mask
clears it — but the interrupt path needed a `clac`, which cannot simply be
assembled in: it is an invalid instruction on a processor without SMAP, and
danos boots on those too. So the entry ships as a three-byte NOP and is
patched at boot, through the physmap, because the kernel maps its own text
read-only.

The ordering that makes that safe is enforced rather than described: the
patch sets a flag, and no core will set the SMAP bit until it is true. A
translation that fails, or bytes that read back wrong through the address
they will actually be fetched from, leave the machine unhardened and saying
so — which is the same posture the IOMMU takes, and better than enforcing
over an entry path that cannot comply. The patch runs before interrupts are
enabled and before any second core exists; a comment says so, because the
three bytes pass through an encoding that must never be executed and a
future change that moves this later has to deal with that first.

Suite 114/114, with a case that reads a user page from ring 0 and requires
the fault, and the multi-core case asserting every core that ran work had
the bit — the same shape SMEP got, for the same reason: CR4 is per-core, and
a hardening is only as wide as its narrowest core.
This commit is contained in:
Daniel Samson
2026-08-01 12:20:44 +01:00
parent cb30faf15f
commit 5a5146ec13
10 changed files with 301 additions and 13 deletions
+16 -1
View File
@@ -150,9 +150,16 @@ pub fn init() void {
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.
// 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();
}
@@ -163,6 +170,14 @@ 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
/// them. Needs the frame allocator and the boot info (for the memory map and the
/// kernel's segment layout). Call once the frame allocator is up.
+20
View File
@@ -442,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
+113 -2
View File
@@ -17,10 +17,18 @@
//! 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;
@@ -112,6 +120,95 @@ fn smepSupported() bool {
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),
@@ -134,8 +231,14 @@ fn writeCr4(value: u64) void {
/// 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);
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
@@ -145,3 +248,11 @@ pub fn initHardening() void {
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;
}
+4 -1
View File
@@ -182,7 +182,10 @@ fn apEntry(percpu: usize) callconv(.c) noreturn {
// 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
// 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)
+7
View File
@@ -288,6 +288,13 @@ fn kmain(boot_information: *const BootInformation) noreturn {
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.
+75
View File
@@ -142,6 +142,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
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")) {
@@ -774,6 +776,10 @@ var seen_core = [_]bool{false} ** 8;
/// 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
@@ -785,6 +791,7 @@ fn smpWorker() void {
if (c < seen_core.len) {
seen_core[c] = true;
seen_core_smep[c] = architecture.supervisorExecutePreventionEnabled();
seen_core_smap[c] = architecture.supervisorAccessPreventionEnabled();
}
}
scheduler.exit();
@@ -831,6 +838,20 @@ fn smpTest() void {
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();
@@ -4462,6 +4483,60 @@ fn faultSmep() void {
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", .{});