diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 935b89a..fe32764 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -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. diff --git a/docs/os-development/smep-smap.md b/docs/os-development/smep-smap.md index 3c35908..a71c3a1 100644 --- a/docs/os-development/smep-smap.md +++ b/docs/os-development/smep-smap.md @@ -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; diff --git a/docs/security-track-plan.md b/docs/security-track-plan.md index c2ce9f4..dce91ff 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 | **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). --- 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..a68e70d 100644 --- a/system/kernel/architecture/x86_64/cpu.zig +++ b/system/kernel/architecture/x86_64/cpu.zig @@ -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 { 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/isr.s b/system/kernel/architecture/x86_64/isr.s index 704d844..277fcb3 100644 --- a/system/kernel/architecture/x86_64/isr.s +++ b/system/kernel/architecture/x86_64/isr.s @@ -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 diff --git a/system/kernel/architecture/x86_64/per-cpu.zig b/system/kernel/architecture/x86_64/per-cpu.zig index 4e9e8f2..9cd2ace 100644 --- a/system/kernel/architecture/x86_64/per-cpu.zig +++ b/system/kernel/architecture/x86_64/per-cpu.zig @@ -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; } diff --git a/system/kernel/architecture/x86_64/smp.zig b/system/kernel/architecture/x86_64/smp.zig index d85b4bd..2428069 100644 --- a/system/kernel/architecture/x86_64/smp.zig +++ b/system/kernel/architecture/x86_64/smp.zig @@ -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) diff --git a/system/kernel/kernel.zig b/system/kernel/kernel.zig index b9e1e29..c7eee06 100644 --- a/system/kernel/kernel.zig +++ b/system/kernel/kernel.zig @@ -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. diff --git a/system/kernel/process.zig b/system/kernel/process.zig index 615a8ae..20371a9 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -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; diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index a98c662..d815c89 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -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", .{}); diff --git a/test/qemu_test.py b/test/qemu_test.py index 5426a32..936fe92 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,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",