150 lines
8.2 KiB
Markdown
150 lines
8.2 KiB
Markdown
# SMEP and SMAP — supervisor-mode hardening
|
|
|
|
*Design, 2026-07-31. H1 (the copy layer), H2 (SMEP), HS (the SYSRET guard) and
|
|
H3 (SMAP) have all landed; the track is complete. Companion to
|
|
[protocol-namespace.md](protocol-namespace.md) on the security track — this is
|
|
the hardware half; that is the namespace half.*
|
|
|
|
Two CR4 bits that make the CPU refuse the two things a kernel should never do
|
|
with user memory:
|
|
|
|
- **SMEP** (Supervisor Mode Execution Prevention, CR4 bit 20): instruction
|
|
fetch in ring 0 from a page whose U/S bit says *user* → #PF. Kills the
|
|
classic ret2usr exploit shape — a kernel bug that redirects control flow
|
|
can no longer land in attacker-prepared user code.
|
|
- **SMAP** (Supervisor Mode Access Prevention, CR4 bit 21): data read/write
|
|
in ring 0 to a user page → #PF, unless `EFLAGS.AC` is set. `stac`/`clac`
|
|
open and close deliberate access windows; danos's design needs no windows
|
|
at all (below).
|
|
|
|
Detection is CPUID leaf 7, subleaf 0, EBX bit 7 (SMEP) and bit 20 (SMAP).
|
|
Both bits are per-core state: the BSP and every AP must set them.
|
|
|
|
## Why, in danos terms
|
|
|
|
Every syscall argument is an attacker-controlled integer, and several take
|
|
pointers. A kernel bug that dereferences a crafted pointer reads, writes, or
|
|
executes memory of the attacker's choosing — the exact bug class the
|
|
isolation tracks exist to prevent. SMEP/SMAP turn that class from "silent
|
|
compromise" into "immediate, attributable #PF with a kernel RIP in the log."
|
|
|
|
The second benefit matters as much as the first: **SMAP is a permanent
|
|
tripwire.** Once it is on, any *future* syscall that touches user memory
|
|
directly — instead of going through the checked copy layer — faults the
|
|
first time the QEMU suite runs it. The discipline stops depending on review.
|
|
|
|
## Where danos already stands
|
|
|
|
The design is closer than it looks, because the IPC layer was built right:
|
|
|
|
- **The copy layer is already SMAP-proof.** `copyAcross` and `copyFromUser`
|
|
(`system/kernel/ipc-synchronous.zig:305,333`) never dereference a user
|
|
virtual address: they walk the page tables and move bytes through the
|
|
physmap — kernel mappings throughout. SMAP cannot object.
|
|
- **Syscall entry already clears AC.** `SFMASK = 0x4_0700` clears IF, TF,
|
|
DF, **AC** on every `syscall`
|
|
(`system/kernel/architecture/x86_64/per-cpu.zig:76`). The syscall path is
|
|
SMAP-clean from day one.
|
|
- **The interrupt path is not.** Hardware does *not* clear AC on IDT
|
|
delivery, and ring 3 can set AC with `popfq` — so a hostile process could
|
|
take an interrupt with AC=1 and have the handler run with SMAP suspended.
|
|
`isr_common` (`system/kernel/architecture/x86_64/isr.s:366`) needs a
|
|
`clac` beside its `swapgs`.
|
|
- **CR4 today:** the BSP inherits firmware CR4 (no kernel write anywhere);
|
|
APs set PAE/OSFXSR/OSXMMEXCPT in `trampoline.s:62-68`. Neither path sets
|
|
SMEP/SMAP yet, and both must.
|
|
- **The stragglers.** Nine syscalls still dereference user pointers raw
|
|
after a bounds check — every one is a SMAP #PF waiting to happen, and
|
|
every one is *already* a latent kernel fault today (an unmapped-but-in-
|
|
range user page oopses the kernel instead of failing the call). The
|
|
verified sweep of `system/kernel/process.zig` (2026-07-31; a
|
|
whole-kernel `@ptrFromInt` audit found no user-address dereference
|
|
outside this file):
|
|
|
|
| Syscall | Raw access | Direction |
|
|
|---|---|---|
|
|
| `system_spawn` | name + argument blob (`:972`, `:980`) | read |
|
|
| `fs_resolve` | path in (`:1780`), result out (`:1797`) | read + write |
|
|
| `fs_mount` | prefix + rewrite strings (`:1864`, `:1865`) | read |
|
|
| `fs_unmount` | prefix string (`:1883`) | read |
|
|
| `fs_node` | read buffer out (`:1820`) | write |
|
|
| `debug_write` | message bytes (`:1700`; read twice — memcpy `:1710` and `log.append` `:1717`) | read |
|
|
| `klog_read` | log bytes out (`:1741`) | write |
|
|
| `klog_status` | status struct out (`:1758`) | write |
|
|
| `process_enumerate` | descriptor array out (`:1132`) | write |
|
|
| `device_enumerate` | descriptor array out (`:388`) | write |
|
|
|
|
For the write-direction rows the `@ptrFromInt` is in process.zig but the
|
|
stores happen in callees (`scheduler.enumerate`
|
|
`system/kernel/scheduler.zig:1209`, `devices_broker.enumerate`
|
|
`devices-broker.zig:136`, `log.readAt` `log.zig:209`, the vfs node calls
|
|
`vfs.zig:257/269/289`) — converting them means bounce buffers plus
|
|
`copyToUser` around those calls, not just editing the process.zig lines.
|
|
(Some paths already do it right — the futex word and the device-register
|
|
descriptor go through `copyFromUser` (`:1087`, `:924`). The write
|
|
direction has no public helper yet, but the mechanism exists:
|
|
`copyAcross` with a kernel source is exactly how IPC replies reach user
|
|
buffers, so `copyToUser` is a mechanical mirror.)
|
|
|
|
- **One known gap inside the copy layer itself:** the walk checks presence,
|
|
not the leaf U/S and writable bits (`ipc-synchronous.zig:20-22` flags
|
|
this). Today that is nearly moot — the user half contains only mappings
|
|
the kernel itself created for that process — but it must close before
|
|
shared or copy-on-write mappings exist, and closing it is part of making
|
|
the copy layer the single trusted door.
|
|
|
|
## The plan
|
|
|
|
**H1 — copy discipline (the real work).** A `user-memory` kernel module:
|
|
`copyFromUser` / `copyToUser` (the missing write direction) via the physmap
|
|
walk, with U/S and writable leaf checks closing the in-tree TODO. Convert
|
|
the nine stragglers. This fixes the latent unmapped-page kernel fault on
|
|
its own — it is worth doing even if SMEP/SMAP never shipped. QEMU suite
|
|
green; no behavior change visible to correct programs.
|
|
|
|
**H2 — SMEP.** A leaf-7 feature probe (the kernel has per-leaf `cpuid`
|
|
helpers in `apic.zig` to generalize); set CR4.SMEP during per-CPU bring-up
|
|
on BSP and APs — prefer the Zig-side per-CPU init over the trampoline
|
|
assembly, so one code path covers every core and the trampoline stays
|
|
minimal. Audit first that ring 0 never executes user-mapped pages: kernel
|
|
text lives in the kernel half, `jump_to_user` is kernel code, and the AP
|
|
trampoline page is kernel-mapped — expected clean, verify before flipping.
|
|
|
|
**H3 — SMAP.** Add `clac` at `isr_common` entry. `clac` is #UD on CPUs
|
|
without SMAP, so the instruction is a 3-byte NOP in the image, patched to
|
|
`clac` at boot when CPUID advertises SMAP (one-time patch beats a
|
|
conditional branch in the hottest path in the kernel). Then set CR4.SMAP in
|
|
the same per-CPU init. From this point the whole QEMU suite doubles as the
|
|
enforcement test: any missed raw dereference is a vector-14 with a kernel
|
|
RIP and a user CR2 — loud and attributable.
|
|
|
|
**H4 — keep it honest.** A line in the coding standards: kernel code
|
|
touches user memory only through `user-memory`; there is no `stac` anywhere
|
|
in the tree, and a PR that adds one is wrong by definition. SMAP enforces
|
|
the rule mechanically at test time.
|
|
|
|
Feature-gating follows the timekeeping rule (work on any VM, real Intel,
|
|
real AMD): both bits are probed, absence is logged and tolerated — like the
|
|
IOMMU's fail-open, the machine still boots, just unhardened. QEMU: TCG
|
|
implements both; KVM inherits the host (Intel Ivy Bridge+ for SMEP,
|
|
Broadwell+ for SMAP; AMD Zen+ for both). The test images should run with
|
|
`-cpu max` so the suite always exercises the enabled paths.
|
|
|
|
## Adjacent, deliberately separate
|
|
|
|
- **SYSRET canonical-RIP hardening** (`isr.s:192-194` documents it): a
|
|
non-canonical return RIP makes `sysretq` #GP *in ring 0* on Intel. Same
|
|
hardening bucket, independent fix (validate RCX before `sysretq`, fall
|
|
back to `iretq`), should ride the same branch as H2/H3 but is not
|
|
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
|
|
architectural accesses, not speculative ones. danos runs one kernel
|
|
mapping in every address space and accepts that on affected hardware;
|
|
revisit only if the threat model ever includes hostile native code on
|
|
shared machines.
|