diff --git a/docs/os-development/smep-smap.md b/docs/os-development/smep-smap.md index 3c35908..d97f807 100644 --- a/docs/os-development/smep-smap.md +++ b/docs/os-development/smep-smap.md @@ -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.* diff --git a/docs/security-track-plan.md b/docs/security-track-plan.md index 70e53ec..dd8df3c 100644 --- a/docs/security-track-plan.md +++ b/docs/security-track-plan.md @@ -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 diff --git a/system/kernel/architecture/x86_64/apic.zig b/system/kernel/architecture/x86_64/apic.zig index 9757c77..abc0703 100644 --- a/system/kernel/architecture/x86_64/apic.zig +++ b/system/kernel/architecture/x86_64/apic.zig @@ -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 = diff --git a/system/kernel/architecture/x86_64/cpu.zig b/system/kernel/architecture/x86_64/cpu.zig index f0dbb83..6775191 100644 --- a/system/kernel/architecture/x86_64/cpu.zig +++ b/system/kernel/architecture/x86_64/cpu.zig @@ -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 diff --git a/system/kernel/architecture/x86_64/cpuid.zig b/system/kernel/architecture/x86_64/cpuid.zig new file mode 100644 index 0000000..1b078f3 --- /dev/null +++ b/system/kernel/architecture/x86_64/cpuid.zig @@ -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; +} diff --git a/system/kernel/architecture/x86_64/per-cpu.zig b/system/kernel/architecture/x86_64/per-cpu.zig index 4e9e8f2..03ba211 100644 --- a/system/kernel/architecture/x86_64/per-cpu.zig +++ b/system/kernel/architecture/x86_64/per-cpu.zig @@ -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; +} diff --git a/system/kernel/architecture/x86_64/smp.zig b/system/kernel/architecture/x86_64/smp.zig index d85b4bd..91d5c18 100644 --- a/system/kernel/architecture/x86_64/smp.zig +++ b/system/kernel/architecture/x86_64/smp.zig @@ -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) diff --git a/system/kernel/kernel.zig b/system/kernel/kernel.zig index b9e1e29..df087f3 100644 --- a/system/kernel/kernel.zig +++ b/system/kernel/kernel.zig @@ -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. diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index a98c662..01cd788 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -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", .{}); diff --git a/test/qemu_test.py b/test/qemu_test.py index 5426a32..feadc2a 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -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",