Repository navigation
kernel entry: no kernel register reaches Ring 3, AC and PAN bind at every entry - #1154
Conversation
…very entry A new x86-64 thread entered Ring 3 holding whatever the kernel left in rax-rdx, rsi, rbp, r8-r11 and r15 (and rdi, for a process): its first instruction could read kernel stack and heap addresses. process_start and thread_start now share one tail that loads the iretq frame and zeroes every general register but rsp and the argument in rdi, as AArch64's trampoline already did. The redundant r12-r14 pushes around the sysv64 calls go with it; thread_start's three of them had misaligned its calls. An interrupt or trap gate clears neither DF nor AC (SDM Vol. 3A §6.12.1.3), and Ring 3 sets AC with a popf, under which SMAP lets Ring 0 reach every user page. Every interrupt and trap gate now runs clac after its cld: ring3_naked_asm, and the NMI, spurious and unclaimed gates that open-code their prologue. SYSCALL's entry keeps cld alone under its own macro, since IA32_FMASK clears AC and the shipping-image gate requires the save and the stack switch to follow cld directly. SMEP and SMAP move from CR4's optional bits to its required ones: every CPU of the kernel's range has them, clac is #UD without SMAP, and a CPU without either is refused by name instead of running with the user half open to a kernel bug. The harness's CR4 verdict holds SMAP to set. AArch64 ran EL1 with SCTLR_EL1.SPAN set and never set PSTATE.PAN, so EL1 could load and store through any EL0 mapping. SPAN is now clear, so every exception to EL1 sets PAN; the entry sets it for the boot by ERET into EL1 with PAN in SPSR (from EL2 as before, and now from EL1 too), the one way that needs no FEAT_PAN instruction a CPU without it would take as undefined before the check can name it. control_regs::check refuses a CPU without FEAT_PAN by name and reads PSTATE.PAN back on every CPU. Tests: - test_rs_first_entry (x86-64, shared boot): a raw thread's first instruction stores the registers; every one but rsp and rdi is zero. - test_rs_ring0_clears_ac (x86-64, the actuator boot): the thread sets AC and takes 64 page faults and a syscall with it; the test kernel's ac_witness at trap_dispatch and syscall_handler panics by name where Ring 0 runs with AC set, and the thread reads its own AC back still set. - virt_pan_refuses_el1_a_user_page: kernelprobe's pan_read has the test kernel read its page at EL1 (SYS_DEBUG NULL_READ, which had no caller); the kernel ends in a permission fault on exactly that address. - virt_el2_drop's Cortex-A72 is Armv8.0, without FEAT_PAN: it now holds the drop to the kernel's refusal by name. virt_no_seed_refused emulates a Cortex-A76 (FEAT_PAN, no RNDR) instead of a Cortex-A72. - control_regs_verdict refuses a machine without SMAP. Closes issues/a-new-x86-thread-enters-ring-3-holding-kernel-register-values.md, issues/interrupt-entry-keeps-a-ring-3-ac-flag.md and issues/aarch64-el1-runs-without-pan.md; narrows issues/user-pointer-checks-have-no-spectre-v1-fence-and-smap-is-optional.md to its Spectre v1 half. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r1 of #1154 at 938c4a7Architecture. The ownership is right. The x86 trampolines share one tail. AC is cleared in the entry macro that every interrupt and trap gate already goes through, plus the three gates that write their own prologue. SYSCALL keeps relying on What I checked against the Intel SDM, AMD APM and Arm ARM:
FindingsNo blockers.
Metal ( LAND |
control_regs::declaration's refusal printed only a mask (`lacks CR4 bits 0x200000`); it now names them (`(SMAP)`) from a table of CR4_REQUIRED's bits that a const assertion holds complete. And build.rs's CLD doc says what its gate reads: the cld that opens syscall_entry, not every entry. issues/a-threads-kernel-stack-has-no-guard-page.md is held until the small-kernel track's K6 deletes the kernel-thread machinery, which changes which kernel stacks exist (the owner's order, 2026-11-03: a component's refactor before its small fixes), with the two constraints a design must meet: AArch64's EL1 entry has to check sp and report from a per-CPU overflow stack, and a slot's direct-map hole decides when its memory can go back to the PMM. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ock (S0-a) Every user copy goes through `arch::uaccess`: on x86-64 `stac; rep movsb; clac` (or `rep stosb`), on AArch64 unprivileged LDTR/STTR. A Ring 0 or EL1 fault at one of those accesses on a user address is served as the process's own fault would be and the copy resumes, or the copy resumes at its fixup and answers the bytes it did not copy, which `user_ptr` reports as `Faulted` (`BadAddress`). Each routine clamps an address at or past the user half's top to 0 without a branch (`cmovae`; `csel` plus `csdb`), so a mispredicted range check loads no kernel byte. No user copy runs under a spin lock: `user_ptr::gate` panics above the syscall's preemption depth or with interrupts masked. The kernel has no sleeping lock since #1152, so that is every kernel lock. Callers clone their object out of the process's lock (`syscall::handles::object`) and copy after; a device claim is described under it and copied out after (`DeviceRead`). Pipes and messages share one claim protocol whose waiter records itself each time it finds the claim held (`kernel::pipe::Bytes::turn`), so no wake is lost, and an abandoned write announces its room. A pipe transfer of at most 4 KiB and every message bounce through the caller's stack in the hold that looks the handle up; a larger pipe transfer claims its bytes and copies straight between frames and the user. The SDK's `lock_word` spins briefly before it sleeps. SMAP and PAN being required, and the entry `clac`, are main's (#1154). The kernel-file and console parts of the earlier rounds went with #1152. Closes issues/a-user-copy-demand-pages-under-whatever-its-caller-holds.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ock (S0-a) Every user copy goes through `arch::uaccess`: on x86-64 `stac; rep movsb; clac` (or `rep stosb`), on AArch64 unprivileged LDTR/STTR. A Ring 0 or EL1 fault at one of those accesses on a user address is served as the process's own fault would be and the copy resumes, or the copy resumes at its fixup and answers the bytes it did not copy, which `user_ptr` reports as `Faulted` (`BadAddress`). Each routine clamps an address at or past the user half's top to 0 without a branch (`cmovae`; `csel` plus `csdb`), so a mispredicted range check loads no kernel byte. No user copy runs under a spin lock: `user_ptr::gate` panics above the syscall's preemption depth or with interrupts masked. The kernel has no sleeping lock since #1152, so that is every kernel lock. Callers clone their object out of the process's lock (`syscall::handles::object`) and copy after; a device claim is described under it and copied out after (`DeviceRead`). Pipes and messages share one claim protocol whose waiter records itself each time it finds the claim held (`kernel::pipe::Bytes::turn`), so no wake is lost, and an abandoned write announces its room. A pipe transfer of at most 4 KiB and every message bounce through the caller's stack in the hold that looks the handle up; a larger pipe transfer claims its bytes and copies straight between frames and the user. The SDK's `lock_word` spins briefly before it sleeps. SMAP and PAN being required, and the entry `clac`, are main's (#1154). The kernel-file and console parts of the earlier rounds went with #1152. Closes issues/a-user-copy-demand-pages-under-whatever-its-caller-holds.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Kernel entry and isolation, package 1 of the consolidation: what crosses a Ring 0/Ring 3 (EL1/EL0) transition. Closes three records and narrows a fourth.
Problem
process_start/thread_startrestoredr12–r14, the FP state andiretq'd:rax–rdx,rsi,rbp,r8–r11,r15(andrdifor a process) reached a new thread's first instruction holding kernel values. The negative control below shows what:rcx=0xffff8000037f9460 r8=0xffff800000a08f20 r11=0xffff800000a08f20 ...— kernel stack and heap addresses.DFnorAC(Intel SDM Vol. 3A §6.12.1.3). Ring 3 setsACwithpopf(CR0.AMis clear), and SMAP binds only whileACis clear (§4.6.1), so a handler entered from such a thread ran with SMAP off.SYSCALLalready cleared it throughIA32_FMASK.CR4bits, so a CPU without them booted with the user half open to a kernel bug.SCTLR_EL1.SPANwas set (in the RES1 list) and nothing setPSTATE.PAN, so EL1 could load and store through any EL0 mapping.Change
arch::entry::first_entry!: one tail for both x86-64 user trampolines that pushes theiretqframe and zeroes every general register butrspandrdi(the argument; zero for a process), as AArch64's trampoline already did. Ther12–r14pushes around the sysv64 calls were redundant and go;thread_start's three had misaligned itscalls.ring3_naked_asm!(every interrupt and trap gate) now openscld; clac; the NMI, spurious and unclaimed gates, which open-code their prologue, gainclactoo.syscall_entrygets its ownsyscall_naked_asm!withcldalone:IA32_FMASKclearsAC, and the shipping-image gate (build/build.rs'sentry_window) requires the user-rspsave and the switch straight aftercld.DF'sentry-df-uncleancontrol is unchanged (acld!()macro now holds the one instruction it removes).kernel/CLAUDE.md's caveat names both flags.SMEPandSMAPmove toCR4_REQUIRED; the bootclacincontrol_regs::initis unconditional (firmware'sAC). A CPU without either is refused by name.SPAN(bit 23) leavesSCTLR, so every exception to EL1 setsPSTATE.PAN. The entry sets it for the boot byERETing into EL1 withPANinSPSR(SPSR_ENTRY): from EL2 as the drop already did, and now from EL1 too, in place ofbr x22. That needs no FEAT_PAN instruction, which a CPU without it would take as undefined before anything can report.control_regs::checknow asserts EL1/SP_EL1first, refuses a CPU without FEAT_PAN (ID_AA64MMFR1_EL1.PAN) by name, and readsPSTATE.PANback (S3_0_C4_C2_3: theaarch64-unknown-none-softfloatassembler rejectspanas "expected readable system register") on every CPU.Decision for the owner: FEAT_PAN required means Armv8.1+
Every Armv8.1+ CPU has FEAT_PAN (and FEAT_VHE); Armv8.0 cores (Cortex-A53/A57/A72) have neither, so they are now refused. Every AArch64 machine this tree runs on has it:
virtunder HVF (Apple silicon) and TCG'smax. Two test profiles emulated a Cortex-A72:VirtNoRng(the no-RNDR machine) now emulates a Cortex-A76 (Armv8.2: PAN, no RNDR).VirtEl2NoVhestays on the Cortex-A72, since no CPU with PAN lacks VHE;virt_el2_dropnow holds the EL2 drop to the kernel's FEAT_PAN refusal by name (control registers: this CPU, entered at EL2, has no FEAT_PAN) instead of tocheck's "as declared" line. The positive EL2 drop withcheckpassing isVirtEl2(max):virt_jobs_at_el2and the SMP rows.If Armv8.0 hardware is meant to be a target, PAN would have to be optional and this decision reversed.
SMEP/SMAP required: every CPU of the kernel's 2020+ range has both; both benches (Tiger Lake T14, Ryzen 7 4800U) are in the metal request below, where a CPU without either refuses the boot.
Tests
New:
test_rs_first_entry(x86-64, shared boot; metal and the scratch QEMU run below): a raw thread whose first instruction stores the registers; all butrsp/rdimust be zero. AArch64 already hadvirt_first_entry.test_rs_ring0_clears_ac(x86-64, the actuator boot): the thread setsAC, takes 64 page faults and a syscall with it, and reads its ownACback still set (the stimulus happened and the kernel gave it back). The test kernel'scpu::ac_witness(cfg(feature = "test-actuators"), attrap_dispatchandsyscall_handler, besidedf_witness) panics by name where Ring 0 runs withAC. The binary first asksSYS_DEBUG'sSPACE_REACHEDso it refuses a kernel without the witness rather than passing vacuously; that puts it onACTUATOR_TESTSthroughsuite_split's rule.virt_pan_refuses_el1_a_user_page(virt, HVF on Apple silicon): kernelprobe'span_readhas the test kernel read its page at EL1 (NULL_READ, which had no caller;issues/sys-debug-actions-and-two-loader-words-that-nothing-calls.mdupdated). The kernel must end in a permission fault (EC 0x25, DFSC 0b0011xx) at exactly that address. Own boot (tests/images/virtpancase.toml), since it ends the machine.control_regs_verdict(host) refuses a machine without SMAP; the metalcontrol_regsrow holds SMAP to set.Why guest tests: whether a CPU's first instruction, a gate's flags or EL1's permission check behave is the CPU's, which no host test reaches.
Runs (revision
7a8263e5e; the head938c4a7cbonly adds the new case tobuild/build.rs'sALL_CONFIGStest list, rerun there; Apple silicon host; x86-64 under TCGqemu64,+smap,+smep,..., AArch64virtunder HVF):cargo run -- --build-only --arch x86_64and--arch aarch64: exit 0.cargo test --test toyos-build -- virt_first_entry virt_pan_refuses_el1_a_user_page virt_el2_drop virt_no_seed_refused virt_jobs_at_el2 scratch_entry_jobs: exit 0, 6/6.scratch_entry_jobswas a scratch (uncommitted) machine test that booted the test kernel on x86-64 q35 and rantest_rs_first_entrythentest_rs_ring0_clears_ac: the shared boots run only on metal, so this is the local QEMU evidence for the two x86 binaries.first_entryexits 101,a new thread's first instruction found rcx=0xffff8000037f9460 rsi=0x2 r8=0xffff800000a08f20 ....clacremoved fromring3_naked_asm: the boot panicsAC WITNESS: Ring 0 reached trap_dispatch with RFLAGS.AC set.control_regs.rs/boot.rsatorigin/main(the whole AArch64 change reverted):virt_panreds,pan_read: the kernel read 0x111000 at EL1 and answered 0x0: PAN did not refuse it.cargo run -- --ci host: exit 1, 1 of 109 steps red:every_shipped_boot_config_is_covered, the new case missing fromALL_CONFIGS; fixed in the head, andcargo test -p toyos-build --libthere exits 0 (375 passed).Evidence: https://github.com/ToyOSOrg/evidence/tree/58790fa1c0d69f8b29a7a5e801cef4cca3d562f7/pr-1154/r1
Metal (both benches, orchestrator's):
boot:testcases(shipping kernel:test_rs_first_entryand thecontrol_regsCR4 verdict with SMAP required) andboot:shared-debug(test kernel:test_rs_ring0_clears_ac). Request staged at 938c4a7 (--metal-readback, no machine touched):~/.claude/jobs/2280e09e/tmp/scratchpad/agents/entry/metal-pr1/request.txt; itstestcases-watchdogboot (no jobs) came withboot:testcases's plan and is not needed for these claims.Overlaps and what stays open
issues/user-pointer-checks-have-no-spectre-v1-fence-and-smap-is-optional.mdis renamedissues/user-pointer-checks-have-no-spectre-v1-fence.mdwith its SMAP half removed; its two citations moved. Its fence/mask is not in this PR: it lands inkernel/src/user_ptr.rs, which kernel: user copies go through the process's own mapping, and none runs under a lock (S0-a's lock half) #1150 and Consolidate storage around native bcachefs and userland filesystems #1152 both rewrite.🤖 Generated with Claude Code
Review r1 (head f6bb93f)
lacks CR4 bits 0x200000 (SMAP) ...) from a table a const assertion holds equal toCR4_REQUIRED.build/build.rs'sCLDdoc now says it openssyscall_entry, which is what its gate reads.issues/a-threads-kernel-stack-has-no-guard-page.mdis held until the small-kernel track's K6 (owner's order: a component's refactor before its small fixes), with the two constraints a design must meet.cargo run -- --clippyexit 0;cargo test -p toyos-build --libexit 0 (375);vector_state_survives_a_switchexit 0. Evidence: https://github.com/ToyOSOrg/evidence/tree/f6720ffe935076401c223520b28a4c1de61f9499/pr-1154/r2