kernel: ring 0 cannot execute a user page

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.
This commit is contained in:
Daniel Samson
2026-08-01 10:05:58 +01:00
parent b3011fbb40
commit 36a7cc5fe9
10 changed files with 231 additions and 29 deletions
+72 -1
View File
@@ -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", .{});