Repository navigation
Kernel memory is never writable and executable, and a boot audit holds it - #984
Conversation
|
Metal, head
|
Review r1 — PR #984 at
|
…plit Review r1 of #984. - AArch64 `paging::join` installed the kernel's TTBR1 root over the loader's while running on it and flushed afterwards. The two maps differ in block size (the loader's 2 MiB blocks against the kernel's pages around its image) and in permissions, and a size change needs break-before-make (Arm ARM DDI 0487 M.d D8.17.1) or the TLB may hold both translations and take a TLB conflict abort. `join` now runs from the image's identity alias under the old TTBR0, swaps TTBR1 to an empty root, invalidates with `TLBI VMALLE1`, installs the kernel's root, returns to the link address and only then switches TTBR0, whose new root maps nothing. Interrupts stay masked across it. QEMU models no TLB conflict, so this rests on the architecture, not on a test. - Both architectures map a 2 MiB page of the direct map as one leaf wherever the image's protections are uniform across it (`mm::Image::block`), and split only pages that hold two. The boot stack and whole pages of data stay blocks: 4092 kernel leaves on q35 instead of 6647, 3184 on `virt` instead of 5739. - x86-64 `kernel_leaf`'s doc covers both leaf sizes and the PAT-bit caveat; the `kernel_page` wrapper is gone. - Both `stage_writable_executable` controls handle a leaf under a split 2 MiB page and refuse an absent one. - The x86-64 boot map issue joins the security track's list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Response to review r1. The new head is B1 (AArch64
B2 (x86-64 Suggestions:
Host CI is green, both builds pass, 8 of 8 guest tests pass, and the data-executable mutation reds 4 of 4 again. Please review the fixes; the earlier findings stay settled. |
|
Metal, head
The mixed-only split cut leaves from 12529 to 9974 (T14) and from 12390 to 9835 (AMD). |
Review r2 — PR #984 at
|
…s it Stage 0 of the native memory design: kernel W^X on x86-64 and AArch64. Before this, the kernel's direct map was one writable, executable 2 MiB leaf per page (x86-64 `kernel_leaf` with no NX, AArch64 with PXN clear), and the image ran from it: its text was writable and its heap, stacks and every user frame's alias executable at CPL 0 / EL1. Now: - Kernel leaves take a `Prot`. The image's 2 MiB pages of the direct map are split into 4 KiB leaves from its PT_LOAD flags (`mm::Image`): text read-only and executable, rodata read-only and execute-never, data and the boot stack read-write and execute-never. Every other kernel leaf, the rest of the direct map, MMIO and the scanout included, is read-write and execute-never. The image still runs from the direct map, so its text has one mapping and no writable alias. - x86-64: `control_regs::init_cr0` becomes `init_paging`, which writes the declared CR0 (WP) and EFER (NXE) before a CPU loads the kernel's tables: the BSP before `mm::init`, an AP first in `ap_entry`. The NX CPUID check moves there, ahead of the write. The AP trampoline now writes the declared EFER less LMA from its data block instead of OR-ing in LME, so an AP enters long mode under the declaration and can walk NX entries even under `no-ap-control-regs`. - AArch64: SCTLR_EL1.WXN is declared. The entry writes it before translation turns on under the loader's tables, so those map the kernel's text read-only too: the boot map plan takes the text range (`toyos_bootmap::Access`), splits the pages it shares, and the AArch64 encoding makes everything else PXN. x86-64's encoding makes the text read-only and cannot use XD there (no NXE yet when the loader switches). - `kernel::wx::audit` walks the kernel's tables once boot mappings are made (after `iommu::drivers_settled`) and panics on any leaf that is writable and executable, or executable outside the text. A leaf is judged by its own bits only, so the audit is never looser than the hardware. It logs its pass with the leaf count. - `kernel-wx-leaf` stages one writable, executable direct-map leaf for the negative control. Bit positions: Intel SDM Vol. 3A §4.5 tables; AArch64 descriptors from Arm DDI 0487 M.d D8.3 (Tables D8-48, D8-52), WXN semantics D8.4.1.2.4, SCTLR_EL1.WXN bit 19 from Arm's system register XML (DDI 0601) 2023-12. Closes `issues/kernel-text-is-writable-and-every-kernel-page-executable.md`. Files what remains: the kernel's RELRO stays writable, and x86-64's boot map is writable and executable until each CPU leaves it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…plit Review r1 of #984. - AArch64 `paging::join` installed the kernel's TTBR1 root over the loader's while running on it and flushed afterwards. The two maps differ in block size (the loader's 2 MiB blocks against the kernel's pages around its image) and in permissions, and a size change needs break-before-make (Arm ARM DDI 0487 M.d D8.17.1) or the TLB may hold both translations and take a TLB conflict abort. `join` now runs from the image's identity alias under the old TTBR0, swaps TTBR1 to an empty root, invalidates with `TLBI VMALLE1`, installs the kernel's root, returns to the link address and only then switches TTBR0, whose new root maps nothing. Interrupts stay masked across it. QEMU models no TLB conflict, so this rests on the architecture, not on a test. - Both architectures map a 2 MiB page of the direct map as one leaf wherever the image's protections are uniform across it (`mm::Image::block`), and split only pages that hold two. The boot stack and whole pages of data stay blocks: 4092 kernel leaves on q35 instead of 6647, 3184 on `virt` instead of 5739. - x86-64 `kernel_leaf`'s doc covers both leaf sizes and the PAT-bit caveat; the `kernel_page` wrapper is gone. - Both `stage_writable_executable` controls handle a leaf under a split 2 MiB page and refuse an absent one. - The x86-64 boot map issue joins the security track's list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stage 0 of the native memory design: no kernel page is ever both writable and executable, on x86-64 and AArch64, and every boot checks it.
Problem
The kernel's direct map was one 2 MiB leaf per page, writable and executable (x86-64
kernel_leafhad no NX; AArch64's left PXN clear), and the kernel image runs from that map. So kernel text could be overwritten, and the heap, the stacks and every user frame's direct-map alias could be executed at CPL 0 / EL1 (issues/kernel-text-is-writable-and-every-kernel-page-executable.md).Resulting behaviour
Kernel leaves take a
Prot. Each page of the kernel image follows itsPT_LOADflags (mm::Image). A 2 MiB page of the direct map stays one leaf wherever the image's protection is the same across it, and is split into 4 KiB leaves only where it holds two:Every other kernel leaf is read-write and execute-never. That covers the rest of the direct map, the heap, MMIO and the scanout. The image still runs from the direct map, so its text has exactly one mapping and no writable alias.
x86-64 control registers.
control_regs::init_cr0is nowinit_paging. It writes the declaredCR0(WP) andEFER(NXE) before a CPU loads the kernel's tables: on the BSP beforemm::init, and on an AP first thing inap_entry. The NX CPUID check moves ahead of that write.control_regs::initno longer writesEFER, but its self-check still asserts it.x86-64 AP trampoline. It used to OR in
LME. It now writes the declaredEFERminusLMA(EFER_BEFORE_LONG_MODE) from its data block, so an AP enters long mode under the declaration. As a result, an AP left undeclared byno-ap-control-regscan still walk NX entries and reach the self-check that catches it.AArch64
WXN.SCTLR_EL1.WXNis now part of the declaration, so hardware forces every writable page execute-never at EL1 and EL0. The entry writesSCTLRbefore it turns translation on under the loader's boot map. That boot map therefore had to stop mapping the kernel's text writable:toyos_bootmap::Plantakes the text range and splits the 2 MiB pages it shares;Access::TextorAccess::Data;AArch64's boot map is now W^X too.
x86-64 boot map. Its encoding makes the text read-only, but it cannot mark anything XD: the loader switches to the boot map before anyone has set
NXE. That map stays writable and executable until each CPU loads the kernel's tables (filed below).The boot audit.
kernel::wx::auditis a pure, host-tested walk with a decoder for each architecture. The boot runs it once its own mappings are made, afteriommu::drivers_settled. It panics on any kernel leaf that is writable and executable, or executable outside the text, and otherwise logspaging: N kernel leaves, none writable and executable, none executable outside the text A..B. It judges each leaf by the leaf's own bits only, never crediting a table's narrowing, so it can be stricter than the hardware but never looser.Negative-control actuator.
kernel-wx-leafmakes one direct-map leaf writable and executable before the audit runs.Handoff, trampoline, patched code and early boot
mm::Imagerefuses the same.join, the switch from the loader's map to the kernel's tables. It is a break-before-make. The two maps differ in block size and in permissions, and Arm ARM DDI 0487 M.d D8.17.1 requires break-before-make for a block-size change. Sojoinruns from the image's identity alias under the oldTTBR0, swapsTTBR1to an empty root, runsTLBI VMALLE1, installs the kernel's root, and returns to the link address. Only then does it switchTTBR0, whose new root maps nothing. Interrupts stay masked throughout. QEMU models no TLB conflict, so this rests on the architecture, not on a test.bringup_root, which shares the kernel's own W^X tables, withWXNalready set, and then go through the samejoin.rgfinds no text patching.cache::make_executablecleans user frames through the direct map's data alias and is unchanged.Decisions to look at
WXN. On x86-64 it costs nothing and makes the text read-only from the first kernel instruction. The alternative was a two-phaseSCTLR(enter withoutWXN, set it afterjoin), which would have given the one declaration two values.EFERfrom the declaration's own value. It already wroteEFER(LME), and now it applies the declaration instead of deciding a bit itself.SCTLR_EL1.UCI/UCTare not set here. The design's stage-0 row lists them, but they open EL0 cache maintenance for user JIT, which belongs to the user-memory stages the brief excluded.PT_GNU_RELROis not made read-only.toyos-elfdoesn't parse it, and teaching it to would change the sysroot's identity. It is filed instead.Checks and evidence
Review r1 fixes, head
aa2295250The index is at https://github.com/ToyOSOrg/evidence/blob/6afdea6bb08e80ef6409cdee413b7ca729d58f99/r2/README.md.
cargo run -- --ci host: exit 0, 87 steps green (log).--build-onlybuilds: exit 0.virtat EL1 and at EL2 through the newjoin. The leaf counts fall to 4092 on q35 and 3184 onvirt.no-ap-control-regs, run as a temporary probe that was then reverted (log):efer=0x0d01;cpu1 holds cr0=0xe0000011, the declaration is 0x80010033.b7a7b5827(posted on the PR): the T14 passed with 12529 leaves and the AMD bench with 12390. The x86-64 mappings changed in r2, so both benches are restaged foraa2295250: T14, AMD.First round, head
b7a7b5827The index is at https://github.com/ToyOSOrg/evidence/blob/b2796eb810f176286974aec225239a0076c1f101/README.md. Results for head
b7a7b5827:cargo run -- --ci host: exit 0, 87 steps green (log).cargo run -- --build-onlyand--arch aarch64: both exit 0 (x86-64, AArch64).cargo test --test toyos-build -- kernel_wx_leaf_is_refused virt_user_mode virt_smp virt_el1_smp console_image_boots virt_early_panic virt_el2_drop, exit 0, 8 of 8 passed (log).cr0=0x80010033,efer=0x0d01;virt: 5749 leaves pass, 8 CPUs at EL1 and at EL2, every CPU holdingSCTLR_EL1=0x30d8199d(WXNset).kernel::wx(10 tests: both decoders, leaf sizes, canonical andTTBR1addresses, both rules);toyos-bootmaptext splitting, encodings and refusals, plus an independent x86-64 walk that reads the text as read-only in both views.Integration tests and where they run
kernel_wx_leaf_is_refused(q35,Profile::Metal, whose 16550 carries the panic) andvirt_kernel_wx_leaf_is_refused(virt). Each needs a boot of its own because the audit's panic ends it. The judge requires the refusal to name the staged leaf, and no other leaf, under both rules. That also shows every leaf the boot made itself passed the same walk.kernel_wx(the T14 and the AMD bench) reads the shipping kernel's pass line from the sharedtestcasesboot and adds no boot. Firmware, memory maps and machine tables differ on metal, which is why that row matters. It is staged and not yet run: the orchestrator runs it. Requests: T14, AMD (the AMD bench's run needs its--host). No AArch64 bench exists, so AArch64 has QEMU evidence only.Arm sources
Every AArch64 bit position was checked against Arm's own documents, with hashes and pages recorded in the provenance file:
SCTLR_EL1.WXNbit 19: Arm's system register XML 2023-12 (DDI 0601's source);WXNsemantics: DDI 0487 M.d D8.4.1.2.4;TTBR1range: D8.2.4.Issues
kernel-text-is-writable-and-every-kernel-page-executable.md: its exit is met, including that the old direct map now reds the walk.the-kernels-relro-stays-writable.md(Linux-parity residual; it takes the deleted issue's place in the security track) andx86-64s-boot-map-is-writable-and-executable.md.Risks
aa2295250waits on the restaged runs.b7a7b5827passed on both benches.kernel_leaf(cache, Prot), and noProtis both writable and executable.join's break-before-make cannot be shown by a test: QEMU models no TLB conflict, and there is no AArch64 bench. It rests on D8.17.1 and the code.🤖 Generated with Claude Code