merge: security track group 4 — SMEP, the SYSRET guard, and SMAP

# Conflicts:
#	docs/security-track-plan.md
This commit is contained in:
Daniel Samson 2026-08-01 12:20:54 +01:00
commit a85f29edc9
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
13 changed files with 757 additions and 38 deletions

View File

@ -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 is a clipped form of?* If yes, write the word. If it's an initialism standing in for a
phrase, leave it. 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 ## Zen of Zig
* Communicate intent precisely. * Communicate intent precisely.

View File

@ -1,6 +1,7 @@
# SMEP and SMAP — supervisor-mode hardening # SMEP and SMAP — supervisor-mode hardening
*Design, 2026-07-31. Not yet implemented. Companion to *Design, 2026-07-31. H1 (the copy layer), H2 (SMEP), HS (the SYSRET guard) and
H3 (SMAP) have all landed; the track is complete. Companion to
[protocol-namespace.md](protocol-namespace.md) on the security track — this is [protocol-namespace.md](protocol-namespace.md) on the security track — this is
the hardware half; that is the namespace half.* 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 hardening bucket, independent fix (validate RCX before `sysretq`, fall
back to `iretq`), should ride the same branch as H2/H3 but is not back to `iretq`), should ride the same branch as H2/H3 but is not
SMEP/SMAP. 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 - **KPTI / Meltdown-class leaks are out of scope.** SMEP/SMAP police
architectural accesses, not speculative ones. danos runs one kernel architectural accesses, not speculative ones. danos runs one kernel
mapping in every address space and accepts that on affected hardware; mapping in every address space and accepts that on affected hardware;

View File

@ -36,11 +36,11 @@ plain `main` checkout always tells the truth about where the work is.**
| | | | | |
|---|---| |---|---|
| Working on | **H3** — SMAP (the last phase) | | Working on | nothing — the track is complete |
| Branch carrying it | `feat/security-group-4` (pushed to origin) | | Branch carrying it | |
| On `main` | everything through P4c — groups 1, 2 and 3 merged | | On `main` | every phase — groups 1, 2, 3 and 4 merged |
| Awaiting merge | H2, HS — land with the group 4 merge | | Awaiting merge | nothing |
| Suite | 113 cases, all passing | | Suite | 114 cases, all passing |
| Last updated | 2026-08-01 | | Last updated | 2026-08-01 |
A checkbox below means the phase met its definition of green and was 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 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 not model Intel's ring-0 #GP and every outcome check still passed. Suite
113/113) 113/113)
- [ ] **H3** — SMAP + boot-patched `clac`; `-cpu max` in the harness; negative tests - [x] **H3** — SMAP on every core, and the boot-patched `clac` that makes it
- [ ] **merge** group 4 → main, push 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 **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 user page; expect vector 14 + `error code : 0x1` + kernel IP. And the
whole suite becomes the tripwire — any missed straggler now fails loudly. 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). Suite 114 (HS added one).
--- ---

View File

