Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions issues/a-threads-segment-selectors-are-its-cpus.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ opened: 2026-11-11

Ring 3 loads DS, ES, FS and GS with any selector CPL 3 may load (SDM Vol. 2B,
MOV): null or the user data or code descriptor, at any RPL. The x86-64 switch
(`kernel/src/arch/x86_64/hw.rs`) carries a thread's FS base and nothing of
those four selectors, so a thread switched in reads, with `mov ax, gs`, the
selector the thread before it on that CPU loaded, and loses its own. The
bases are not involved: every descriptor Ring 3 may load has base 0, the FS
base moves with the thread, and the kernel's GS base is swapped in at every
entry (`kernel/src/arch/x86_64/entry.rs`). Found by reading while building
that entry.
(`kernel/src/arch/x86_64/hw.rs`) carries a thread's FS and GS bases and
nothing of those four selectors, so a thread switched in reads, with `mov ax,
gs`, the selector the thread before it on that CPU loaded, and loses its own.
The bases are not involved: they move with the thread, and the kernel's GS
base is swapped in at every entry (`kernel/src/arch/x86_64/entry.rs`). Found
by reading while building that entry.

**Exit**: a thread reads back the DS, ES, FS and GS selectors it loaded after
its CPU has run a thread that loaded others, by the switch saving and
Expand Down
16 changes: 16 additions & 0 deletions issues/an-ap-takes-ist-vectors-before-its-tss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
status: open
kind: finding
opened: 2026-11-11
---

# An AP takes IST vectors before its TSS

An AP's trampoline (`kernel/src/arch/x86_64/smp.rs`) loads the kernel's IDT
and enters Rust under the BSP's GDT. The AP loads its own GDT and TSS
(`ltr`) only later, in `percpu::init_ap`, just after its declared `CR4`.
Until `ltr`, TR is still what INIT left, so an NMI, `#DB`, `#DF` or `#MC`
in that span takes its IST stack pointer from no TSS of the kernel's. Found
by reading while making those entries independent of GS.

**Exit**: an AP holds its own GDT and TSS before it loads the IDT.
20 changes: 15 additions & 5 deletions issues/fsgsbase-is-taken.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ opened: 2026-09-29

# FSGSBASE is taken

`CR4.FSGSBASE` is forbidden (`CR4_FORBIDDEN`, `kernel/src/arch/x86_64/control_regs.rs`):
the switch moves no thread's GS base, since every GS load Ring 3 can make
leaves it zero, so a `WRGSBASE` would reach the next thread on the CPU.
Intel names what it gives, user programs' FS and GS bases without a system
call and a more efficient kernel context switch, and has it used "never in
production environments unless accompanied by a complete OS implementation":
Expand All @@ -30,9 +27,14 @@ guidance asks; NMI, `#DB`, `#MC` and `#DF` install the kernel's base from
found. The
`nmi-user-gs` actuator enters vector 2 at CPL 0 under a decoy kernel-half
base, which a sign test of `IA32_GS_BASE` would take for the kernel's.
`CR4.FSGSBASE` is required of every CPU (`kernel/src/arch/x86_64/control_regs.rs`),
and the switch carries each thread's FS and GS bases
(`kernel/src/arch/x86_64/hw.rs`); `fsgs_bases_per_thread` reads them back
across yields on one CPU.

**Left**: `CR4.FSGSBASE` on every CPU, and the switch saving and restoring
each thread's GS base beside its FS base.
**Left**: the exit's runs: the vector-2 arm and the readback on each proving
machine and under TCG, and the T14's pipe round trips with and without
FSGSBASE.

