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] 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.