Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/src/architecture/applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 14 additions & 11 deletions issues/an-executables-relro-stays-writable.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions sdk/toyos/src/dl/image/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ pub(crate) fn plan(header: &[u8]) -> Result<Plan, Refused> {
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<Option<(usize, usize)>, 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.
Expand Down
44 changes: 43 additions & 1 deletion sdk/toyos/src/dl/image/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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]
Expand Down
58 changes: 42 additions & 16 deletions sdk/toyos/src/dl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Published, Refused> {
Expand Down
82 changes: 82 additions & 0 deletions tests/guest/rust-tests/src/bin/relro_sealed.rs
Original file line number Diff line number Diff line change
@@ -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::<u64>().read_unaligned() as usize;
let phnum = usize::from(header.add(56).cast::<u16>().read_unaligned());
let kind = |i: usize| header.add(phoff + i * PH_SIZE).cast::<u32>().read_unaligned();
let word = |i: usize, at: usize| header.add(phoff + i * PH_SIZE + at).cast::<u64>().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::<usize>());
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");
}
2 changes: 1 addition & 1 deletion tests/images/virtjobcase.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
10 changes: 9 additions & 1 deletion tests/toyos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand All @@ -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"),
];

Expand All @@ -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),
Expand Down