SMAP makes the rule the copy layer has followed since it was written into a rule the hardware keeps. A ring-0 read or write of a user page now faults, so any code that reaches for a user pointer directly fails the first time it runs rather than the first time someone attacks it — and the suite becomes the enforcement test, because every case exercises the kernel with the bit on. Nothing had to be fixed to turn it on, which is the retrospective proof that the nine stragglers converted earlier were all of them. The interrupt entry needed one instruction first. Hardware does not clear the alignment-check flag on its way into a handler, and ring 3 sets that flag freely, so a process could have taken an interrupt with SMAP suspended for the duration. The system call path was already covered — its flag mask clears it — but the interrupt path needed a `clac`, which cannot simply be assembled in: it is an invalid instruction on a processor without SMAP, and danos boots on those too. So the entry ships as a three-byte NOP and is patched at boot, through the physmap, because the kernel maps its own text read-only. The ordering that makes that safe is enforced rather than described: the patch sets a flag, and no core will set the SMAP bit until it is true. A translation that fails, or bytes that read back wrong through the address they will actually be fetched from, leave the machine unhardened and saying so — which is the same posture the IOMMU takes, and better than enforcing over an entry path that cannot comply. The patch runs before interrupts are enabled and before any second core exists; a comment says so, because the three bytes pass through an encoding that must never be executed and a future change that moves this later has to deal with that first. Suite 114/114, with a case that reads a user page from ring 0 and requires the fault, and the multi-core case asserting every core that ran work had the bit — the same shape SMEP got, for the same reason: CR4 is per-core, and a hardening is only as wide as its narrowest core.
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.
|