Skip to content

An executable's PT_GNU_RELRO is read-only once it has relocated itself - #1237

Merged
Japabu merged 3 commits into
mainfrom
executable-relro-read-only
Nov 12, 2026
Merged

Japabu merged 3 commits into
mainfrom
executable-relro-read-only

Conversation

@Japabu

@Japabu Japabu commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

PT_GNU_RELRO covers what a program's relocations write once and nothing writes after: .data.rel.ro, .dynamic, .got, vtables and tables of function pointers. The SDK's loader took the write away from each library's RELRO once relocated, but an executable's stayed writable for its whole life, so a stray or hijacked store could rewrite a vtable (issues/an-executables-relro-stays-writable.md, which stays open only for its x86-64 boot, owed on metal).

Behavior

toyos::dl::start, which std's and libc's start call once thread-local storage is laid out, makes the executable's whole RELRO pages read-only with SYS_VM_PROTECT after every relocation is applied (its own by toyos::relocate, the rest by the library load where it names one), then runs the libraries' constructors as before. The pages are counted from the executable's header, the first byte of its PT_LOAD at file offset 0. A last partial page stays writable, as for a library. A refusal exits 127 with a line on stderr, as a failed library load does.

The manual's applications page states the requirement: a program seals its own relocated data as the loader does a library's.

Tests

  • Host: an_executables_relro_pages_are_counted_from_its_header, which covers whole and partial pages, no RELRO, and an image whose addresses do not start at 0.
  • Guest relro_sealed: a child stores to a relocated word it has checked lies in a whole RELRO page, and the parent holds that the child ends as Fault::Memory. Only a booted system can show this, because the claim is that the kernel faults a store to a page the program protected. On x86-64 it is a member of the T14's testcases shared metal boot (DRIVEN_AND_SHARED). On AArch64 it is a job of the QEMU job case (virt_jobs_at_el2), which runs because the change is in the shared loader beneath both architectures. No x86-64 QEMU row runs it: shared-boot members run on metal.

Evidence

Round 1's checks ran at 4a9e332e8. Index: r1/index.md.

Host checks, chosen for what the change touches (log):

  • cargo test -p toyos --lib dl: exit 0, 21 passed, an_executables_relro_pages_are_counted_from_its_header among them. This is the SDK module the change is in.
  • cargo test --test toyos-checks: exit 0, 44 passed. suite_split holds the new DRIVEN_AND_SHARED entry. No host check covers the job case's list; the guest run below does.
  • cargo clippy -p toyos --all-targets and cargo clippy -p toyos-build --all-targets, with the adopted lints and -D warnings: exit 0 each.
  • Not run: the full --ci host, because nothing in the build system or harness mechanics changed. The merge queue runs it.

Guest:

  • AArch64 virt_jobs_at_el2 (run 43046428666): success (log). The job case built relro_sealed for AArch64 and printed relro_sealed: a store to a relocated word of PT_GNU_RELRO ended its program as a memory fault. Every other job in that boot also starts through the changed path.
  • Negative control: the same row on executable-relro-read-only-mut (6e1dcac, seal() not called), run 43046434572: red for its reason (log). The holder's assertion fails with "a store to PT_GNU_RELRO did not end the program as a memory fault (exit status: 0)". The storing copy printed "the store to PT_GNU_RELRO landed". Every other job of the case ended with exit 0 and printed its line, including abuse_readonly_copyout, which runs after it.
  • x86-64, at 61007d23a: run 43093182539, queued. No x86-64 guest row runs relro_sealed, so this run instead shows that sealing breaks no program: every boot starts the system's servers through the changed path, and these rows launch many more:
    • libc_children: every C program under tests/guest/libc, plus five Rust ones, each starting children;
    • session_lifecycle and a_launch_gives_each_unsent_stdio_slot_a_null;
    • std_threads_count_the_cpus_and_give_back_ended_threads;
    • a_spawn_from_an_image_frees_it_when_the_child_ends;
    • netstack_holders_exported_to_children;
    • the desktop's the_compositor_drives_the_virtio_gpu and a_window_is_drawn_at_the_scale_and_relaid_at_the_next;
    • console_image_boots.
      hosted_clang_hello is left out. Its clang and LLD would be the largest C++ programs to seal, but the SDK change alters the sysroot the hosted toolchain is keyed on, so the runner would rebuild LLVM first.

