Skip to content

kernel entry: no kernel register reaches Ring 3, AC and PAN bind at every entry - #1154

Merged
Japabu merged 2 commits into
mainfrom
kernel-entry
Nov 3, 2026
Merged

Japabu merged 2 commits into
mainfrom
kernel-entry

Conversation

@Japabu

@Japabu Japabu commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • x86-64 register leak. process_start/thread_start restored r12–r14, the FP state and iretq'd: rax–rdx, rsi, rbp, r8–r11, r15 (and rdi for 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.
  • AC on interrupt entry. An interrupt or trap gate clears neither DF nor AC (Intel SDM Vol. 3A §6.12.1.3). Ring 3 sets AC with popf (CR0.AM is clear), and SMAP binds only while AC is clear (§4.6.1), so a handler entered from such a thread ran with SMAP off. SYSCALL already cleared it through IA32_FMASK.
  • SMAP/SMEP optional. Both were optional CR4 bits, so a CPU without them booted with the user half open to a kernel bug.
  • No PAN on AArch64. SCTLR_EL1.SPAN was set (in the RES1 list) and nothing set PSTATE.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 the iretq frame and zeroes every general register but rsp and rdi (the argument; zero for a process), as AArch64's trampoline already did. The r12–r14 pushes around the sysv64 calls were redundant and go; thread_start's three had misaligned its calls.
  • ring3_naked_asm! (every interrupt and trap gate) now opens cld; clac; the NMI, spurious and unclaimed gates, which open-code their prologue, gain clac too. syscall_entry gets its own syscall_naked_asm! with cld alone: IA32_FMASK clears AC, and the shipping-image gate (build/build.rs's entry_window) requires the user-rsp save and the switch straight after cld. DF's entry-df-unclean control is unchanged (a cld!() macro now holds the one instruction it removes). kernel/CLAUDE.md's caveat names both flags.
  • SMEP and SMAP move to CR4_REQUIRED; the boot clac in control_regs::init is unconditional (firmware's AC). A CPU without either is refused by name.
  • AArch64: SPAN (bit 23) leaves SCTLR, so every exception to EL1 sets PSTATE.PAN. The entry sets it for the boot by ERETing into EL1 with PAN in SPSR (SPSR_ENTRY): from EL2 as the drop already did, and now from EL1 too, in place of br x22. That needs no FEAT_PAN instruction, which a CPU without it would take as undefined before anything can report. control_regs::check now asserts EL1/SP_EL1 first, refuses a CPU without FEAT_PAN (ID_AA64MMFR1_EL1.PAN) by name, and reads PSTATE.PAN back (S3_0_C4_C2_3: the aarch64-unknown-none-softfloat assembler rejects pan as "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: virt under HVF (Apple silicon) and TCG's max. Two test profiles emulated a Cortex-A72:

  • VirtNoRng (the no-RNDR machine) now emulates a Cortex-A76 (Armv8.2: PAN, no RNDR).
  • VirtEl2NoVhe stays on the Cortex-A72, since no CPU with PAN lacks VHE; virt_el2_drop now 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 to check's "as declared" line. The positive EL2 drop with check passing is VirtEl2 (max): virt_jobs_at_el2 and 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 but rsp/rdi must be zero. AArch64 already had virt_first_entry.
  • test_rs_ring0_clears_ac (x86-64, the actuator boot): the thread sets AC, takes 64 page faults and a syscall with it, and reads its own AC back still set (the stimulus happened and the kernel gave it back). The test kernel's cpu::ac_witness (cfg(feature = "test-actuators"), at trap_dispatch and syscall_handler, beside df_witness) panics by name where Ring 0 runs with AC. The binary first asks SYS_DEBUG's SPACE_REACHED so it refuses a kernel without the witness rather than passing vacuously; that puts it on ACTUATOR_TESTS through suite_split's rule.
  • virt_pan_refuses_el1_a_user_page (virt, HVF on Apple silicon): kernelprobe's pan_read has 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.md updated). 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 metal control_regs row 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 head 938c4a7cb only adds the new case to build/build.rs's ALL_CONFIGS test list, rerun there; Apple silicon host; x86-64 under TCG qemu64,+smap,+smep,..., AArch64 virt under HVF):

  • cargo run -- --build-only --arch x86_64 and --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_jobs was a scratch (uncommitted) machine test that booted the test kernel on x86-64 q35 and ran test_rs_first_entry then test_rs_ring0_clears_ac: the shared boots run only on metal, so this is the local QEMU evidence for the two x86 binaries.
  • Negative controls, each a checked patch applied, run and reverted in the same run:
    • NC1, the zeroing removed: first_entry exits 101, a new thread's first instruction found rcx=0xffff8000037f9460 rsi=0x2 r8=0xffff800000a08f20 ....
    • NC2, clac removed from ring3_naked_asm: the boot panics AC WITNESS: Ring 0 reached trap_dispatch with RFLAGS.AC set.
    • NC3, AArch64's control_regs.rs/boot.rs at origin/main (the whole AArch64 change reverted): virt_pan reds, 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 from ALL_CONFIGS; fixed in the head, and cargo test -p toyos-build --lib there 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_entry and the control_regs CR4 verdict with SMAP required) and boot: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; its testcases-watchdog boot (no jobs) came with boot:testcases's plan and is not needed for these claims.

Overlaps and what stays open

🤖 Generated with Claude Code

Review r1 (head f6bb93f)

  • The CR4 refusal now names the missing bits (lacks CR4 bits 0x200000 (SMAP) ...) from a table a const assertion holds equal to CR4_REQUIRED.
  • build/build.rs's CLD doc now says it opens syscall_entry, which is what its gate reads.
  • issues/a-threads-kernel-stack-has-no-guard-page.md is 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.
  • Runs at f6bb93f: cargo run -- --clippy exit 0; cargo test -p toyos-build --lib exit 0 (375); vector_state_survives_a_switch exit 0. Evidence: https://github.com/ToyOSOrg/evidence/tree/f6720ffe935076401c223520b28a4c1de61f9499/pr-1154/r2
  • The queued metal at 938c4a7 still stands: the image differs only in the refusal's text and a compile-time assertion, so every decision the benches judge is unchanged.

…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>
@Japabu

Japabu commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Review r1 of #1154 at 938c4a7

Architecture. 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 IA32_FMASK. On AArch64, PAN is part of the control_regs declaration (SCTLR.SPAN clear, SPSR_ENTRY) and check asserts it on every CPU (boot.rs for the BSP, smp.rs:109 for each AP). That meets the rule that one declaration is applied and asserted per CPU. Setting PAN by ERET from EL1 to EL1 is simpler than an msr pan that would need its own feature probe first. Net: production +169/-89, tests +233/-30, issue records -70.

What I checked against the Intel SDM, AMD APM and Arm ARM:

  • x86 gates. All 19 dispatched exception stubs (including #DF on IST1 and #MC on IST3) go through common_entry and then ring3_naked_asm (cld; clac). The timer, TLB, device-IRQ macro (DMA fault, RTC, ISA, kick, user-device claims), NMI (IST2), spurious and unclaimed gates all clear AC. stub_halt_all touches no memory. The microcode #GP gate is entered from Ring 0 only. Nothing in the kernel runs stac or sets AC: user_ptr copies through the direct map, so no legitimate copy depends on AC. context_switch's pushfq/popfq saves AC as clear because every Ring 0 context has it clear. iretq restores Ring 3's AC from the frame, and a nested return restores Ring 0's clear AC. clac raises #UD on CPUID.SMAP=0, not on CR4.SMAP, and control_regs::init(0) runs before idt::init (percpu.rs:514-519), so the BSP is refused before any gate's clac can run.
  • First entry. initial_frame gives a process r14 = 0 (loader/start.rs:101). trampoline_entry and load_initial are extern "C", so r12-r14 survive them. With the pushes gone, thread_start's calls are aligned.
  • AArch64. With SPAN = 0, every exception to EL1 sets PAN (sync, IRQ, FIQ and SError, from EL0 or EL1). Returns use the saved SPSR, which nothing modifies (trap.rs:487/496). Kernel threads start by ret, not ERET. context_switch restores only DAIF, and PAN stays 1 at EL1 throughout. There is no PSCI suspend/resume path that could come back with PAN = 0. Every user leaf sets AP[1] (paging.rs:94-96; there are no execute-only EL0 pages), so PAN without FEAT_PAN3/EPAN covers every user page. AT S1E1R (present_in_current_tables) and dc by VA are unaffected by PAN. The PAN encoding S3_0_C4_C2_3, ID_AA64MMFR1_EL1[23:20] and SPSR bit 22 are correct. The ERET to EL1h forces SP_EL1, and the entry sets sp after it.
  • Evidence. The guest rows ran at 7a8263e5e, and the only change since is one ALL_CONFIGS line in build/build.rs, so reusing those runs is sound. NC1, NC2 and NC3 each fail for the expected reason (logs/nc1-nc3.log, logs/nc2.log).

Findings

No blockers.

  • SUGGESTION kernel/src/arch/x86_64/control_regs.rs:463: the PR and commit say a CPU without SMEP or SMAP "is refused by name". The kernel's refusal prints only a mask (lacks CR4 bits 0x200000); the harness verdict is what names the bit. Either name the missing bits in the panic or correct the description.
  • SUGGESTION build/build.rs:1746: "which arch::entry::cld puts first in every Ring 0 entry" is not true of the NMI, spurious and unclaimed gates, which write a literal cld. "first in syscall_entry", which is what the gate checks, would be accurate.
  • NOTE for the orchestrator, not a code finding: the PR correctly frames "FEAT_PAN required" as a decision for the owner. It drops Armv8.0 cores (A53/A57/A72). The record this closes already had "refuses a CPU without FEAT_PAN" in its exit, and no AArch64 machine in the tree lacks PAN. Still, the handoff's heading "OWNER DECISION" should not be read as a ruling already given.

Metal (boot:testcases, boot:shared-debug) is queued and not reviewed here.

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>
@Japabu
Japabu marked this pull request as ready for review November 3, 2026 22:56
@Japabu
Japabu added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit f87a16a Nov 3, 2026
10 checks passed
@Japabu
Japabu deleted the kernel-entry branch November 3, 2026 23:16
Japabu added a commit that referenced this pull request Nov 5, 2026
…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>
Japabu added a commit that referenced this pull request Nov 5, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant