Skip to content

feat(runtime/linux): mprotect PT_GNU_RELRO read-only after self-relocation#335

Merged
octalide merged 2 commits into
devfrom
feat/relro-mprotect
Jul 2, 2026
Merged

feat(runtime/linux): mprotect PT_GNU_RELRO read-only after self-relocation#335
octalide merged 2 commits into
devfrom
feat/relro-mprotect

Conversation

@octalide

@octalide octalide commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

What

Runtime half of RELRO hardening for static-PIE (briar-systems/mach#1778). The linker (mach) emits a PT_GNU_RELRO program header over the read-only segment it maps writable for self-relocation (.rodata holding relocated constants — vtables, val pointer globals). This restores that region to read-only after the relocation pass, before main.

_rt_relocate now:

  • captures AT_PAGESZ in the auxv scan and the PT_GNU_RELRO header (p_vaddr, p_memsz) in the program-header walk;
  • after applying every RELATIVE entry, re-protects the region read-only.

The writer emits p_vaddr page-aligned and p_memsz already page-rounded (DATA_SEGMENT_RELRO_END style), so the runtime does no arithmetic: it gates on the region being a whole number of runtime pages (start % AT_PAGESZ == 0 && sz % AT_PAGESZ == 0) and mprotects exactly [start, start+sz) to PROT_READ. On a kernel page larger than the image's 4 KiB segment alignment the gate skips — the region stays writable (correct, just unhardened) rather than risking EINVAL or over-protecting the following segment (mach-std#336). The step runs strictly after relocation, so it uses the ordinary syscall path (std.system.os.linux.shared.protect) — the position-independent constraint only applies to the pre-relocation code above it, and the RELATIVE apply is guarded rather than early-returned so re-protection stays independent of reloc presence.

Verification

Built with the from-source mach linker (which emits PT_GNU_RELRO), across all three no-libc linux arches:

target read relocated const ptr write to its RELRO'd storage
linux (x86_64, native) read=1001 SIGSEGV (exit 139)
linux-arm64 (qemu) read=1001 SIGSEGV (exit 139)
linux-riscv64 (qemu) read=1001 SIGSEGV (exit 139)
  • With this change the relocated constant reads correctly (self-relocation applied) and a write to its .rodata storage faults — proving the region is read-only after startup.
  • With the unmodified runtime the same write succeeds (wrote ok, exit 0) — confirming the fault is the new mprotect.
  • A normal program that reads through relocated pointers and writes only to .data still runs correctly (s=9012, exit 0) on all three arches — RELRO does not touch genuinely-writable data.
  • mach test . (mach-std): 571 ok.

Release ordering

Same flow as the #1727 self-relocation runtime: this lands on mach-std devmain release, then a mach.lock bump in mach activates it and adds the write-fault exec guard to the int harness. The mach linker PR (briar-systems/mach#1811) is independent and already CI-green against the current runtime (the header is emitted; the current runtime simply leaves the region writable).

Follow-up: mach-std#336 (surface AT_PAGESZ to the OS layer, replacing the hardcoded page_size()).

…ation

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.
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.
@octalide

octalide commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Review changes addressed (pushed as fix(runtime/linux): make RELRO mprotect page-exact and reloc-independent, CI green):

1. Rounding math. Moved to the writer: mach now emits PT_GNU_RELRO p_memsz already page-rounded (DATA_SEGMENT_RELRO_END style). _rt_relocate does no arithmetic — it gates on the region being a whole number of runtime pages (relro_start % AT_PAGESZ == 0 && relro_sz % AT_PAGESZ == 0) and mprotects exactly [start, start+sz). On a kernel page larger than the image's 4 KiB segment alignment the gate skips, so there's no EINVAL and no over-protection of the next segment.

2. PT_GNU_RELRO constant + PROT_READ. PT_GNU_RELRO is now the bare literal 0x6474e552 in the pre-relocation phdr walk (matching its 6/2 neighbors, so a codegen change can't silently disable it). PROT_READ is now pub in std.system.os.linux.shared and used from there — no duplicate local.

3. AT_PAGESZ fabrication. Dropped — no ==0 → 4096 guess; hardening is skipped when AT_PAGESZ is absent.

4. Reloc-presence coupling. The RELATIVE apply is now guarded (if rela != 0 && relasz != 0) rather than early-returned, so the re-protection no longer sits behind a reloc-count gate. The only coupling is the writer's (RELRO emitted exclusively for reloc-bearing segments), stated in the comment.

5. mprotect failure policy. Kept best-effort, but the comment now ties the single degradation path explicitly to the large-page gap (mach-std#336). With fix 1 the mprotect only runs when start+size are page-congruent, so the reachable failure surface is near-zero.

6. AT_PAGESZ → OS layer. Filed as mach-std#336 (page_size() hardcodes 4096 with a TODO whose precondition this PR meets); not implemented here.

Re-verified: relocated val/vtable write faults (SIGSEGV) and normal programs run (s=9012) on x86_64/arm64/riscv64; mach test . 571 ok.

@octalide octalide merged commit 47c7b12 into dev Jul 2, 2026
2 checks passed
@octalide octalide deleted the feat/relro-mprotect branch July 2, 2026 05:48
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