From 764be82aa9f4e62c05561760562ab2407e18044d Mon Sep 17 00:00:00 2001 From: octalide Date: Thu, 2 Jul 2026 01:03:26 -0400 Subject: [PATCH 1/2] feat(runtime/linux): mprotect PT_GNU_RELRO read-only after self-relocation The static-PIE self-relocation maps a read-only segment holding relocated constants (`.rodata` with vtables or `val` pointer globals) writable so it can slide their RELATIVE slots. Once applied, re-protect that region read-only, restoring the constants' hardening before `main`. `_rt_relocate` now captures the PT_GNU_RELRO program header (and AT_PAGESZ) and, after the relocation pass, `mprotect`s `[bias+p_vaddr, page-rounded end)` to PROT_READ. The step runs after relocation, so it uses the ordinary syscall path; it is best-effort (a kernel page larger than the image's segment alignment leaves the region writable rather than failing the program). Runtime half of briar-systems/mach#1778; the linker emits the PT_GNU_RELRO. --- src/runtime/linux/reloc.mach | 60 ++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/src/runtime/linux/reloc.mach b/src/runtime/linux/reloc.mach index ec85b3f..f38fcd4 100644 --- a/src/runtime/linux/reloc.mach +++ b/src/runtime/linux/reloc.mach @@ -16,12 +16,26 @@ # DT_RELA/DT_RELASZ, and slides every entry by the bias: `*(bias + r_offset) = bias + # r_addend`. The mach ELF PIE writer emits only R_*_RELATIVE entries, so every entry is # applied unconditionally. +# +# RELRO: once the relocations are applied, the region the writer marked PT_GNU_RELRO +# (`.rodata` holding relocated constants, mapped writable for this pass) is mprotect'd +# read-only, restoring the hardening of those constants before `main`. This step runs +# after relocation, so it may use the ordinary syscall path. use std.types.bool.bool; use std.types.bool.true; use std.types.bool.false; use std.types.size.usize; +# for the post-relocation RELRO mprotect (see `_rt_relocate`); safe here because it is +# reached only after every RELATIVE entry has been slid. +use sys: std.system.os.linux.shared; + +# ELF PT_GNU_RELRO program-header type and the mprotect PROT_READ flag, as literal +# immediates to match the module's position-independent constant style. +val PT_GNU_RELRO: u32 = 0x6474E552; +val PROT_READ: u32 = 1; + # read a u64 at a raw runtime address. PIC: pure pointer arithmetic, no global. # --- # addr: the runtime address to read @@ -66,34 +80,41 @@ pub fun _rt_relocate(envp: *u64) { idx = idx + 1; # scan the auxv for the program-header location: AT_PHDR=3, AT_PHENT=4, AT_PHNUM=5, - # terminated by AT_NULL=0. - var at_phdr: u64 = 0; - var at_phent: u64 = 0; - var at_phnum: u64 = 0; + # and AT_PAGESZ=6 (the runtime page size, for the RELRO mprotect), terminated by + # AT_NULL=0. + var at_phdr: u64 = 0; + var at_phent: u64 = 0; + var at_phnum: u64 = 0; + var at_pagesz: u64 = 0; for (envp[idx] != 0) { val t: u64 = envp[idx]; val v: u64 = envp[idx + 1]; - if (t == 3) { at_phdr = v; } - if (t == 4) { at_phent = v; } - if (t == 5) { at_phnum = v; } + if (t == 3) { at_phdr = v; } + if (t == 4) { at_phent = v; } + if (t == 5) { at_phnum = v; } + if (t == 6) { at_pagesz = v; } idx = idx + 2; } if (at_phdr == 0 || at_phnum == 0) { ret; } if (at_phent == 0) { at_phent = 56; } # Elf64_Phdr stride - # walk the program headers (Elf64_Phdr: p_type u32 @0, p_vaddr u64 @16): PT_PHDR=6 - # yields the load bias (runtime AT_PHDR minus its link-time vaddr), PT_DYNAMIC=2 the - # .dynamic table. + # walk the program headers (Elf64_Phdr: p_type u32 @0, p_vaddr u64 @16, p_memsz u64 + # @40): PT_PHDR=6 yields the load bias (runtime AT_PHDR minus its link-time vaddr), + # PT_DYNAMIC=2 the .dynamic table, PT_GNU_RELRO the region to re-protect after + # relocation. var bias: u64 = 0; var dyn_va: u64 = 0; + var relro_va: u64 = 0; + var relro_sz: u64 = 0; var have_bias: bool = false; var p: u64 = 0; for (p < at_phnum) { val ph: usize = (at_phdr + p * at_phent)::usize; val p_type: u32 = load32(ph); val p_vaddr: u64 = load64(ph + 16); - if (p_type == 6) { bias = at_phdr - p_vaddr; have_bias = true; } - if (p_type == 2) { dyn_va = p_vaddr; } + if (p_type == 6) { bias = at_phdr - p_vaddr; have_bias = true; } + if (p_type == 2) { dyn_va = p_vaddr; } + if (p_type == PT_GNU_RELRO) { relro_va = p_vaddr; relro_sz = load64(ph + 40); } p = p + 1; } if (!have_bias || dyn_va == 0) { ret; } @@ -124,4 +145,19 @@ pub fun _rt_relocate(envp: *u64) { store64((bias + r_offset)::usize, bias + r_addend); off = off + 24; } + + # RELRO: re-protect the region the PIE writer marked PT_GNU_RELRO. it holds relocated + # constants (vtables, `val` pointer globals) mapped writable for the pass above; now + # that they are slid, mprotect it read-only to restore their hardening before `main`. + # the start is page-aligned by construction and each segment owns its trailing page, + # so the end is rounded up to cover the whole region. best-effort: on a kernel whose + # page exceeds the image's segment alignment the syscall is refused and the region + # stays writable (correct, just unhardened). + if (relro_va != 0 && relro_sz != 0) { + var pagesz: u64 = at_pagesz; + if (pagesz == 0) { pagesz = 4096; } + val relro_start: u64 = bias + relro_va; + val relro_end: u64 = (relro_start + relro_sz + (pagesz - 1)) & ~(pagesz - 1); + sys.protect(relro_start::usize::ptr, (relro_end - relro_start)::usize, PROT_READ); + } } From 737c02f053ca3f02b09523ed0b107007ad58360a Mon Sep 17 00:00:00 2001 From: octalide Date: Thu, 2 Jul 2026 01:41:01 -0400 Subject: [PATCH 2/2] fix(runtime/linux): make RELRO mprotect page-exact and reloc-independent Address review of the PT_GNU_RELRO re-protection: - No runtime page arithmetic. The writer now emits p_memsz already page-rounded, so `_rt_relocate` gates on the region being a whole number of runtime pages (start and size both AT_PAGESZ-congruent) and mprotects it exactly. On a kernel page larger than the image's 4 KiB segment alignment the gate skips, leaving the region writable (correct, just unhardened) instead of risking EINVAL or over-protecting the following segment. Tracked by #336. - Drop the AT_PAGESZ==0 -> 4096 fabrication; AT_PAGESZ is mandatory on linux, so skip hardening when it is absent rather than guessing. - Decouple the re-protection from reloc presence: the RELATIVE apply is guarded rather than early-returned, so the mprotect no longer sits behind a reloc-count gate (they are only coupled by the writer, which emits RELRO for reloc-bearing segments). - Use the bare literal 0x6474e552 for PT_GNU_RELRO in the pre-relocation phdr walk (matching its neighbors, so a codegen change can't silently disable it), and export PROT_READ from the shared OS layer instead of a duplicate local constant. --- src/runtime/linux/reloc.mach | 61 ++++++++++++++++----------------- src/system/os/linux/shared.mach | 4 ++- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/runtime/linux/reloc.mach b/src/runtime/linux/reloc.mach index f38fcd4..a5821f9 100644 --- a/src/runtime/linux/reloc.mach +++ b/src/runtime/linux/reloc.mach @@ -28,14 +28,9 @@ use std.types.bool.false; use std.types.size.usize; # for the post-relocation RELRO mprotect (see `_rt_relocate`); safe here because it is -# reached only after every RELATIVE entry has been slid. +# reached only after every RELATIVE entry has been slid, never before. use sys: std.system.os.linux.shared; -# ELF PT_GNU_RELRO program-header type and the mprotect PROT_READ flag, as literal -# immediates to match the module's position-independent constant style. -val PT_GNU_RELRO: u32 = 0x6474E552; -val PROT_READ: u32 = 1; - # read a u64 at a raw runtime address. PIC: pure pointer arithmetic, no global. # --- # addr: the runtime address to read @@ -100,8 +95,8 @@ pub fun _rt_relocate(envp: *u64) { # walk the program headers (Elf64_Phdr: p_type u32 @0, p_vaddr u64 @16, p_memsz u64 # @40): PT_PHDR=6 yields the load bias (runtime AT_PHDR minus its link-time vaddr), - # PT_DYNAMIC=2 the .dynamic table, PT_GNU_RELRO the region to re-protect after - # relocation. + # PT_DYNAMIC=2 the .dynamic table, PT_GNU_RELRO=0x6474e552 the region to re-protect + # after relocation. type ids are bare literals so this stays position-independent. var bias: u64 = 0; var dyn_va: u64 = 0; var relro_va: u64 = 0; @@ -112,9 +107,9 @@ pub fun _rt_relocate(envp: *u64) { val ph: usize = (at_phdr + p * at_phent)::usize; val p_type: u32 = load32(ph); val p_vaddr: u64 = load64(ph + 16); - if (p_type == 6) { bias = at_phdr - p_vaddr; have_bias = true; } - if (p_type == 2) { dyn_va = p_vaddr; } - if (p_type == PT_GNU_RELRO) { relro_va = p_vaddr; relro_sz = load64(ph + 40); } + if (p_type == 6) { bias = at_phdr - p_vaddr; have_bias = true; } + if (p_type == 2) { dyn_va = p_vaddr; } + if (p_type == 0x6474E552) { relro_va = p_vaddr; relro_sz = load64(ph + 40); } # PT_GNU_RELRO p = p + 1; } if (!have_bias || dyn_va == 0) { ret; } @@ -132,32 +127,36 @@ pub fun _rt_relocate(envp: *u64) { if (tag == 8) { relasz = dval; } d = d + 1; } - if (rela == 0 || relasz == 0) { ret; } - # apply each RELATIVE entry (Elf64_Rela: r_offset u64 @0, r_addend u64 @16, 24-byte - # stride): *(bias + r_offset) = bias + r_addend. - val rela_addr: usize = (rela + bias)::usize; - var off: u64 = 0; - for (off < relasz) { - val ent: usize = rela_addr + off::usize; - val r_offset: u64 = load64(ent); - val r_addend: u64 = load64(ent + 16); - store64((bias + r_offset)::usize, bias + r_addend); - off = off + 24; + # stride): *(bias + r_offset) = bias + r_addend. guarded rather than early-returned so + # the RELRO step below stays independent of reloc presence (they are only coupled by + # the writer, which emits RELRO exclusively for reloc-bearing segments). + if (rela != 0 && relasz != 0) { + val rela_addr: usize = (rela + bias)::usize; + var off: u64 = 0; + for (off < relasz) { + val ent: usize = rela_addr + off::usize; + val r_offset: u64 = load64(ent); + val r_addend: u64 = load64(ent + 16); + store64((bias + r_offset)::usize, bias + r_addend); + off = off + 24; + } } # RELRO: re-protect the region the PIE writer marked PT_GNU_RELRO. it holds relocated # constants (vtables, `val` pointer globals) mapped writable for the pass above; now # that they are slid, mprotect it read-only to restore their hardening before `main`. - # the start is page-aligned by construction and each segment owns its trailing page, - # so the end is rounded up to cover the whole region. best-effort: on a kernel whose - # page exceeds the image's segment alignment the syscall is refused and the region - # stays writable (correct, just unhardened). - if (relro_va != 0 && relro_sz != 0) { - var pagesz: u64 = at_pagesz; - if (pagesz == 0) { pagesz = 4096; } + # the writer emits p_vaddr page-aligned and p_memsz already page-rounded, so this does + # no arithmetic: it gates on the region being a whole number of runtime pages and + # mprotects it exactly. AT_PAGESZ is mandatory on linux; when it is absent, or the + # region is not congruent with the runtime page (a kernel page larger than the writer's + # 4 KiB segment alignment - briar-systems/mach-std#336), the region is left writable + # (correct, just unhardened) rather than risking EINVAL or over-protecting the next + # segment. this best-effort skip is the one large-page degradation. + if (relro_va != 0 && relro_sz != 0 && at_pagesz != 0) { val relro_start: u64 = bias + relro_va; - val relro_end: u64 = (relro_start + relro_sz + (pagesz - 1)) & ~(pagesz - 1); - sys.protect(relro_start::usize::ptr, (relro_end - relro_start)::usize, PROT_READ); + if (relro_start % at_pagesz == 0 && relro_sz % at_pagesz == 0) { + sys.protect(relro_start::usize::ptr, relro_sz::usize, sys.PROT_READ::u32); + } } } diff --git a/src/system/os/linux/shared.mach b/src/system/os/linux/shared.mach index 46f8b25..6396708 100644 --- a/src/system/os/linux/shared.mach +++ b/src/system/os/linux/shared.mach @@ -204,7 +204,9 @@ val SIGCHLD: usize = 17; val MREMAP_MAYMOVE: usize = 1; val PROT_NONE: i32 = 0; -val PROT_READ: i32 = 1; +# the read-only protection, exported for the static-PIE RELRO re-protection in +# std.runtime.linux.reloc. +pub val PROT_READ: i32 = 1; val PROT_WRITE: i32 = 2; val PROT_EXEC: i32 = 4;