Review round 1 (b2172a8d6, then the records-only 61007d23a, index) changes only start's and seal's comments, the applications page and one host test. So the image, and every guest and metal result at 4a9e332e8, is unchanged by it.

  • cargo test -p toyos --lib dl: exit 0, 21 passed (log). an_executables_relro_pages_are_counted_from_its_header gains two cases: a header's segment above another PT_LOAD, and a RELRO below the header, which is refused.
  • Control: executable_relro without the header segment's start subtracted fails the new case (exit 101).
  • cargo clippy -p toyos --all-targets: exit 0.

The head is 5f0467f28, rebased onto main 75bdec3cd, which carries #1259 (r4 index).

Metal is not a landing condition: the benches are down. The record's records-only commit 61007d23a keeps it open with Owed on metal: the T14's testcases shared boot, for one batched boot of main once a bench returns. 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, which is x86-64's verdict;
  • no program saying PT_GNU_RELRO could not be made read-only;
  • no exit 127 that main's last testcases boot before the seal did not have.

The images staged for it at 4a9e332e8 are deleted. Their request records what was asked.

Coordination

map7b's step 1 (#1259) landed first, and this branch is rebased onto it, as above.

Risks

A program that writes its own RELRO after start now faults. Nothing in the tree is known to. Every program starts through this path. On x86-64 only the T14's whole testcases shared boot (every discovered Rust test and the C corpus) would show it, and that boot is owed on metal. Until it runs, x86-64 has only the host tests and the shared SDK code behind it, and AArch64 has the job case.

🤖 Generated with Claude Code

@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1237 at 4a9e332 (round 1)

Architecture

The owner is right. The program seals its own PT_GNU_RELRO in the SDK start path, toyos::dl::start. That is the code that already protects each library's RELRO (dl::protect), and it uses the existing SYS_VM_PROTECT. There is no kernel change and no new mechanism.

executable_relro is a separate function from plan() for a real reason. plan() refuses an image whose addresses do not start at 0, but relocate_self and elf::program::lay_out accept one. Reusing plan() would have refused executables that start today.

Size: about 54 production lines are added (dl +26 net, image +12, the manual +6), and the issue record loses 24. Tests add 33 host lines, an 82-line guest program and 11 harness lines.