**Owed on metal** (#1262; the benches were down when it was reviewed). On
the T14 and the AMD bench, main must show:
Expand All @@ -51,6 +53,14 @@ the T14 and the AMD bench, main must show:
- `nmi_finds_the_kernels_gs`, on the `shared-debug` boot: `nmi-user-gs: PASS`.
- `syscall_cost`, on the `testcases` boot: within its bound.

**Owed on metal** (#1264, the same way). On the T14 and the AMD bench, main
must show:
- `fsgs_bases_per_thread`, on the `testcases` boot: both threads read back
their own bases, and both crossing counts are above 0.
- The rows above, through the entries' `RDGSBASE` and `WRGSBASE`.
- An A/B of main before and after #1264 on the T14's `benchcase` boot:
`pingpong.pipe.*`, reported. It decides nothing.

**Unobserved**: the `#DB` entry's resume into a gate it interrupted, where
a single-step held over a `MOV SS` lands on the first instruction of
`SYSCALL`'s entry or of the `#BP` gate (`tf-mov-ss-syscall`,
Expand Down
23 changes: 19 additions & 4 deletions issues/ring-3-replaces-the-kernels-gs-base-with-a-segment-load.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ severity: [security, kernel-from-userland]

# Ring 3 replaces the kernel's GS base with a segment load

Fixed in code by #1262; what follows is the defect as it stood, and below it
what the exit still owes.

The kernel runs no `swapgs`: GS holds this CPU's per-CPU block from boot on,
and the `SYSCALL` entry's first instructions address it through `gs:` while
still on the user stack (`kernel/src/arch/x86_64/syscall.rs`). `CR4.FSGSBASE`
Expand All @@ -28,10 +31,22 @@ first. Every handler after it addresses `gs:` with the same zero base. Found by
reading, while deciding whether the switch may keep a thread's FS base instead
of reading it back (it may not, for the same reason); not reproduced.

**Built** (#1262): every entry from Ring 3 swaps in the kernel's GS base, and
the IST entries install it from `GDTR` whatever GS holds
(`kernel/src/arch/x86_64/entry.rs`). `gs_segment_load` is the exit's test. Its
child loads GS with each selector CPL 3 may load (null, user data and user
code, at RPL 0 to 3) and enters the kernel by a syscall, a page fault and a
park. The call answers, and GS's base reads back as its own. It was green
under QEMU. Its negative control, the fix reverted, stalled the machine at the
first entry after a GS load.

**Owed on metal** (the benches were down when #1262 landed): on the T14 and
the AMD bench, main's `testcases` boot must show `gs_segment_load` with all 12
selectors reported. `issues/fsgsbase-is-taken.md` lists it with the rest #1262
owes. The record closes when it has.

**Exit**: a guest test, run on the T14 and the AMD bench, whose child loads GS
with each selector Ring 3 can name and then makes a system call, and the kernel
survives and ends the child; its negative control is the kernel without the
fix. The fix is an entry that does not trust the GS base it finds, the owner's
to choose; `issues/fsgsbase-is-taken.md` weighs the `swapgs` one. A GDT without
a loadable Ring 3 data descriptor is not one: `IRETQ` to Ring 3 needs SS to name
a writable DPL-3 data segment.
fix. A GDT without a loadable Ring 3 data descriptor is no fix: `IRETQ` to
Ring 3 needs SS to name a writable DPL-3 data segment.
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ Metal: test kernels armed on the T14. Host: `kernel::sched` and `kernel-sim`, `k
`toyos-wallclock`. Exit: as stage A; an unrepresentable name is a type the kernel builds against
and a compile-fail case.

- metal `fpu_isolation`, `gsbase_locked`, first: x86-64 FPU isolation runs nowhere until this
lands. One process's FPU state and GS base never reach another; brings back the
`fpu-save-nothing` and `user-writable-gsbase` features, their negative controls.
- metal `fpu_isolation`, first: x86-64 FPU isolation runs nowhere until this lands. One
process's FPU state never reaches another; brings back the `fpu-save-nothing` feature, its
negative control.
- metal `control_regs_negative`: an AP left without the declared control registers is caught,
under `no-ap-control-regs`.
- metal `va_exhaustion`, `heap_ceiling_bounds`: an exhausted address space and heap refuse by name.
Expand Down
9 changes: 9 additions & 0 deletions kernel/src/arch/aarch64/hw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ fn check_switch_frame(ctx: &KernelCtx) -> u64 {
sp
}

/// Nothing: an AArch64 thread's one base register is its thread pointer,
/// `TPIDR_EL0`.
#[derive(Clone, Copy)]
pub struct UserBases;

impl UserBases {
pub const INITIAL: Self = Self;
}

impl Hw for KernelHw {
type Payload = KernelPayload;

Expand Down
24 changes: 9 additions & 15 deletions kernel/src/arch/x86_64/control_regs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,11 @@ pub const CR0: u64 = cr0::PE | cr0::MP | cr0::ET | cr0::NE | cr0::WP | cr0::PG;
/// from or touching a user page (SDM Vol. 3A §4.6), which every CPU of this
/// kernel's range (2020 on) offers, so one without them is refused rather than
/// run with the user half open to a kernel bug; and every Ring 0 entry's
/// `clac` (`arch::entry`) is `#UD` without `SMAP`. `FSGSBASE` is
/// [`CR4_FORBIDDEN`], not here.
/// `clac` (`arch::entry`) is `#UD` without `SMAP`. `FSGSBASE`, in the same
/// range, gives Ring 3 its own FS and GS bases and the kernel both without an
/// MSR, under Intel's condition (Guidance for Enabling FSGSBASE, ID 767615):
/// the switch carries both bases per thread (`arch::hw`), and no entry trusts
/// the GS base it finds (`arch::entry`).
const CR4_REQUIRED: u64 = cr4::DE
| cr4::PAE
| cr4::MCE
Expand All @@ -176,10 +179,11 @@ const CR4_REQUIRED: u64 = cr4::DE
| cr4::OSXMMEXCPT
| cr4::OSXSAVE
| cr4::SMEP
| cr4::SMAP;
| cr4::SMAP
| cr4::FSGSBASE;

/// [`CR4_REQUIRED`]'s bits by name, for a refusal to say which a CPU lacks.
const CR4_REQUIRED_NAMES: [(u64, &str); 9] = [
const CR4_REQUIRED_NAMES: [(u64, &str); 10] = [
(cr4::DE, "DE"),
(cr4::PAE, "PAE"),
(cr4::MCE, "MCE"),
Expand All @@ -189,6 +193,7 @@ const CR4_REQUIRED_NAMES: [(u64, &str); 9] = [
(cr4::OSXSAVE, "OSXSAVE"),
(cr4::SMEP, "SMEP"),
(cr4::SMAP, "SMAP"),
(cr4::FSGSBASE, "FSGSBASE"),
];

const _: () = {
Expand Down Expand Up @@ -217,17 +222,6 @@ impl core::fmt::Display for Cr4Names {
/// `CR4` bits this kernel takes when the CPU offers them (checked against CPUID first: an undefined bit is `#GP`).
const CR4_OPTIONAL: u64 = cr4::PCIDE | cr4::UMIP;

/// The `CR4` bit no shipping CPU may hold: `FSGSBASE` gives Ring 3 `WRGSBASE`,
/// and the switch moves no thread's GS base, which every Ring 3 GS load
/// leaves zero (`percpu::clear_user_gs_base`) — one thread's would reach the
/// next. The kernel's FS base uses `IA32_FS_BASE` (`cpu::write_fs_base`)
/// instead.
const CR4_FORBIDDEN: u64 = cr4::FSGSBASE;

// Forbidden in the declaration, and never optional.
const _: () = assert!(CR4_REQUIRED & CR4_FORBIDDEN == 0);
const _: () = assert!(CR4_OPTIONAL & CR4_FORBIDDEN == 0);

/// `IA32_EFER` on every CPU: `SCE`, `LME`, `LMA`, `NXE`. `SCE` is declared only
/// here, never by `arch::syscall::init`, so one register keeps one owner.
pub const EFER: u64 = efer::SCE | efer::LME | efer::LMA | efer::NXE;
Expand Down
29 changes: 23 additions & 6 deletions kernel/src/arch/x86_64/cpu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,20 +436,37 @@ pub fn disable_interrupts() {
}
}

/// The kernel's own FS base, not `rd/wrfsbase` (`control_regs::CR4_FORBIDDEN`).
const IA32_FS_BASE: u32 = 0xC000_0100;

/// This CPU's FS base, which Ring 3 may have written itself (`CR4.FSGSBASE`).
#[inline]
pub fn read_fs_base() -> u64 {
rdmsr(IA32_FS_BASE)
let base: u64;
// SAFETY: reads FS's base alone; `CR4.FSGSBASE` is every CPU's declaration.
unsafe { asm!("rdfsbase {}", out(reg) base, options(nomem, nostack, preserves_flags)) };
base
}

/// # Safety
/// `val` becomes this CPU's FS base; #GP if non-canonical, and the caller owns which thread's thread-locals it addresses.
#[inline]
pub unsafe fn write_fs_base(val: u64) {
// SAFETY: `IA32_FS_BASE` takes any canonical value; the caller owns both.
unsafe { wrmsr(IA32_FS_BASE, val) };
// SAFETY: the caller's; `CR4.FSGSBASE` is every CPU's declaration.
unsafe { asm!("wrfsbase {}", in(reg) val, options(nomem, nostack, preserves_flags)) };
}

/// The GS base Ring 3 returns to: `IA32_KERNEL_GS_BASE` while the kernel runs
/// (`arch::entry`).
#[inline]
pub fn read_user_gs_base() -> u64 {
rdmsr(super::percpu::MSR_KERNEL_GS_BASE)
}

/// # Safety
/// `val` is canonical, and the GS base of the thread this CPU next returns to
/// Ring 3.
#[inline]
pub unsafe fn write_user_gs_base(val: u64) {
// SAFETY: the caller's.
unsafe { wrmsr(super::percpu::MSR_KERNEL_GS_BASE, val) };
}

pub fn halt() -> ! {
Expand Down
17 changes: 7 additions & 10 deletions kernel/src/arch/x86_64/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,23 +96,20 @@ macro_rules! syscall_naked_asm {
};
}

/// Puts this CPU's `PerCpu` in `IA32_GS_BASE` whatever GS held, for a gate
/// that can arrive in the kernel's CPL-0 stretches with a user's base live.
/// Puts this CPU's `PerCpu` in GS's base whatever it held, for a gate that
/// can arrive in the kernel's CPL-0 stretches with a user's base live.
/// `GDTR`'s base is the block's own `gdt` (`percpu::load_gdt`), so it names
/// the CPU without reading GS. Clobbers `rax`, `rcx` and `rdx`; the operands
/// are `gs_base_msr` and `gdt_in_percpu`. No branch precedes the `wrmsr`, so
/// no load through GS after it is speculated with another base.
/// the CPU without reading GS. Clobbers `rax`; the operand is
/// `gdt_in_percpu`. No branch precedes the `wrgsbase`, so no load through GS
/// after it is speculated with another base.
macro_rules! install_kernel_gs {
() => {
"mov ecx, {gs_base_msr}\n\
sub rsp, 16\n\
"sub rsp, 16\n\
sgdt [rsp + 6]\n\
mov rax, [rsp + 8]\n\
add rsp, 16\n\
sub rax, {gdt_in_percpu}\n\
mov rdx, rax\n\
shr rdx, 32\n\
wrmsr"
wrgsbase rax"
};
}

Expand Down
32 changes: 26 additions & 6 deletions kernel/src/arch/x86_64/hw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,18 @@ pub fn declare_vendor(vendor: kernel::cpu::Vendor) {
SYSRET_KEEPS_SS.store(vendor != kernel::cpu::Vendor::Intel, Relaxed);
}

/// A thread's GS base, which Ring 3 writes with `WRGSBASE` and the kernel
/// keeps in `IA32_KERNEL_GS_BASE` while it runs (`arch::entry`).
#[derive(Clone, Copy)]
pub struct UserBases {
gs: u64,
}

impl UserBases {
/// A thread's before it first runs.
pub const INITIAL: Self = Self { gs: 0 };
}

fn read_ss() -> u16 {
let ss: u16;
// SAFETY: reads a segment selector into a scratch register; touches nothing else.
Expand Down Expand Up @@ -309,18 +321,22 @@ impl Hw for KernelHw {
// moves `IF`; the resumed context's own guard puts back what it had.
#[cfg(feature = "mask-windows")]
let _masked = crate::arch::IrqGuard::close();
// SAFETY: `save`/`restore` are live Box-backed contexts from `SchedPass::finish`, freed only by a later pass; `incoming.thread_pointer` is this kernel's own canonical value for the thread being installed; `area` is the outgoing task's own, saved as it leaves this CPU and restored when it returns into this frame.
// SAFETY: `save`/`restore` are live Box-backed contexts from `SchedPass::finish`, freed only by a later pass; `incoming.thread_pointer` and `incoming.user_bases` are canonical values the CPU itself handed the switch out of that thread, or zero; `area` is the outgoing task's own, saved as it leaves this CPU and restored when it returns into this frame.
unsafe {
// A thread's user state area lies above its stack's top (`fpu`);
// the idle context has none, and switches with neither a save nor a
// restore. The thread resumes in this frame, so the restore below
// reads the area its own save wrote. Its FS base is read back, not
// kept: Ring 3 moves it with a segment load (SDM Vol. 2B, MOV).
// reads the area its own save wrote. Its bases are read back, not
// kept: Ring 3 moves them with a segment load (SDM Vol. 2B, MOV),
// `WRFSBASE` and `WRGSBASE`.
let area = match (*save).runs {
Runs::User(_) => {
let base = cpu::read_fs_base();
(*save).thread_pointer = base;
percpu::set_fs_base(base);
let fs = cpu::read_fs_base();
(*save).thread_pointer = fs;
percpu::set_fs_base(fs);
let gs = cpu::read_user_gs_base();
(*save).user_bases.gs = gs;
percpu::set_user_gs_base(gs);
Some((*save).kernel_stack_top)
}
Runs::Idle => None,
Expand Down Expand Up @@ -353,6 +369,10 @@ impl Hw for KernelHw {
cpu::write_fs_base(incoming.thread_pointer);
percpu::set_fs_base(incoming.thread_pointer);
}
if percpu::user_gs_base() != incoming.user_bases.gs {
cpu::write_user_gs_base(incoming.user_bases.gs);
percpu::set_user_gs_base(incoming.user_bases.gs);
}
}
// idle's stack top is per-CPU, unknowable at boot-time init, so
// it is read here instead. It keeps whatever root is loaded:
Expand Down
21 changes: 7 additions & 14 deletions kernel/src/arch/x86_64/idt/debug.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
use core::arch::naked_asm;

use crate::arch::entry::install_kernel_gs;
use crate::arch::percpu::{MSR_GS_BASE, OFF_GDT, OFF_KERNEL_RSP, USER_CS, USER_DS};
use crate::arch::percpu::{OFF_GDT, OFF_KERNEL_RSP, USER_CS, USER_DS};

/// Eleven pushes place the CPU frame's `rip` at 88.
const RIP_OFFSET: usize = 88;
Expand All @@ -34,14 +34,11 @@ pub(super) extern "sysv64" fn debug_entry() {
"test byte ptr [rsp + 8], 3",
"jnz 2f",
"push rax",
"push rcx",
"push rdx",
"mov ecx, {gs_base_msr}",
"rdmsr",
"shl rdx, 32",
"or rax, rdx",
"rdgsbase rax",
"push rax",
install_kernel_gs!(),
"push rcx",
"push rdx",
"push rsi",
"push rdi",
"push r8",
Expand All @@ -61,14 +58,11 @@ pub(super) extern "sysv64" fn debug_entry() {
"pop r8",
"pop rdi",
"pop rsi",
// The base this `#DB` found, back: nothing after it reads GS.
"pop rax",
"mov rdx, rax",
"shr rdx, 32",
"mov ecx, {gs_base_msr}",
"wrmsr",
"pop rdx",
"pop rcx",
// The base this `#DB` found, back: nothing after it reads GS.
"pop rax",
"wrgsbase rax",
"pop rax",
// `deferred` makes the frame a Ring 3 one where its thread is to end.
"test byte ptr [rsp + 8], 3",
Expand Down Expand Up @@ -98,7 +92,6 @@ pub(super) extern "sysv64" fn debug_entry() {
"push 0",
"push {vector}",
"jmp {common}",
gs_base_msr = const MSR_GS_BASE,
gdt_in_percpu = const OFF_GDT,
kernel_rsp = const OFF_KERNEL_RSP,
rip_offset = const RIP_OFFSET,
Expand Down
1 change: 0 additions & 1 deletion kernel/src/arch/x86_64/idt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,6 @@ extern "sysv64" fn fatal_entry() {
"call {dispatch}",
"ud2",
dispatch = sym trap_dispatch,
gs_base_msr = const percpu::MSR_GS_BASE,
gdt_in_percpu = const percpu::OFF_GDT,
);
}
Expand Down
Loading
Loading