@ -11,6 +11,7 @@
const boot_handoff = @import("boot-handoff"); const boot_handoff = @import("boot-handoff");
const io = @import("io.zig"); const io = @import("io.zig");
const cpuid = @import("cpuid.zig");
const paging = @import("paging.zig"); const paging = @import("paging.zig");
/// The ACPI PM timer, as a calibration reference: an I/O port or MMIO counter. /// 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 /// 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). /// null if the CPU doesn't enumerate it (common under QEMU, and on AMD).
fn cpuidTscHz() ?u64 { fn cpuidTscHz() ?u64 {
if (cpuid(0).eax < 0x15) return null; if (!cpuid.supports(0x15)) return null;
const r = cpuid(0x15); const r = cpuid.leaf(0x15);
if (r.eax == 0 or r.ebx == 0 or r.ecx == 0) return null; // ratio/crystal not given 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; 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 /// at a constant rate across P/C-states and never stops. Requires the extended-leaf
/// range to reach 0x80000007 first. /// range to reach 0x80000007 first.
fn tscIsInvariant() bool { fn tscIsInvariant() bool {
if (cpuid(0x80000000).eax < 0x80000007) return false; if (!cpuid.supports(0x8000_0007)) return false;
return (cpuid(0x80000007).edx & (1 << 8)) != 0; return (cpuid.leaf(0x8000_0007).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 };
} }
// HPET registers: capabilities at +0x00 (period in the high dword, in fs; bit 13 = // HPET registers: capabilities at +0x00 (period in the high dword, in fs; bit 13 =

View File

@ -141,11 +141,41 @@ pub fn debugconWrite(bytes: []const u8) void {
/// stack for double faults), then the IDT with exception handlers. After this a /// 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 /// CPU fault is reported instead of triple-faulting. Install the fault handler
/// (setFaultHandler) first so early faults are caught. /// (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 { pub fn init() void {
gdt.init(); gdt.init();
tss.init(); tss.init();
idt.init(); idt.init();
pcpu.initSystemCall(); pcpu.initSystemCall();
// The machine-wide half of SMAP, and it must precede every CR4 write: the
// interrupt entry has to be able to clear EFLAGS.AC before any core claims the
// bit. This is the boot processor and the application processors are still
// parked, so "before every core" is simply "here".
_ = pcpu.armSupervisorAccessPrevention();
// Safe this early, before the kernel is on its own page tables: the loader's
// bootstrap tables (boot/efi.zig) map with present|writable and never set the
// U/S bit, so no page the BSP executes from is user-accessible and, for SMAP,
// no page it *reads* is either, so there is nothing for the new bit to refuse
// between here and the switch to the kernel's own tables.
pcpu.initHardening();
}
/// Whether ring 0 is barred from executing user-mapped pages on this core (CR4.SMEP
/// on x86_64; the privileged-execute-never behaviour elsewhere). False means the CPU
/// doesn't offer it and the machine is running unhardened see per-cpu.zig.
pub fn supervisorExecutePreventionEnabled() bool {
return pcpu.supervisorExecutePreventionEnabled();
}
/// Whether ring 0 is barred from *reading or writing* user-mapped pages on this core
/// (CR4.SMAP on x86_64; the privileged-access-never behaviour elsewhere). False means
/// either the CPU doesn't offer it or the interrupt entry could not be armed to clear
/// AC see per-cpu.zig; the machine boots either way, unhardened and saying so.
pub fn supervisorAccessPreventionEnabled() bool {
return pcpu.supervisorAccessPreventionEnabled();
} }
/// Build the kernel's own page tables (with real permissions) and switch onto /// Build the kernel's own page tables (with real permissions) and switch onto
@ -300,6 +330,20 @@ pub fn userExit() noreturn {
user_exit_to_kernel(); 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 /// Register the handler for the user system_call gate (int 0x80, vector 128). The
/// handler may write the trap frame (see `setSystemCallResult`). /// handler may write the trap frame (see `setSystemCallResult`).
pub fn setSystemCallHandler(handler: *const fn (*CpuState) void) void { pub fn setSystemCallHandler(handler: *const fn (*CpuState) void) void {

View File

@ -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;
}

View File

@ -187,11 +187,9 @@ user_exit_to_kernel:
# CS/SS from STAR, masks RFLAGS with SFMASK (so IF is already clear), and jumps # 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 # 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 # 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. # 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
# Hazard (acceptable while init is the only, trusted, user program): SYSRETQ #GPs # the exit below returns through IRETQ instead (docs/os-development/smep-smap.md).
# 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.
.global syscall_entry .global syscall_entry
syscall_entry: syscall_entry:
swapgs # kernel GS base swapgs # kernel GS base
@ -249,16 +247,79 @@ syscall_entry:
pop %rax pop %rax
add $16, %rsp # drop vector + error_code -> rsp at rip add $16, %rsp # drop vector + error_code -> rsp at rip
popq %rcx # rip -> RCX (SYSRETQ restores RIP from RCX) 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) addq $8, %rsp # skip the cs slot (SYSRETQ loads CS from STAR)
popq %r11 # rflags -> R11 (SYSRETQ restores RFLAGS from R11) popq %r11 # rflags -> R11 (SYSRETQ restores RFLAGS from R11)
popq %rsp # user rsp (the ss slot below is abandoned) popq %rsp # user rsp (the ss slot below is abandoned)
swapgs # user GS base swapgs # user GS base
sysretq # -> ring 3: RIP=RCX, RFLAGS=R11, CS/SS from STAR 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 .section .bss
.balign 8 .balign 8
user_saved_rsp: user_saved_rsp:
.skip 8 .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 .text
# --- user-mode test program -------------------------------------------------- # --- user-mode test program --------------------------------------------------
@ -282,6 +343,24 @@ user_pf_start:
1: jmp 1b 1: jmp 1b
user_pf_end: 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 .text
# Stub for a vector the CPU does NOT push an error code for: push a dummy 0. # Stub for a vector the CPU does NOT push an error code for: push a dummy 0.
@ -363,7 +442,27 @@ STUB_NOERR 128
# If the interrupt came from ring 3 the GS base holds the user's value, so swap # 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 # 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). # 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_common:
isr_smap_patch:
.byte 0x0f, 0x1f, 0x00 # nopl (%rax) -> patched to `clac` when the CPU has SMAP
testb $3, 24(%rsp) testb $3, 24(%rsp)
jz 1f jz 1f
swapgs swapgs

View File

@ -11,9 +11,24 @@
//! transition is always an exit (the kernel starts in ring 0), the swap pairs //! 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 //! keep the invariant without seeding KERNEL_GS_BASE. `scheduler()` is therefore
//! valid in any ring-0 context and never sees a user-controlled base. //! valid in any ring-0 context and never sees a user-controlled base.
//!
//! The file has since become the home of **per-core CPU state set at bring-up**
//! generally, not just the GS block: the fast-system_call MSRs and the CR4
//! hardening bits live here too, because each is state a core owns and must set
//! for itself. Both bring-up paths `cpu.init` on the boot processor and
//! `smp.apEntry` on every application processor call the same functions here.
//!
//! One deliberate exception to "per-core": `armSupervisorAccessPrevention` patches
//! a machine-wide instruction into the shared interrupt entry, once, on the boot
//! processor. It lives here anyway because it is the other half of CR4.SMAP
//! same feature probe, same fail-open posture and splitting a hardening measure
//! across two files is how the halves drift apart.
const std = @import("std"); const std = @import("std");
const io = @import("io.zig"); 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 parameters = @import("parameters");
const ia32_gs_base = 0xC000_0101; const ia32_gs_base = 0xC000_0101;
@ -73,5 +88,171 @@ pub fn initSystemCall() void {
io.wrmsr(ia32_star, (@as(u64, 0x08) << 32) | (@as(u64, 0x10) << 48)); io.wrmsr(ia32_star, (@as(u64, 0x08) << 32) | (@as(u64, 0x10) << 48));
const entry = @extern(*const anyopaque, .{ .name = "syscall_entry" }); const entry = @extern(*const anyopaque, .{ .name = "syscall_entry" });
io.wrmsr(ia32_lstar, @intFromPtr(entry)); io.wrmsr(ia32_lstar, @intFromPtr(entry));
io.wrmsr(ia32_sfmask, 0x4_0700); // clear IF, TF, DF, AC on entry // Clear IF, TF, DF, AC and NT on entry. The first four are the usual
// hygiene; NT is here because SYSCALL, unlike an interrupt gate, does not
// clear it for us, so without this the kernel runs every system call with
// whatever nested-task bit ring 3 last chose and the canonical-RIP guard's
// cold path (isr.s) leaves through IRETQ, whose behaviour with NT set is a
// corner of the manuals not worth depending on either way. Masking it costs
// one bit and removes the question: the kernel is never nested, and ring 3
// still gets its own NT back, from R11 on the fast path and from the frame
// on the cold one.
io.wrmsr(ia32_sfmask, 0x4_4700);
}
// --- supervisor-mode hardening (CR4) ---------------------------------------
//
// CR4 is per-core state, so these bits are set during *every* core's bring-up
// the BSP in cpu.init, each AP in smp.apEntry and not in the AP trampoline,
// which stays minimal and would only cover the APs anyway.
/// CR4.SMEP: an instruction fetch in ring 0 from a page whose U/S bit says *user*
/// raises #PF. This is what makes the classic ret2usr shape (a kernel bug steered
/// into attacker-prepared user code) a loud, attributable fault instead of a
/// silent compromise. danos never executes user-mapped memory in ring 0 kernel
/// text lives in the higher half, the ring-3 entry paths are kernel code, and the
/// AP trampoline page is a supervisor mapping so nothing legitimate is refused.
const cr4_smep: u64 = 1 << 20;
/// SMEP's feature bit: CPUID leaf 7, sub-leaf 0, EBX bit 7.
fn smepSupported() bool {
if (!cpuid.supports(7)) return false;
return cpuid.leaf(7).ebx & (1 << 7) != 0;
}
/// CR4.SMAP: a ring-0 data *read or write* to a page whose U/S bit says *user*
/// raises #PF, unless EFLAGS.AC is set. It is the standing enforcement behind
/// system/kernel/user-memory.zig: that layer never dereferences a user virtual
/// address it walks the process's tables and moves bytes through the physmap,
/// kernel mappings throughout so nothing legitimate in this kernel is refused,
/// and any future code that reaches for a user pointer directly faults the first
/// time it runs. danos therefore opens no `stac` window anywhere; there is no
/// correct reason to have one, and adding one is how the guarantee is lost.
const cr4_smap: u64 = 1 << 21;
/// SMAP's feature bit: CPUID leaf 7, sub-leaf 0, EBX bit 20.
fn smapSupported() bool {
if (!cpuid.supports(7)) return false;
return cpuid.leaf(7).ebx & (1 << 20) != 0;
}
/// `clac` the three bytes that replace the NOP at `isr_smap_patch` once the
/// interrupt entry is allowed to execute them.
const clac_opcode = [_]u8{ 0x0f, 0x01, 0xca };
/// Set only once the interrupt entry really clears AC, and read by every core
/// before it turns SMAP on. Nothing turns SMAP on until this is true, so there is
/// no window not even on the boot processor, not even before ring 3 exists
/// in which the bit is live while an interrupt could still be taken with AC set.
var interrupt_entry_clears_ac = false;
fn readCr3() u64 {
return asm volatile ("mov %%cr3, %[out]"
: [out] "=r" (-> u64),
);
}
/// Patch the `clac` into the shared interrupt entry, and by doing so authorize
/// CR4.SMAP. **Boot processor only, once, before any core sets the bit and before
/// the application processors are woken** `initHardening` refuses SMAP until
/// this has run, so the order is enforced rather than merely documented, and an AP
/// climbing the trampoline cannot get ahead of it.
///
/// The write goes through the physmap, not through the kernel's own view of its
/// text: once `paging.init` has run, kernel `.text` is mapped read-only under W^X,
/// and a store to it would fault (or, worse, silently need CR0.WP cleared). The
/// physmap alias of the same frame is an ordinary supervisor RW mapping the same
/// door `smp.arm` uses to write the AP trampoline and `process.run` uses to fill a
/// read-only user code frame. Going through it also makes this correct under
/// *either* set of tables: the loader's bootstrap tables map the image writable,
/// the kernel's own do not, and this runs before the switch.
///
/// Three bytes, translated one at a time, so a patch site that straddles a page
/// boundary is not a special case. A translation that fails leaves the NOP in
/// place and returns false, and the machine then boots without SMAP rather than
/// with SMAP and an entry path that cannot clear AC.
pub fn armSupervisorAccessPrevention() bool {
if (!smapSupported()) return false;
const site = @intFromPtr(@extern([*]const u8, .{ .name = "isr_smap_patch" }));
const root = readCr3() & 0x000F_FFFF_FFFF_F000;
// MUST run with interrupts masked, and does: this is reached from cpu.init,
// long before the kernel's `sti`, and before any application processor exists.
// The reason is that the three bytes go in one at a time, and the middle state
// 0f 01 00, once the second byte lands is `sgdt (%rax)`, a ten-byte write
// to wherever RAX points, sitting at the first instruction of every interrupt
// entry. Nothing can take that entry here, so nothing can execute it. A future
// change that moves this after interrupts are enabled has to close that window
// first (one 16-bit store covering both changed bytes is the shape, but it
// needs the two to be physically contiguous and 2-byte aligned a naive
// version of exactly that triple-faulted this kernel).
for (clac_opcode, 0..) |byte, i| {
const physical = paging.translateIn(root, site + i) orelse return false;
const alias: *volatile u8 = @ptrFromInt(boot_handoff.physicalToVirtual(physical));
alias.* = byte;
}
// The bytes were written through a different linear address than the one they
// will be fetched from, so serialize before anyone can execute them: CPUID is
// the architecturally sanctioned way to discard whatever the core prefetched or
// decoded of the old encoding.
_ = cpuid.leaf(0);
// Read back through the *text* address, not the alias just written: that is the
// view the CPU will fetch from, so this is what proves the two are the same
// physical page and the patch landed where it will actually execute. A mismatch
// means the translation lied, and SMAP stays off rather than being enabled over
// an entry path that cannot clear AC.
const installed: [*]const volatile u8 = @ptrFromInt(site);
for (clac_opcode, 0..) |byte, i| {
if (installed[i] != byte) return false;
}
interrupt_entry_clears_ac = true;
return true;
}
fn readCr4() u64 {
return asm volatile ("mov %%cr4, %[out]"
: [out] "=r" (-> u64),
);
}
fn writeCr4(value: u64) void {
asm volatile ("mov %[in], %%cr4"
:
: [in] "r" (value),
: .{ .memory = true });
}
/// Turn on the supervisor-mode hardening this CPU offers, on the calling core.
/// Called once per core, next to `initSystemCall`, from both bring-up paths.
///
/// **Fail-open, like the IOMMU and the clocksource:** an absent feature is a
/// machine that boots unhardened, not a machine that refuses to boot. danos has
/// to run on any VM, on real Intel and on real AMD; the boot log states the
/// posture either way (see the platform block in system/kernel/kernel.zig), so
/// an unhardened boot is visible rather than assumed.
pub fn initHardening() void {
var bits: u64 = 0;
if (smepSupported()) bits |= cr4_smep;
// SMAP only after the boot processor has armed the interrupt entry: with the
// NOP still in place an interrupt inherits ring 3's AC and suspends SMAP for
// the length of the handler, which is worse than not claiming the bit at all.
if (smapSupported() and interrupt_entry_clears_ac) bits |= cr4_smap;
if (bits == 0) return;
writeCr4(readCr4() | bits);
}
/// Whether supervisor-mode execution prevention is live on *this* core. Read
/// straight out of CR4 rather than a remembered probe result, so the answer is
/// the state the hardware is actually in which is what both the boot log and
/// the `fault-smep` test case want to assert.
pub fn supervisorExecutePreventionEnabled() bool {
return readCr4() & cr4_smep != 0;
}
/// Whether supervisor-mode *access* prevention is live on *this* core. Read from
/// CR4 for the same reason as its neighbour: the question the boot log and the
/// `fault-smap` case are asking is what the hardware is doing, not what a probe
/// once concluded.
pub fn supervisorAccessPreventionEnabled() bool {
return readCr4() & cr4_smap != 0;
} }

View File

@ -179,6 +179,13 @@ fn apEntry(percpu: usize) callconv(.c) noreturn {
idt.loadOnThisCpu(); // the shared IDT idt.loadOnThisCpu(); // the shared IDT
pcpu.setLocal(cpu, percpu); // per-CPU block via GS base *after* the GDT reload pcpu.setLocal(cpu, percpu); // per-CPU block via GS base *after* the GDT reload
pcpu.initSystemCall(); // enable system_call/sysret on this core pcpu.initSystemCall(); // enable system_call/sysret on this core
// CR4 is per-core: this core starts from the trampoline's CR4 (PAE + SSE only),
// so it sets its own hardening bits here rather than in the trampoline one
// Zig code path shared with the BSP (cpu.init), and the trampoline stays minimal.
// CR4.SMEP and CR4.SMAP on this core, if the CPU has them. The `clac` the SMAP
// bit depends on was patched into the shared interrupt entry by the BSP long
// before this core was woken, so an AP only ever finds it already armed.
pcpu.initHardening();
apic.initSecondary(); // software-enable this core's LAPIC apic.initSecondary(); // software-enable this core's LAPIC
apic.initTimer(apic.frequencyHz()); // arm its timer (still masked: interrupts off) apic.initTimer(apic.frequencyHz()); // arm its timer (still masked: interrupts off)

View File

@ -279,6 +279,22 @@ fn kmain(boot_information: *const BootInformation) noreturn {
log.print(" cpus : {d} usable core(s); 1 running (BSP), {d} AP(s) parked (SMP bring-up pending)\n", .{ cores.len, if (cores.len > 0) cores.len - 1 else 0 }); 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) if (platform.cpusDropped() > 0)
log.print(" cpus : WARNING {d} core(s) beyond pool cap dropped\n", .{platform.cpusDropped()}); log.print(" cpus : WARNING {d} core(s) beyond pool cap dropped\n", .{platform.cpusDropped()});
// The supervisor-execution posture, stated plainly at every boot. Every core
// sets the bit during its own bring-up (architecture/x86_64/per-cpu.zig); the
// BSP answers for the machine here, because the feature is a property of the
// CPU model, not of an individual core. Fail-open like the IOMMU: a CPU without
// it still boots, it just boots unhardened, and says so.
if (architecture.supervisorExecutePreventionEnabled())
log.write(" smep : enabled - ring 0 cannot execute user pages\n")
else
log.write(" smep : absent - ring-0 execution of user pages unprevented\n");
// The supervisor-access posture, the same way. Its second half is a boot-time
// patch (the `clac` at the interrupt entry), so "absent" here covers both a
// CPU without the feature and a patch that could not be applied.
if (architecture.supervisorAccessPreventionEnabled())
log.write(" smap : enabled - ring 0 reaches user memory only through the checked copy layer\n")
else
log.write(" smap : absent - ring-0 access to user pages unprevented\n");
// The DMA-isolation posture, stated plainly at every boot. When a unit exists, // 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 // 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. // fail-open case, so a boot without the line is a boot with translation on.

View File

@ -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_null: u64 = 0; // AT_NULL end of the vector
const auxiliary_vector_page_size: u64 = 6; // AT_PAGESZ 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_start = @extern([*]const u8, .{ .name = "user_pf_start" });
const pf_end = @extern([*]const u8, .{ .name = "user_pf_end" }); 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. /// The isolation-proof program: reads a kernel-only page, must #PF.
pub fn pfBlob() []const u8 { pub fn pfBlob() []const u8 {
return pf_start[0 .. @intFromPtr(pf_end) - @intFromPtr(pf_start)]; 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. /// What debug_write syscalls produced (accumulated), and the exit system_call's code.
pub var write_buffer: [256]u8 = undefined; pub var write_buffer: [256]u8 = undefined;
pub var write_len: usize = 0; pub var write_len: usize = 0;

View File

@ -140,6 +140,12 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
faultNoExecute(); faultNoExecute();
} else if (eql(case, "fault-null")) { } else if (eql(case, "fault-null")) {
faultNull(); 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")) { } else if (eql(case, "usermem")) {
userMemTest(); userMemTest();
} else if (eql(case, "user-memory")) { } else if (eql(case, "user-memory")) {
@ -765,6 +771,15 @@ fn sleepTest() void {
// --- SMP parallelism ------------------------------------------------------ // --- SMP parallelism ------------------------------------------------------
var seen_core = [_]bool{false} ** 8; var seen_core = [_]bool{false} ** 8;
/// Whether the core this worker ran on had SMEP on in its own CR4. Recorded per
/// core because CR4 is per-core state: the boot processor enabling it says
/// nothing about the ones the trampoline brought up, and the whole hardening is
/// only as wide as its narrowest core.
var seen_core_smep = [_]bool{false} ** 8;
/// The same, for SMAP. A core that came up without it would still be able to read
/// and write user pages from ring 0 while every other core could not a hole
/// whose only symptom is that the tripwire never trips there.
var seen_core_smap = [_]bool{false} ** 8;
var smp_running: bool = true; var smp_running: bool = true;
/// A worker that, while running, records which core it's executing on. Spread across /// A worker that, while running, records which core it's executing on. Spread across
@ -773,7 +788,11 @@ fn smpWorker() void {
const p: *volatile bool = &smp_running; const p: *volatile bool = &smp_running;
while (p.*) { while (p.*) {
const c = scheduler.currentCpuIndex(); const c = scheduler.currentCpuIndex();
if (c < seen_core.len) seen_core[c] = true; if (c < seen_core.len) {
seen_core[c] = true;
seen_core_smep[c] = architecture.supervisorExecutePreventionEnabled();
seen_core_smap[c] = architecture.supervisorAccessPreventionEnabled();
}
} }
scheduler.exit(); scheduler.exit();
} }
@ -803,6 +822,36 @@ fn smpTest() void {
log("DANOS-SMP: workers ran on {d} distinct core(s)\n", .{cores_seen}); log("DANOS-SMP: workers ran on {d} distinct core(s)\n", .{cores_seen});
check("tasks ran on multiple cores in parallel", cores_seen >= 2); check("tasks ran on multiple cores in parallel", cores_seen >= 2);
// Every core that ran work must have had SMEP on, not just the one that
// booted: an application processor climbs through the trampoline with CR4
// bare and sets the bit itself on the way in, so a hardening that reached
// only the boot processor would leave every other core able to execute a
// user page in ring 0 and would look identical from the boot log. Only
// asserted where the platform has SMEP at all, so a machine without it
// still passes rather than failing for the one reason that is not a bug.
if (architecture.supervisorExecutePreventionEnabled()) {
var hardened: u32 = 0;
for (seen_core, seen_core_smep) |ran, smep| {
if (ran and smep) hardened += 1;
}
log("DANOS-SMP: {d} of {d} core(s) had supervisor execute prevention\n", .{ hardened, cores_seen });
check("every core that ran work had SMEP enabled", hardened == cores_seen);
}
// And the same for SMAP, for the same reason and with one more of its own: the
// bit is refused until the boot processor has patched the `clac` into the shared
// interrupt entry, so a core reporting SMAP on is also a core confirming it came
// up after that patch the ordering the whole scheme rests on, checked from the
// far end. Gated on the running core the same way, so a CPU without SMAP passes.
if (architecture.supervisorAccessPreventionEnabled()) {
var hardened: u32 = 0;
for (seen_core, seen_core_smap) |ran, smap| {
if (ran and smap) hardened += 1;
}
log("DANOS-SMP: {d} of {d} core(s) had supervisor access prevention\n", .{ hardened, cores_seen });
check("every core that ran work had SMAP enabled", hardened == cores_seen);
}
// Bring-up is done, so the trampoline frame must be inert: zeroed (no stale code) // 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. // and non-executable (W^X restored). It's armed only while a core is climbing.
const tramp = architecture.trampolinePage(); const tramp = architecture.trampolinePage();
@ -1641,6 +1690,99 @@ fn faultRecoveryTest(boot_information: *const BootInformation) void {
result(); 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 /// 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 /// 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 /// exactly once per space no leak, no double-free. Spawn and kill several ring-3
@ -4296,6 +4438,105 @@ fn faultNoExecute() void {
log("DANOS-TEST-RESULT: FAIL (NX not enforced)\n", .{}); log("DANOS-TEST-RESULT: FAIL (NX not enforced)\n", .{});
} }
/// Verify SMEP: a ring-0 instruction fetch from a *user*-mapped page must fault.
///
/// The ret2usr shape, staged deliberately a user code page (present, executable,
/// U/S set) mapped into the address space the kernel itself is running on, then
/// called from ring 0. CR4.SMEP makes the fetch a #PF; without it the `ret` simply
/// returns and the FAIL line below is reached.
///
/// The CPU reports it as: present (bit 0) + instruction fetch (bit 4) = error code
/// 0x11, taken in ring 0, at the user address it tried to fetch from a #PF on an
/// instruction fetch is raised *at* the instruction that could not be fetched, so
/// the reported IP is the probe page, not the kernel-half `call` that jumped there.
/// The enabled-check up front stops the case passing vacuously on a CPU that has no
/// SMEP (where nothing would fault and nothing would be proved).
fn faultSmep() void {
log("DANOS-TEST-BEGIN: fault-smep\n", .{});
if (!architecture.supervisorExecutePreventionEnabled()) {
log("DANOS-TEST-RESULT: FAIL (SMEP not enabled in this boot)\n", .{});
return;
}
const frame = pmm.alloc() orelse {
log("DANOS-TEST-RESULT: FAIL (no frame for the probe page)\n", .{});
return;
};
// Fill through the physmap the user mapping is read-only. A lone `ret` so an
// unenforced fetch lands harmlessly back here (and reports FAIL), int3 padding
// so a stray slide traps instead of running into whatever the frame held.
const code: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame));
@memset(code[0..abi.page_size], 0xCC);
code[0] = 0xC3; // ret
// RO + X *and user-accessible*, in the kernel's own tables: this task is a kernel
// task, so it runs on them (a process would have its own address space).
architecture.mapUserPage(process.code_virtual, frame, false, true);
// Launder the address through empty asm so the backend really forms an indirect
// call rather than folding anything about a known constant target.
var target: u64 = process.code_virtual;
target = asm (""
: [ret] "=r" (-> u64),
: [in] "0" (target),
);
const f: *const fn () void = @ptrFromInt(target);
f(); // ring-0 instruction fetch from a user page -> #PF
log("DANOS-TEST-RESULT: FAIL (SMEP not enforced)\n", .{});
}
/// Verify SMAP: a ring-0 data read from a *user*-mapped page must fault.
///
/// The bug shape this case stands in for is the ordinary one a syscall that takes
/// the pointer ring 3 handed it and dereferences it. So the probe does exactly that
/// and nothing more: an ordinary user data page (present, user-accessible, read-only,
/// no-execute) mapped into the address space this kernel task is already running on,
/// then read directly rather than through system/kernel/user-memory.zig. With SMAP on
/// and EFLAGS.AC clear the load is a #PF; without it the byte comes back and the FAIL
/// line below reports the value it should never have seen.
///
/// The CPU reports it as error code **0x1**: present (bit 0) alone. A SMAP violation
/// has no error-code bit of its own bit 1 stays clear because this is a read, and
/// bit 2 (U/S) describes the *access*, which was made in ring 0, not the page. So the
/// report is indistinguishable in its bits from any other supervisor read of a present
/// page; what identifies it is the pairing with `ring 0 (kernel)` and a user CR2.
///
/// The seed write goes through the physmap, which is the point in miniature: that is
/// the route the checked copy layer takes for every legitimate access to this same
/// frame, and SMAP has no objection to it. Only the second access the one through
/// the *user* virtual address is refused.
///
/// The enabled-check up front stops the case passing vacuously on a CPU without SMAP,
/// and fails rather than skips: a suite that quietly stops testing the tripwire is
/// exactly the outcome the tripwire exists to prevent.
fn faultSmap() void {
log("DANOS-TEST-BEGIN: fault-smap\n", .{});
if (!architecture.supervisorAccessPreventionEnabled()) {
log("DANOS-TEST-RESULT: FAIL (SMAP not enabled in this boot)\n", .{});
return;
}
const frame = pmm.alloc() orelse {
log("DANOS-TEST-RESULT: FAIL (no frame for the probe page)\n", .{});
return;
};
const seed: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame));
@memset(seed[0..abi.page_size], 0);
seed[0] = 0x5A; // a sentinel, so an unenforced read is reported as a value, not a guess
// RO + NX *and user-accessible*, in the kernel's own tables: this task is a kernel
// task, so it runs on them (a process would have its own address space).
architecture.mapUserPage(process.code_virtual, frame, false, false);
// Launder the address through empty asm for the same reason `fault-null` does:
// the backend must form a real load from a runtime address rather than reasoning
// about a constant it can see the provenance of.
var target: u64 = process.code_virtual;
target = asm (""
: [ret] "=r" (-> u64),
: [in] "0" (target),
);
const p: *const volatile u8 = @ptrFromInt(target);
const value = p.*; // ring-0 data read from a user page -> #PF
log("DANOS-TEST-RESULT: FAIL (SMAP not enforced; read 0x{x})\n", .{value});
}
/// Verify the null guard: dereferencing address 0 (page 0 left unmapped) faults. /// Verify the null guard: dereferencing address 0 (page 0 left unmapped) faults.
fn faultNull() void { fn faultNull() void {
log("DANOS-TEST-BEGIN: fault-null\n", .{}); log("DANOS-TEST-BEGIN: fault-null\n", .{});

View File

@ -65,6 +65,11 @@ ARCHES = {
# Built as a function so we can splice in per-run paths. # Built as a function so we can splice in per-run paths.
"qemu_args": lambda a, boot_volume, vars_fd, serial: [ "qemu_args": lambda a, boot_volume, vars_fd, serial: [
"-machine", "q35", "-m", "128M", "-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,readonly=on,file={a['ovmf_code']}",
"-drive", f"if=pflash,format=raw,file={vars_fd}", "-drive", f"if=pflash,format=raw,file={vars_fd}",
# Boot off a FAT USB device: the boot volume is a mass-storage device on # Boot off a FAT USB device: the boot volume is a mass-storage device on
@ -338,6 +343,46 @@ CASES = [
"expect": r"page fault \(vector 14\)", "expect": r"page fault \(vector 14\)",
"fail": r"NX not enforced"}, "fail": r"NX not enforced"},
{"name": "fault-null", "expect": r"page fault \(vector 14\)"}, {"name": "fault-null", "expect": r"page fault \(vector 14\)"},
# SMEP: ring 0 calls into a page that is present, executable and *user*-accessible
# — the ret2usr shape. CR4.SMEP must refuse the instruction fetch: #PF with error
# code 0x11 (present | instruction-fetch; the U/S bit reports the *access* ring, so
# it is 0 for a ring-0 fetch), taken in ring 0, reported at the page it tried to
# fetch from (a fetch #PF is raised at the un-fetchable instruction, so the IP is
# the user address, not the kernel-half call site). The boot line is asserted too:
# on a CPU without SMEP nothing would fault and the case would prove nothing.
{"name": "fault-smep",
"expect": r"(?s)(?=.*smep\s+: enabled)(?=.*page fault \(vector 14\))"
r"(?=.*ring 0 \(kernel\))(?=.*error code : 0x11)"
r"(?=.*fault addr : 0x0000700000000000)",
"fail": r"SMEP not enforced|SMEP not enabled|no frame for the probe page"},
# SMAP: ring 0 reads a page that is present and *user*-accessible, the way a syscall
# that dereferenced its caller's pointer would. CR4.SMAP (plus the `clac` patched
# into the interrupt entry, which keeps ring 3 from suspending it by taking an
# interrupt with AC set) must refuse the load: #PF with error code 0x1 — present,
# and nothing else. A SMAP violation has no bit of its own; bit 1 is clear because
# it is a read and bit 2 (U/S) reports the ring of the *access*, which was 0. Taken
# in ring 0, at the user address it read. The boot line is asserted too: without
# SMAP nothing would fault and the case would prove nothing.
{"name": "fault-smap",
"expect": r"(?s)(?=.*smap\s+: enabled)(?=.*page fault \(vector 14\))"
r"(?=.*ring 0 \(kernel\))(?=.*error code : 0x1(?![0-9a-f]))"
r"(?=.*fault addr : 0x0000700000000000)",
"fail": r"SMAP not enforced|SMAP not enabled|no frame for the probe page"},
# SYSRET canonical-RIP guard: a ring-3 probe whose `syscall` sits on the last two
# bytes of executable address space, so the return RIP the kernel must hand back
# is 0x0000800000000000 — non-canonical, and a ring-0 #GP if it reached SYSRETQ.
# The kernel must return through IRETQ instead, which faults the *process* in
# ring 3: the kill line names a general protection fault at exactly that address,
# and the probe's own result line reports the guard counter. Any "ring 0 (kernel)"
# report, panic or reset here is the hazard itself, so they are failures.
# TCG does not model the ring-0 #GP (measured: with the guard removed these two
# lines are unchanged), so the case's teeth are the counter assertion inside
# DANOS-TEST-RESULT, not the kill line.
{"name": "sysret-canonical",
"expect": r"(?s)(?=.*killed by general protection fault \(vector 13\))"
r"(?=.*IP\s+: 0x0000800000000000)"
r"(?=.*DANOS-TEST-RESULT: PASS)",
"fail": r"DANOS-TEST-RESULT: FAIL|CPU EXCEPTION|KERNEL PANIC"},
# Memory grants: the mmap/munmap path hands out user pages into a process's # 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. # arena, translate resolves them, munmap frees them, and no frames leak.
{"name": "usermem", {"name": "usermem",