From 36a7cc5fe9f709789aa40b69188526ffc57befd3 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:05:58 +0100 Subject: [PATCH 1/3] kernel: ring 0 cannot execute a user page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMEP turns the classic escalation — divert kernel control flow into a page the attacker wrote — from a silent takeover into an immediate fault with the offending address in the log. The bit is per-core state, so it is set where the syscall MSRs already are: in the per-CPU bring-up both the boot processor and every application processor run on their way in. A core that climbed the trampoline without it would be a hole no boot log would show, which is why the SMP case now reads CR4 on each core it lands on and requires every one of them to be hardened, not just the one that printed the banner. Enabling it that early is only safe because nothing ring 0 executes is mapped for ring 3, and that had to be established rather than assumed: kernel text carries only its ELF flags, the physmap is no-execute, the trampoline page is mapped supervisor and the core running it has not enabled the bit yet, and the boot processor turns it on while still on the loader's tables — which map nothing user-accessible at all. The one indirect call in the kernel takes a kernel address. The CPUID probing that was scattered across the timer code becomes a small shared helper, since the feature question is now asked from two places and each wanted the same maximum-leaf guard. Absence is tolerated and reported, like the IOMMU: danos still boots on a machine without the feature, and says which one it is. The test harness starts asking QEMU for a CPU that has the bit at all — its default model has neither SMEP nor SMAP, so the code would otherwise have been unreachable in every run. No case behaved differently under the richer model. Suite 112/112, with a new case that maps an executable user page, calls into it from the kernel, and requires the fault the CPU is supposed to raise. --- docs/os-development/smep-smap.md | 2 +- docs/security-track-plan.md | 10 +-- system/kernel/architecture/x86_64/apic.zig | 27 ++----- system/kernel/architecture/x86_64/cpu.zig | 15 ++++ system/kernel/architecture/x86_64/cpuid.zig | 42 +++++++++++ system/kernel/architecture/x86_64/per-cpu.zig | 61 ++++++++++++++++ system/kernel/architecture/x86_64/smp.zig | 4 + system/kernel/kernel.zig | 9 +++ system/kernel/tests.zig | 73 ++++++++++++++++++- test/qemu_test.py | 17 +++++ 10 files changed, 231 insertions(+), 29 deletions(-) create mode 100644 system/kernel/architecture/x86_64/cpuid.zig 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", From cb30faf15f3cd9d57fc12ec24d553e6d3d5d3971 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:22:07 +0100 Subject: [PATCH 2/3] kernel: a hostile return address cannot fault the kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SYSRETQ with a non-canonical RIP raises a general protection fault in ring 0 — on the kernel stack, an instruction after the swapgs that installed the user's GS base. It is one of the better-known escalation primitives, and ring 3 reaches it without any kernel bug at all: the processor saves the address of the instruction after SYSCALL, so a program whose SYSCALL is the last two bytes of the last canonical page returns to the first non-canonical address. The new test does exactly that. The exit path now sign-extends the return address from bit 47 and compares; if the value changed, it returns through IRETQ instead, which commits the privilege change before fetching the new address, so the fault arrives from ring 3 and the process dies like any other. Four register-only operations and a branch that a correct program can never take — it could not have executed at a non-canonical address in the first place. Bit 47 is the right pivot because danos builds four-level page tables and nothing sets the five-level bit; a future port must move the pivot, and the comment says so. SFMASK grows one bit while we are here. SYSCALL, unlike an interrupt gate, does not clear the nested-task flag, so the kernel had been running every system call with whatever ring 3 last chose — harmless while the only exit was SYSRETQ, and a question worth not having now that one exit is IRETQ. The kernel is never nested; ring 3 still gets its own flag back. Suite 113/113. The new case asserts the refusal counter rather than the dying process: the emulator we test on kills it either way, so only the counter distinguishes a guard that ran from one that did not. --- docs/os-development/smep-smap.md | 8 +- docs/security-track-plan.md | 25 ++++- system/kernel/architecture/x86_64/cpu.zig | 14 +++ system/kernel/architecture/x86_64/isr.s | 89 ++++++++++++++++- system/kernel/architecture/x86_64/per-cpu.zig | 11 ++- system/kernel/process.zig | 13 ++- system/kernel/tests.zig | 95 +++++++++++++++++++ test/qemu_test.py | 15 +++ 8 files changed, 257 insertions(+), 13 deletions(-) diff --git a/docs/os-development/smep-smap.md b/docs/os-development/smep-smap.md index d97f807..af0cee7 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. H2 (SMEP) has landed; HS and H3 are the remaining work. Companion to +*Design, 2026-07-31. H2 (SMEP) and HS (the SYSRET guard) have landed; H3 is 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.* @@ -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 dd8df3c..c2ce9f4 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 | **HS** — SYSRET canonical-RIP guard | +| 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 — lands with the group 4 merge | -| Suite | 112 cases, all passing | +| Awaiting merge | H2, HS — land with the group 4 merge | +| Suite | 113 cases, all passing | | Last updated | 2026-08-01 | A checkbox below means the phase met its definition of green and was @@ -81,7 +81,20 @@ group boundary. refusal paired with a control; suite 111/111) - [x] **merge** group 3 → main, push - [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 +- [x] **HS** — SYSRET canonical-RIP guard (the syscall exit sign-extends the + return RIP from bit 47 and returns through `iretq` when that changes it — + four register ALU ops and a never-taken branch on the hot path, no load; the + fallback un-pops the rip slot so `iretq` consumes the frame entry already + built, reloads R11 from the rflags slot, and keeps the `swapgs` in the same + place relative to the ring change. 4-level is not an assumption but a fact: + nothing sets CR4.LA57, and the comment says what a 5-level port must change. + Ring 3 **can** reach the hazard — `syscall` as the last two bytes of the last + canonical page returns to `user_half_end` — so the new `sysret-canonical` + case is a real ring-3 probe doing exactly that, and dies of a ring-3 #GP at + `0x0000800000000000` while the kernel runs on. Its teeth are the refusal + 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 @@ -518,6 +531,8 @@ put the text in `expect`, per `qemu_test.py:189`). Suite 112. **Test:** kernel unit case driving a thread whose return RIP is forged non-canonical via the syscall path if constructible cheaply; otherwise the review-level proof plus the existing fault cases regression. Suite 112. +*(Landed: it was constructible, and from ring 3 rather than by forgery — the +`sysret-canonical` case, suite 113.)* ## H3 — SMAP @@ -532,7 +547,7 @@ 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. -Suite 113. +Suite 114 (HS added one). --- diff --git a/system/kernel/architecture/x86_64/cpu.zig b/system/kernel/architecture/x86_64/cpu.zig index 6775191..80ca8b1 100644 --- a/system/kernel/architecture/x86_64/cpu.zig +++ b/system/kernel/architecture/x86_64/cpu.zig @@ -315,6 +315,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/isr.s b/system/kernel/architecture/x86_64/isr.s index 704d844..0bba73f 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. diff --git a/system/kernel/architecture/x86_64/per-cpu.zig b/system/kernel/architecture/x86_64/per-cpu.zig index 03ba211..9ccd484 100644 --- a/system/kernel/architecture/x86_64/per-cpu.zig +++ b/system/kernel/architecture/x86_64/per-cpu.zig @@ -80,7 +80,16 @@ 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) --------------------------------------- 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 01cd788..f763a23 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -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, "sysret-canonical")) { + sysretCanonicalTest(); } else if (eql(case, "usermem")) { userMemTest(); } else if (eql(case, "user-memory")) { @@ -1667,6 +1669,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 diff --git a/test/qemu_test.py b/test/qemu_test.py index feadc2a..c7402be 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -355,6 +355,21 @@ CASES = [ 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"}, + # 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", From 5a5146ec13526f2ebf08c16a5d039c57bb5ace97 Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:20:44 +0100 Subject: [PATCH 3/3] kernel: ring 0 reaches user memory only through the checked copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/coding-standards.md | 20 +++ docs/os-development/smep-smap.md | 4 +- docs/security-track-plan.md | 38 ++++-- system/kernel/architecture/x86_64/cpu.zig | 17 ++- system/kernel/architecture/x86_64/isr.s | 20 +++ system/kernel/architecture/x86_64/per-cpu.zig | 115 +++++++++++++++++- system/kernel/architecture/x86_64/smp.zig | 5 +- system/kernel/kernel.zig | 7 ++ system/kernel/tests.zig | 75 ++++++++++++ test/qemu_test.py | 13 ++ 10 files changed, 301 insertions(+), 13 deletions(-) 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 af0cee7..a71c3a1 100644 --- a/docs/os-development/smep-smap.md +++ b/docs/os-development/smep-smap.md @@ -1,7 +1,7 @@ # SMEP and SMAP — supervisor-mode hardening -*Design, 2026-07-31. H2 (SMEP) and HS (the SYSRET guard) have landed; H3 is the -remaining work. 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.* 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/cpu.zig b/system/kernel/architecture/x86_64/cpu.zig index 80ca8b1..a68e70d 100644 --- a/system/kernel/architecture/x86_64/cpu.zig +++ b/system/kernel/architecture/x86_64/cpu.zig @@ -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. diff --git a/system/kernel/architecture/x86_64/isr.s b/system/kernel/architecture/x86_64/isr.s index 0bba73f..277fcb3 100644 --- a/system/kernel/architecture/x86_64/isr.s +++ b/system/kernel/architecture/x86_64/isr.s @@ -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 diff --git a/system/kernel/architecture/x86_64/per-cpu.zig b/system/kernel/architecture/x86_64/per-cpu.zig index 9ccd484..9cd2ace 100644 --- a/system/kernel/architecture/x86_64/per-cpu.zig +++ b/system/kernel/architecture/x86_64/per-cpu.zig @@ -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; +} diff --git a/system/kernel/architecture/x86_64/smp.zig b/system/kernel/architecture/x86_64/smp.zig index 91d5c18..2428069 100644 --- a/system/kernel/architecture/x86_64/smp.zig +++ b/system/kernel/architecture/x86_64/smp.zig @@ -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) diff --git a/system/kernel/kernel.zig b/system/kernel/kernel.zig index df087f3..c7eee06 100644 --- a/system/kernel/kernel.zig +++ b/system/kernel/kernel.zig @@ -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. diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index f763a23..d815c89 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -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", .{}); diff --git a/test/qemu_test.py b/test/qemu_test.py index c7402be..936fe92 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -355,6 +355,19 @@ CASES = [ 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.