kernel: a hostile return address cannot fault the kernel

SYSRETQ with a non-canonical RIP raises a general protection fault in ring
0 — on the kernel stack, an instruction after the swapgs that installed the
user's GS base. It is one of the better-known escalation primitives, and
ring 3 reaches it without any kernel bug at all: the processor saves the
address of the instruction after SYSCALL, so a program whose SYSCALL is the
last two bytes of the last canonical page returns to the first
non-canonical address. The new test does exactly that.

The exit path now sign-extends the return address from bit 47 and compares;
if the value changed, it returns through IRETQ instead, which commits the
privilege change before fetching the new address, so the fault arrives from
ring 3 and the process dies like any other. Four register-only operations
and a branch that a correct program can never take — it could not have
executed at a non-canonical address in the first place. Bit 47 is the right
pivot because danos builds four-level page tables and nothing sets the
five-level bit; a future port must move the pivot, and the comment says so.

SFMASK grows one bit while we are here. SYSCALL, unlike an interrupt gate,
does not clear the nested-task flag, so the kernel had been running every
system call with whatever ring 3 last chose — harmless while the only exit
was SYSRETQ, and a question worth not having now that one exit is IRETQ.
The kernel is never nested; ring 3 still gets its own flag back.

Suite 113/113. The new case asserts the refusal counter rather than the
dying process: the emulator we test on kills it either way, so only the
counter distinguishes a guard that ran from one that did not.
This commit is contained in:
Daniel Samson 2026-08-01 11:22:07 +01:00
parent 36a7cc5fe9
commit cb30faf15f
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
8 changed files with 257 additions and 13 deletions

View File

@ -1,6 +1,7 @@
# SMEP and SMAP — supervisor-mode hardening # SMEP and SMAP — supervisor-mode hardening
*Design, 2026-07-31. H2 (SMEP) has landed; HS and H3 are the remaining work. Companion to *Design, 2026-07-31. H2 (SMEP) and HS (the SYSRET guard) have landed; H3 is the
remaining work. Companion to
[protocol-namespace.md](protocol-namespace.md) on the security track — this is [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 | **HS** — SYSRET canonical-RIP guard | | Working on | **H3** — SMAP (the last phase) |
| Branch carrying it | `feat/security-group-4` (pushed to origin) | | Branch carrying it | `feat/security-group-4` (pushed to origin) |
| On `main` | everything through P4c — groups 1, 2 and 3 merged | | On `main` | everything through P4c — groups 1, 2 and 3 merged |
| Awaiting merge | H2 — lands with the group 4 merge | | Awaiting merge | H2, HS — land with the group 4 merge |
| Suite | 112 cases, all passing | | Suite | 113 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
@ -81,7 +81,20 @@ group boundary.
refusal paired with a control; suite 111/111) refusal paired with a control; suite 111/111)
- [x] **merge** group 3 → main, push - [x] **merge** group 3 → main, push
- [x] **H2** — SMEP on every core (shared CPUID helper; CR4 bit 20 set in the per-CPU bring-up both the BSP and every AP run, asserted per core by the smp case; ring-0-executes-user-pages audit clean incl. the pre-paging window on the loader's tables; fail-open with a posture line; `-cpu max` added to the harness since QEMU's default model has neither bit; new `fault-smep` case; suite 112/112) - [x] **H2** — SMEP on every core (shared CPUID helper; CR4 bit 20 set in the per-CPU bring-up both the BSP and every AP run, asserted per core by the smp case; ring-0-executes-user-pages audit clean incl. the pre-paging window on the loader's tables; fail-open with a posture line; `-cpu max` added to the harness since QEMU's default model has neither bit; new `fault-smep` case; suite 112/112)
- [ ] **HS** — SYSRET canonical-RIP guard - [x] **HS** — SYSRET canonical-RIP guard (the syscall exit sign-extends the
return RIP from bit 47 and returns through `iretq` when that changes it —
four register ALU ops and a never-taken branch on the hot path, no load; the
fallback un-pops the rip slot so `iretq` consumes the frame entry already
built, reloads R11 from the rflags slot, and keeps the `swapgs` in the same
place relative to the ring change. 4-level is not an assumption but a fact:
nothing sets CR4.LA57, and the comment says what a 5-level port must change.
Ring 3 **can** reach the hazard — `syscall` as the last two bytes of the last
canonical page returns to `user_half_end` — so the new `sysret-canonical`
case is a real ring-3 probe doing exactly that, and dies of a ring-3 #GP at
`0x0000800000000000` while the kernel runs on. Its teeth are the refusal
counter, not the outcome: measured with the guard's branch removed, TCG does
not model Intel's ring-0 #GP and every outcome check still passed. Suite
113/113)
- [ ] **H3** — SMAP + boot-patched `clac`; `-cpu max` in the harness; negative tests - [ ] **H3** — SMAP + boot-patched `clac`; `-cpu max` in the harness; negative tests
- [ ] **merge** group 4 → main, push - [ ] **merge** group 4 → main, push
@ -518,6 +531,8 @@ put the text in `expect`, per `qemu_test.py:189`). Suite 112.
**Test:** kernel unit case driving a thread whose return RIP is forged **Test:** kernel unit case driving a thread whose return RIP is forged
non-canonical via the syscall path if constructible cheaply; otherwise the non-canonical via the syscall path if constructible cheaply; otherwise the
review-level proof plus the existing fault cases regression. Suite 112. review-level proof plus the existing fault cases regression. Suite 112.
*(Landed: it was constructible, and from ring 3 rather than by forgery — the
`sysret-canonical` case, suite 113.)*
## H3 — SMAP ## H3 — SMAP
@ -532,7 +547,7 @@ review-level proof plus the existing fault cases regression. Suite 112.
**Test:** new QEMU case `fault-smap`: ring-0 deliberate read of a mapped **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.
Suite 113. Suite 114 (HS added one).
--- ---