What I checked

  • Page alignment. Layout::relro_pages rounds both the start and the end down. The header is at file offset 0, which lies at a page-aligned vaddr, so header + at is page-aligned.

  • Partial pages.

    • The last partial page stays writable, as for a library.
    • The bytes of the first page that lie before RELRO are protected too. That is safe for two reasons. lay_out refuses any PT_LOADs that share a page. And lld opens a PT_LOAD with RELRO, so the bytes before it on that page are padding, not another segment's data.
  • Static and dynamic executables. Both are sealed after every relocation is applied.

    • A static PIE's relocations are applied by relocate_self.
    • A dynamic executable's GOT and template writes are made by load_needed, before seal.
    • Library constructors run after seal.
    • Nothing writes the executable's image later: dl::open passes no executable, and the loader supports no COPY relocations.
  • A refused vm_protect. The program exits 127 with a line on stderr, as a failed library load does. vm_protect is all-or-nothing, so a refusal leaves no partly sealed range. This is the right failure: carrying on silently would be a hidden fallback.

  • Every program start. std's start_rust and libc's start_c both reach dl::start. libc built with std-runtime uses std's _start. The only no_main crates in the tree are the kernel and the loader. Before the seal, the only code that runs after relocate_self is tls::start, eh_frame::init and env::init, and none of them writes RELRO.

  • map7b (A memory object's range is mapped where the caller says, and an immutable one is shared and executable: /system text is ROOT's own pages #1259). There is no semantic conflict.

    • map7b shares only segments that are not writable (Segment::is_file_pages requires !writable).
    • RELRO lives in a writable PT_LOAD, so it stays a private copy that vm_protect may make read-only.
    • map7b does not touch AddressSpace::protect.
    • The textual conflicts are mechanical and fall to map7b's rebase:
      • the args line of tests/images/virtjobcase.toml;
      • in tests/toyos.rs: the tail of DRIVEN_AND_SHARED, the VIRT_* constants, VIRT_JOBS and extra_root_files;
      • the use super::{…} line of dl/image/tests.rs.

    Landing this first is fine.

Required

  1. sdk/toyos/src/dl/mod.rs:423-424: delete "The program does this to itself; one that skipped it would only be without it." The sentence says nothing a reader can use, and the manual paragraph already states the design.
  2. sdk/toyos/src/dl/mod.rs:389-390: start's contract still says only that a program whose libraries cannot be loaded exits 127. It now also exits 127 when its RELRO cannot be protected. Add that case in one clause.

Both are comment-only changes. No check needs to run again for them.

Evidence

  • Host tests. dl 21 tests passed and toyos-checks 44 passed, at the head, as the log shows. These cover the page arithmetic and the harness lists.
  • AArch64 guest. Run 43046428666 is at 4a9e332e8, the head, and is green. It prints the relro_sealed line, and every other job in that boot starts through the changed path. It runs because this is the shared start path beneath both architectures.
  • x86-64. The SDK code is the same Rust on both architectures. The kernel's half (a store to a read-only user page ends as Fault::Memory) is existing behaviour. What only an x86-64 boot can show is that x86-64 binaries link with TABLE in a whole RELRO page and that nothing in the corpus writes its own RELRO. The T14 testcases boot covers both, so it is a landing precondition.

Each pending result must show the following.

  • Negative control, run 43046434572 (still queued):
    • virt_jobs_at_el2 is red only because of the test_rs_relro_sealed holder's assertion "a store to PT_GNU_RELRO did not end the program as a memory fault", with an exit status of 0.
    • The storing copy prints "the store to PT_GNU_RELRO landed".
    • Every other job's line is present.
    • A red for any other reason says nothing about this test.
  • T14 testcases shared boot at 4a9e332:
    • The whole boot is green: every discovered Rust test and every C corpus case.
    • It contains the line relro_sealed: a store to a relocated word of PT_GNU_RELRO ended its program as a memory fault.
    • No program prints "PT_GNU_RELRO could not be made read-only".
    • There is no exit 127 that main's last testcases boot did not have.
    • The testcases-watchdog boot is not needed.

Suggestions

  • docs/src/architecture/applications.md:47-49: the clause "one that did not would gain nothing over any other process" is hard to parse. Consider: "The kernel does not enforce it; a program that skipped it would weaken only itself."
  • sdk/toyos/src/dl/image/tests.rs:307-336: the doc comment says the header's segment need not be where the image's addresses begin, but in every case the header's segment is the lowest. So nothing tests the - first.image().start() subtraction at image/mod.rs:70; a mutation that drops it still passes. Either add a case with a PT_LOAD below the header's segment, or narrow the comment to what the cases do show (addresses that do not start at 0).

SEND BACK. Only the two comment edits are needed. After that, the PR can land once the control and the T14 boot show the results above.

Japabu added a commit that referenced this pull request Nov 11, 2026
…d the header's segment is tested above another

`toyos::dl::start`'s contract now says that a program whose `PT_GNU_RELRO`
cannot be made read-only exits 127 as one whose libraries cannot be loaded
does. `seal`'s comment loses a sentence that told a reader nothing, and the
applications page says plainly that the kernel does not enforce the seal and
that skipping it weakens only the program itself.

`an_executables_relro_pages_are_counted_from_its_header` gains the case its
comment claimed and no case showed: a header's segment that lies above
another `PT_LOAD`, whose RELRO offset is counted from that segment's start,
and a RELRO below the header, which is refused. With the subtraction of the
segment's start dropped from `executable_relro`, the new case fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #1237 at b2172a8 (round 2)

Scope

This round checks only two things: whether round 1's required items are resolved, and whether the code that changed since 4a9e332e8 is correct. The change is b2172a8d6, +17/−6 in three files:

  • sdk/toyos/src/dl/mod.rs: two doc comments;
  • docs/src/architecture/applications.md: one sentence;
  • sdk/toyos/src/dl/image/tests.rs: one new host case, +11 lines.

Round 1's items

  • R1, resolved. sdk/toyos/src/dl/mod.rs:422-424: the empty sentence in seal's comment is gone.
  • R2, resolved. sdk/toyos/src/dl/mod.rs:389-391: start's contract now says that a program whose PT_GNU_RELRO cannot be made read-only exits 127, as one whose libraries cannot be loaded does.
  • Manual suggestion, taken. applications.md:48-49 now reads "The kernel does not enforce it; a program that skipped it would weaken only itself."
  • Test suggestion, taken. See the next section.

The new host case (sdk/toyos/src/dl/image/tests.rs:336-346)

The case moves the header's segment to vaddr 0x1000 (offset 0) and the writable segment to vaddr 0 (offset 0x1000).

  • Layout::parse accepts this. It checks no ordering of PT_LOADs, and the entry (0x500) and PT_DYNAMIC (0x800) stay inside the extent.
  • above(0x1000): relro_pages gives lo = 0x1000, and the header's segment starts at 0x1000, so the result is (0, 0x1000).
  • above(0): the subtraction underflows, and the call is refused with "lies below the executable's header".
  • Without the subtraction, the first case would give (0x1000, 0x1000) and the second Ok(Some((0, 0x1000))). Both assertions catch the mutation.

The RELRO in above(0x1000) covers the header's own read-only segment. No linker emits that, but the case tests only the offset arithmetic, and the comment claims no more. The test's doc comment ("the header's segment need not be where the image's addresses begin") is now true of a case.

The evidence matches the head:

  • r2/host/checks.log was taken on 4a9e332e8 plus a working tree whose diffstat equals b2172a8d6's.
  • cargo test -p toyos --lib dl: 21 passed.
  • Clippy with the adopted lints: exit 0.
  • The control r2/control/header-start-not-subtracted.patch fails at tests.rs:345, Ok(Some((4096, 4096))) against Ok(Some((0, 4096))), with exit 101. The tree was restored clean.

No production code changed

git diff 4a9e332e8 b2172a8d6 touches only:

  • /// lines in dl/mod.rs;
  • one manual paragraph;
  • the test file.

No statement, signature or attribute changed, so the T14 testcases boot queued at 4a9e332e8 remains valid evidence for this head.

Negative control, run 43046434572

I read it with gh run view --log.

  • It ran on executable-relro-read-only-mut at 6e1dcac0f. That commit is 4a9e332e8 with the seal call replaced by let _ = seal;, and nothing else.
  • The run ended failure. The only failing row was virt_jobs_at_el2, for exactly round 1's reason:
    • The storing copy (pid 23) printed relro_sealed: the store to PT_GNU_RELRO landed and exited code=0.
    • The holder (pid 22) panicked at relro_sealed.rs:76 with "a store to PT_GNU_RELRO did not end the program as a memory fault (exit status: 0)", left: Some(Exited(0)), right: Some(Fault(Memory)).
    • The judge reports only the missing relro_sealed: line.
  • The other ten jobs all ended exit=0 with their expected lines. That includes test_rs_abuse_readonly_copyout, which runs after it ("a syscall writes only where its caller could store").

Findings

None.

Landing condition

Round 1's condition stands. The T14 testcases shared boot at 4a9e332e8 must show all of the following:

  • The whole boot is green: every Rust test and every C corpus case.
  • The boot contains relro_sealed: a store to a relocated word of PT_GNU_RELRO ended its program as a memory fault.
  • No program prints "PT_GNU_RELRO could not be made read-only".
  • There is no exit 127 that main's last testcases boot lacked.

LAND, pending the T14 testcases boot above.

@Japabu
Japabu force-pushed the executable-relro-read-only branch from 374c6ca to 61007d2 Compare November 11, 2026 20:01
Japabu and others added 3 commits November 12, 2026 01:44
`PT_GNU_RELRO` covers what a program's relocations write once and nothing
writes after: `.data.rel.ro`, `.dynamic`, `.got`, vtables and tables of
function pointers. The SDK's loader took the write away from a library's
once it was relocated, but an executable's stayed writable for its whole
life, so a stray or hijacked store could rewrite a vtable.

`toyos::dl::start`, which std's and libc's start call once thread-local
storage is laid out, now makes the executable's whole RELRO pages read-only
with `SYS_VM_PROTECT` after every relocation is applied: its own by
`toyos::relocate`, the rest by the library load where it names one. Then the
libraries' constructors run, as before. The pages are counted from the
executable's header, the first byte of its `PT_LOAD` at file offset 0; a
last partial page stays writable, as for a library. A refusal exits 127 with
a line on stderr, as a failed library load does.

`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 x86-64 shared boot's
member and a job of the AArch64 job case.

Closes issues/an-executables-relro-stays-writable.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the header's segment is tested above another

`toyos::dl::start`'s contract now says that a program whose `PT_GNU_RELRO`
cannot be made read-only exits 127 as one whose libraries cannot be loaded
does. `seal`'s comment loses a sentence that told a reader nothing, and the
applications page says plainly that the kernel does not enforce the seal and
that skipping it weakens only the program itself.

`an_executables_relro_pages_are_counted_from_its_header` gains the case its
comment claimed and no case showed: a header's segment that lies above
another `PT_LOAD`, whose RELRO offset is counted from that segment's start,
and a RELRO below the header, which is refused. With the subtraction of the
segment's start dropped from `executable_relro`, the new case fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…etal

`relro_sealed`'s x86-64 verdict is a member of the T14's `testcases`
shared boot, which no guest row runs, and the benches are down. So the record
this change closed stays open: what is built, and the boot it owes, with what
main must show there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu force-pushed the executable-relro-read-only branch from 61007d2 to 5f0467f Compare November 11, 2026 23:47
@Japabu

Japabu commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: rebased onto 75bdec3 at 5f0467f (list-only conflicts, both entries kept; #1237's SDK, guest and doc lines unchanged — verified by the author's line accounting). Ruling on the composition with #1259: no extra run. #1259 maps only non-writable segments from ROOT's pages, RELRO lies in a writable (private) segment, and the layout refuses two segments sharing a page — so the seal never meets a shared page, by construction. #1237 lands on 43093182539 (x86 breadth + aarch64 row) when green.

@Japabu

Japabu commented Nov 12, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: landing under the benches-down rule. Guest run 43093182539 at 61007d2: success (x86-64 breadth rows launching many RELRO-bearing programs, plus the AArch64 row). Head 5f0467f is the same change rebased onto 75bdec3 (list-only conflicts; composition with #1259 holds by construction, ruled above). relro_sealed is owed on metal, as recorded.

@Japabu
Japabu marked this pull request as ready for review November 12, 2026 00:29
@Japabu
Japabu added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit 429f772 Nov 12, 2026
10 checks passed
@Japabu
Japabu deleted the executable-relro-read-only branch November 12, 2026 00:45
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