diff --git a/docs/src/architecture/applications.md b/docs/src/architecture/applications.md index 4812f9fbd02..9ff703394c6 100644 --- a/docs/src/architecture/applications.md +++ b/docs/src/architecture/applications.md @@ -42,6 +42,12 @@ linking and library loading are userland work shared by the Rust and C runtimes. A program's image is read by userland through its own view and handed to the kernel as memory, never named by a path the kernel resolves. +A program relocates itself as it starts, and once its relocations are applied +it makes read-only the part of its image that only they write, as the loader +does for each library. The protection is the program's defence against its own +stray or hijacked stores. The kernel does not enforce it; a program that skipped +it would weaken only itself. + ## Desktop apps A window's pixels reach the compositor through shared memory. The toolkit and diff --git a/issues/an-executables-relro-stays-writable.md b/issues/an-executables-relro-stays-writable.md index 5556ef286bb..026efe7588e 100644 --- a/issues/an-executables-relro-stays-writable.md +++ b/issues/an-executables-relro-stays-writable.md @@ -6,18 +6,21 @@ opened: 2026-10-10 # An executable's RELRO stays writable for its whole life -`PT_GNU_RELRO` names what a program's relocations write once and nothing -writes after: `.data.rel.ro`, `.dynamic`, `.got`, every vtable and every table -of function pointers. A parent lays it out as part of the writable range that -holds it (`toyos::elf::program`), which the kernel maps as asked; nothing reads -an executable's `PT_GNU_RELRO`, and nothing takes the write away once -`toyos::relocate` has run. A stray or hostile write in a running program can -rewrite a vtable. A library's is read-only once the SDK's loader has relocated -it (`sdk/toyos/src/dl/mod.rs`, `protect`); an executable's is not, and the same -`SYS_VM_PROTECT` is what its start would call. +**Built**: `toyos::dl::start` makes an executable's whole `PT_GNU_RELRO` +pages read-only with `SYS_VM_PROTECT` once every relocation is applied, as +the SDK's loader does a library's. `relro_sealed` stores to a relocated word +in a whole RELRO page from a child and holds that the child ends as a memory +fault. The AArch64 job case (`virt_jobs_at_el2`) runs it, and the same row +without the seal is red. On x86-64 it is a member of the T14's `testcases` +shared boot, which no guest row runs. -Owner: the userland loader of -`issues/the-kernel-still-parses-what-userland-writes.md`. +**Left**: +- **Owed on metal:** the T14's `testcases` shared boot. Main must show the + whole boot green, every discovered Rust test and every C corpus case among + it; the line `relro_sealed: a store to a relocated word of PT_GNU_RELRO + ended its program as a memory fault`; no program saying `PT_GNU_RELRO could + not be made read-only`; and no exit 127 that main's last `testcases` boot + before the seal did not have. Exit: once a program has relocated itself its `PT_GNU_RELRO` range is read-only, through a protection primitive the program calls or a mapping diff --git a/sdk/toyos/src/dl/image/mod.rs b/sdk/toyos/src/dl/image/mod.rs index 34b63680ef0..e059738d58a 100644 --- a/sdk/toyos/src/dl/image/mod.rs +++ b/sdk/toyos/src/dl/image/mod.rs @@ -68,6 +68,18 @@ pub(crate) fn plan(header: &[u8]) -> Result { Ok(Plan { layout, span }) } +/// The whole pages of the `PT_GNU_RELRO` of the executable `header` begins, +/// as an offset from `header` and a length: `header` is the first byte of +/// the `PT_LOAD` at file offset 0, wherever the image's lowest address is. +pub(crate) fn executable_relro(header: &[u8]) -> Result, Refused> { + let layout = Layout::parse(header, MACHINE).map_err(|e| e.as_str())?; + let Some((lo, hi)) = layout.relro_pages(PAGE) else { return Ok(None) }; + let first = layout.segments().iter().find(|seg| seg.file_offset() == 0); + let first = first.ok_or("ELF: no PT_LOAD maps the executable's header")?; + let at = lo.checked_sub(first.image().start().get()).ok_or("ELF: PT_GNU_RELRO lies below the executable's header")?; + Ok(Some((at as usize, (hi - lo) as usize))) +} + impl Plan { /// A library's segments, which the loader protects as their flags say: /// one both writable and executable is refused rather than given either. diff --git a/sdk/toyos/src/dl/image/tests.rs b/sdk/toyos/src/dl/image/tests.rs index 66a2a76b806..d86deab05ee 100644 --- a/sdk/toyos/src/dl/image/tests.rs +++ b/sdk/toyos/src/dl/image/tests.rs @@ -9,7 +9,7 @@ use alloc::string::ToString; use alloc::vec; use alloc::vec::Vec; -use super::{plan, relocate, FilePages, Mapped, Pass, Scope}; +use super::{executable_relro, plan, relocate, FilePages, Mapped, Pass, Scope}; use crate::dl::Refused; use crate::elf::RelocError; @@ -304,6 +304,48 @@ fn an_image_off_zero_or_with_segments_sharing_a_page_is_refused() { assert_eq!(plan(&shared[..0x1000]).map(|_| ()).map_err(|e| e.to_string()), Err("ELF: two PT_LOAD segments share a page".to_string())); } +/// An executable's RELRO is where its header says, counted from the header: +/// the header's segment need not be where the image's addresses begin. +#[test] +fn an_executables_relro_pages_are_counted_from_its_header() { + /// `PT_TLS`'s header made `PT_GNU_RELRO` over `[vaddr, vaddr + memsz)`. + fn relro(vaddr: u64, memsz: u64) -> impl FnOnce(&mut [u8]) { + move |b| { + let p = PHDRS + 3 * 56; + b[p..p + 4].copy_from_slice(&0x6474_e552u32.to_le_bytes()); + b[p + 16..p + 24].copy_from_slice(&vaddr.to_le_bytes()); + b[p + 40..p + 48].copy_from_slice(&memsz.to_le_bytes()); + } + } + let pages = |file: &[u8]| executable_relro(&file[..0x1000]).map_err(|e| e.to_string()); + assert_eq!(pages(&object(&[], RELA, relro(DATA, 0x1000))), Ok(Some((0x1000, 0x1000)))); + // Its last, partial page stays writable, and one inside a page is none. + assert_eq!(pages(&object(&[], RELA, relro(DATA, 0xfff))), Ok(None)); + assert_eq!(pages(&object(&[], RELA, |_| {})), Ok(None)); + // Every address 0x1_0000 higher: the header is still the image's first byte. + let lifted = object(&[], RELA, |b| { + relro(DATA + 0x1_0000, 0x1000)(b); + for p in [PHDRS, PHDRS + 56, PHDRS + 2 * 56] { + let vaddr = u64::from_le_bytes(b[p + 16..p + 24].try_into().unwrap()) + 0x1_0000; + b[p + 16..p + 24].copy_from_slice(&vaddr.to_le_bytes()); + } + let entry = u64::from_le_bytes(b[24..32].try_into().unwrap()) + 0x1_0000; + b[24..32].copy_from_slice(&entry.to_le_bytes()); + }); + assert_eq!(pages(&lifted), Ok(Some((0x1000, 0x1000)))); + // The header's segment above another: the writable segment at address 0, + // the header's at 0x1000. + let above = |relro_at: u64| { + object(&[], RELA, |b| { + relro(relro_at, 0x1000)(b); + b[PHDRS + 16..PHDRS + 24].copy_from_slice(&0x1000u64.to_le_bytes()); + b[PHDRS + 56 + 16..PHDRS + 56 + 24].copy_from_slice(&0u64.to_le_bytes()); + }) + }; + assert_eq!(pages(&above(0x1000)), Ok(Some((0, 0x1000)))); + assert_eq!(pages(&above(0)), Err("ELF: PT_GNU_RELRO lies below the executable's header".to_string())); +} + /// A library's segment both writable and executable is refused rather than /// given either protection. #[test] diff --git a/sdk/toyos/src/dl/mod.rs b/sdk/toyos/src/dl/mod.rs index 16e5a7592a6..65484ab3097 100644 --- a/sdk/toyos/src/dl/mod.rs +++ b/sdk/toyos/src/dl/mod.rs @@ -397,32 +397,58 @@ unsafe fn construct(inits: &[(usize, usize)]) { } /// Load the libraries the executable at `header` names and bind its symbols -/// to them, then run their constructors: std's and libc's start call this -/// once its thread-local storage is laid out, before anything that could -/// name such a symbol. One whose libraries cannot be had exits 127 with a -/// line on its stderr naming why, as [`crate::relocate`] does. +/// to them, take the write away from its `PT_GNU_RELRO` pages, then run the +/// libraries' constructors: std's and libc's start call this once its +/// thread-local storage is laid out, before anything that could name such a +/// symbol. One whose libraries cannot be had, or whose `PT_GNU_RELRO` cannot +/// be made read-only, exits 127 with a line on its stderr naming why, as +/// [`crate::relocate`] does. /// /// # Safety /// `header` is the running executable's ELF header, its thread-local storage /// is laid out ([`tls::start`]) and this is its main thread, the only one. pub unsafe fn start(header: *const u8) { - if !crate::relocate::names_a_library() { - return; + let refuse = |line: String| -> ! { + if let Some(stderr) = crate::relocate::stderr() { + let _ = syscall::write(stderr, line.as_bytes()); + } + syscall::exit(127) + }; + let published = match crate::relocate::names_a_library() { + // SAFETY: the caller's. + true => match unsafe { load_needed(header) } { + Ok(published) => Some(published), + Err(why) => refuse(alloc::format!("this program's libraries could not be loaded: {why}\n")), + }, + false => None, + }; + // SAFETY: the caller's, and every relocation of the executable is applied: + // its own by `crate::relocate`, the rest by the load above. + if let Err(why) = unsafe { seal(header) } { + refuse(alloc::format!("this program's PT_GNU_RELRO could not be made read-only: {why}\n")); } - // SAFETY: the caller's. - match unsafe { load_needed(header) } { + if let Some(published) = published { // SAFETY: each a module just published. - Ok(published) => unsafe { construct(&published.inits) }, - Err(why) => { - let line = alloc::format!("this program's libraries could not be loaded: {why}\n"); - if let Some(stderr) = crate::relocate::stderr() { - let _ = syscall::write(stderr, line.as_bytes()); - } - syscall::exit(127) - } + unsafe { construct(&published.inits) }; } } +/// Make the executable's `PT_GNU_RELRO` pages read-only, as [`protect`] does +/// a library's: its vtables, `.got` and `.dynamic`, which a stray or hostile +/// store in the running program could otherwise rewrite. +/// +/// # Safety +/// `header` is the running executable's ELF header, and nothing writes its +/// `PT_GNU_RELRO` again. +unsafe fn seal(header: *const u8) -> Result<(), Refused> { + // SAFETY: the caller's; the page the header begins is mapped. + let page = unsafe { core::slice::from_raw_parts(header, HEADER) }; + let Some((at, len)) = image::executable_relro(page)? else { return Ok(()) }; + // SAFETY: whole pages of the executable's own image, which the header's + // segment begins. + unsafe { syscall::vm_protect(header.cast_mut().add(at), len, VmProt::Read) }.map_err(Refused::Memory) +} + /// # Safety /// [`start`]'s. unsafe fn load_needed(header: *const u8) -> Result { diff --git a/tests/guest/rust-tests/src/bin/relro_sealed.rs b/tests/guest/rust-tests/src/bin/relro_sealed.rs new file mode 100644 index 00000000000..eec737f120d --- /dev/null +++ b/tests/guest/rust-tests/src/bin/relro_sealed.rs @@ -0,0 +1,82 @@ +//! Once a program has relocated itself its `PT_GNU_RELRO` pages are +//! read-only: a copy of this program that stores to a word its relocations +//! wrote is ended as a memory fault, and never reaches the line after the +//! store. + +use std::os::toyos::process::ExitStatusExt; +use std::process::Command; + +use toyos_abi::end::{End, Fault}; + +const STORES: &str = "stores"; +/// What the storing copy exits with when the word is in no whole page of its +/// `PT_GNU_RELRO`: the test's premise is gone, not its subject. +const NOT_IN_RELRO: i32 = 3; + +const PT_LOAD: u32 = 1; +const PT_GNU_RELRO: u32 = 0x6474_e552; +const PH_SIZE: usize = 56; +const PAGE: usize = 4096; + +fn answer() -> u32 { + 42 +} + +/// A function's address, which the linker cannot know: a word this program's +/// relocations write, and so one the linker puts in `PT_GNU_RELRO`. +static TABLE: [fn() -> u32; 1] = [answer]; + +fn main() { + match std::env::args().nth(1).as_deref() { + Some(STORES) => stores(), + Some(other) => panic!("relro_sealed: unknown role {other:?}"), + None => holder(), + } +} + +/// The whole pages of this program's `PT_GNU_RELRO`, as addresses. +fn relro_pages() -> Option<(usize, usize)> { + unsafe extern "C" { + static __ehdr_start: u8; + } + let header = &raw const __ehdr_start; + // SAFETY: the image's own header and program header table, which its + // first segment maps. + unsafe { + let phoff = header.add(32).cast::().read_unaligned() as usize; + let phnum = usize::from(header.add(56).cast::().read_unaligned()); + let kind = |i: usize| header.add(phoff + i * PH_SIZE).cast::().read_unaligned(); + let word = |i: usize, at: usize| header.add(phoff + i * PH_SIZE + at).cast::().read_unaligned() as usize; + // The header is the first byte of the `PT_LOAD` at file offset 0. + let first = (0..phnum).find(|&i| kind(i) == PT_LOAD && word(i, 8) == 0)?; + let relro = (0..phnum).find(|&i| kind(i) == PT_GNU_RELRO)?; + let start = header.addr() + word(relro, 16) - word(first, 16); + Some((start & !(PAGE - 1), (start + word(relro, 40)) & !(PAGE - 1))) + } +} + +fn stores() { + let slot = std::hint::black_box((&raw const TABLE).cast_mut().cast::()); + match relro_pages() { + Some((lo, hi)) if (lo..hi).contains(&slot.addr()) => {} + pages => { + eprintln!("relro_sealed: the table at {:#x} is in no whole page of PT_GNU_RELRO ({pages:x?})", slot.addr()); + std::process::exit(NOT_IN_RELRO); + } + } + // SAFETY: none: the store is the test, and the kernel ends this program at it. + unsafe { slot.write_volatile(0) }; + println!("relro_sealed: the store to PT_GNU_RELRO landed"); +} + +fn holder() { + assert_eq!(TABLE[0](), 42, "relro_sealed: the relocated word does not read"); + let exe = std::env::current_exe().expect("relro_sealed: current_exe"); + let status = Command::new(&exe).arg(STORES).status().expect("relro_sealed: its storing copy"); + assert_eq!( + End::from_word(status.into_raw()), + Some(End::Fault(Fault::Memory)), + "relro_sealed: a store to PT_GNU_RELRO did not end the program as a memory fault ({status})", + ); + println!("relro_sealed: a store to a relocated word of PT_GNU_RELRO ended its program as a memory fault"); +} diff --git a/tests/images/virtjobcase.toml b/tests/images/virtjobcase.toml index 94fb02300fd..64a46a17f60 100644 --- a/tests/images/virtjobcase.toml +++ b/tests/images/virtjobcase.toml @@ -22,7 +22,7 @@ restart = true receives = ["power"] # Four minutes from boot rather than one: a job here may run on an emulated # CPU, and a job past the bound reboots the guest with the job named. -args = ["--bound-ms=240000", "preempt", "fp_isolation", "first_entry", "unmap_touch", "debug_refused", "test_rs_ring0_timer_in_syscall", "test_rs_random_draws", "test_rs_wall_clock_now", "test_rs_user_copy_faults", "test_rs_vm_map_shares", "test_rs_abuse_readonly_copyout"] +args = ["--bound-ms=240000", "preempt", "fp_isolation", "first_entry", "unmap_touch", "debug_refused", "test_rs_ring0_timer_in_syscall", "test_rs_random_draws", "test_rs_wall_clock_now", "test_rs_user_copy_faults", "test_rs_vm_map_shares", "test_rs_relro_sealed", "test_rs_abuse_readonly_copyout"] [programs.kernelprobe] diff --git a/tests/toyos.rs b/tests/toyos.rs index afee118c029..4a0d8e1559b 100644 --- a/tests/toyos.rs +++ b/tests/toyos.rs @@ -388,6 +388,9 @@ const DRIVEN_AND_SHARED: &[&str] = &[ // Its shared run is the x86-64 verdict; `virt_vm_map_shares` builds it // for AArch64 and runs it on that architecture's job case. "vm_map_shares", + // Its shared run is the x86-64 verdict; `virt_jobs_at_el2` builds it for + // AArch64 and runs it on that architecture's job case. + "relro_sealed", ]; /// What the boot's audit of the kernel's tables says when every leaf passed @@ -2918,6 +2921,10 @@ const VIRT_USER_COPY: &str = "user_copy_faults"; /// page mapped executable, which no other AArch64 row reaches. const VIRT_VM_MAP: &str = "vm_map_shares"; +/// A program's `PT_GNU_RELRO` is read-only once it has relocated itself, on +/// AArch64; the shared boot's member on x86-64. +const VIRT_RELRO: &str = "relro_sealed"; + /// The instant `tests/images/virtjobcase.toml`'s RTC starts at, and the same in Unix /// seconds: years from any host's own, so a reading on it came from the RTC. const VIRT_RTC_BASE: &str = "2033-03-07T09:14:25"; @@ -2939,6 +2946,7 @@ const VIRT_JOBS: &[(&str, &str)] = &[ ("test_rs_wall_clock_now", WALL_CLOCK_SAID), ("test_rs_user_copy_faults", "user copies: all checks passed"), ("test_rs_vm_map_shares", "vm_map_shares: an object's pages are mapped where asked"), + ("test_rs_relro_sealed", "relro_sealed: a store to a relocated word of PT_GNU_RELRO ended its program as a memory fault"), ("test_rs_abuse_readonly_copyout", "a syscall writes only where its caller could store"), ]; @@ -2963,7 +2971,7 @@ fn boot_virt_jobs(profile: qemu::Profile) -> QemuInstance { smp: 1, kernel_features: toyos_build::build::TEST_KERNEL, ready_marker: "control registers: SCTLR_EL1=", - extra_root_files: [VIRT_COPYOUT, VIRT_RING0_TIMER, VIRT_RANDOM, VIRT_WALL_CLOCK, VIRT_USER_COPY, VIRT_VM_MAP] + extra_root_files: [VIRT_COPYOUT, VIRT_RING0_TIMER, VIRT_RANDOM, VIRT_WALL_CLOCK, VIRT_USER_COPY, VIRT_VM_MAP, VIRT_RELRO] .map(|name| suite_bin(profile.arch(), name)) .to_vec(), rtc_base: Some(VIRT_RTC_BASE),