View File

@ -315,6 +315,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

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

View File

@ -80,7 +80,16 @@ 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) --------------------------------------- // --- supervisor-mode hardening (CR4) ---------------------------------------

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

@ -142,6 +142,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
faultNull(); faultNull();
} else if (eql(case, "fault-smep")) { } else if (eql(case, "fault-smep")) {
faultSmep(); faultSmep();
} 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")) {
@ -1667,6 +1669,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

View File

@ -355,6 +355,21 @@ CASES = [
r"(?=.*ring 0 \(kernel\))(?=.*error code : 0x11)" r"(?=.*ring 0 \(kernel\))(?=.*error code : 0x11)"
r"(?=.*fault addr : 0x0000700000000000)", r"(?=.*fault addr : 0x0000700000000000)",
"fail": r"SMEP not enforced|SMEP not enabled|no frame for the probe page"}, "fail": r"SMEP not enforced|SMEP not enabled|no frame for the probe page"},
# SYSRET canonical-RIP guard: a ring-3 probe whose `syscall` sits on the last two
# bytes of executable address space, so the return RIP the kernel must hand back
# is 0x0000800000000000 — non-canonical, and a ring-0 #GP if it reached SYSRETQ.
# The kernel must return through IRETQ instead, which faults the *process* in
# ring 3: the kill line names a general protection fault at exactly that address,
# and the probe's own result line reports the guard counter. Any "ring 0 (kernel)"
# report, panic or reset here is the hazard itself, so they are failures.
# TCG does not model the ring-0 #GP (measured: with the guard removed these two
# lines are unchanged), so the case's teeth are the counter assertion inside
# DANOS-TEST-RESULT, not the kill line.
{"name": "sysret-canonical",
"expect": r"(?s)(?=.*killed by general protection fault \(vector 13\))"
r"(?=.*IP\s+: 0x0000800000000000)"
r"(?=.*DANOS-TEST-RESULT: PASS)",
"fail": r"DANOS-TEST-RESULT: FAIL|CPU EXCEPTION|KERNEL PANIC"},
# Memory grants: the mmap/munmap path hands out user pages into a process's # 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",