Skip to content

Parse every ELF value into a type that cannot leave its image (E1-E4, C1) - #544

Merged
Japabu merged 15 commits into
mainfrom
wt/toyos-elffix
Sep 27, 2026
Merged

Japabu merged 15 commits into
mainfrom
wt/toyos-elffix

Conversation

@Japabu

@Japabu Japabu commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

ELF loader and TLS: values checked, types unforgeable

Every ELF-derived value the kernel turns into an address, an offset or a
thread-pointer offset is parsed into a type that cannot leave its image or its
TLS segment; a crafted file is refused, never a kernel panic. Extent,
ImageOffset, TlsOffset and SymIndex are made only by toyos-elf's parses,
and a TlsOffset is bounded only by a TlsSegment that Layout::parse derived.
A thread's TLS block is rebased before it is mapped (C1), and a deterministic
actuator shows the reverse order reachable from userland.

Head: eb0d98c8, with origin/main at 6c9e2cb2 (#547) merged in 945feb6f.

Gates at eb0d98c8 (dev host, TCG; each the command's own exit code)

  • toyos-elf host suite (cargo test in toyos-elf/): EXIT=0.
  • Host workspace (cargo test --workspace --exclude toyos-build): EXIT=0.
  • toyos-build lib, source gates included (cargo test --lib): EXIT=0, 389 passed.
  • Clippy (cargo run -- --clippy, -D warnings, kernel and bootloader on both
    targets with and without actuators): EXIT=0, 10 invocations clean.
  • cargo test --test toyos-build -- abuse_elf_loader: EXIT=0.
  • cargo test --test toyos-build -- tls_rebase_window: EXIT=0. The full tier was not run.

One TPOFF rule

elf::compute_tpoff(r, own_base_offset, own_tls, symbols, tls, tls_info) is the
one S + A - tp rule: unresolved → 0, overflow → TpoffOverflows. Library
callers pass lib.tls(), lib.symbols(); the executable's closure is a single
call with layout.tls(), exe.symbols(). The forwarder resolve_tls is deleted,
and resolve_tls_ref is private to reloc.rs.

abuse_elf_loader reaches the overflow both ways: S + A = 8 + i64::MAX
inside a PT_TLS declared as 0x8000_0000_0000_0010 bytes, so only
S + A - tp leaves an i64. tpoff_overflow_spawn is an executable whose
startup library defines the symbol; tpoff_overflow.so is a dlopen against a
dlopened definer. The harness asserts TpoffOverflows's text beside each file.

Negative control (checked patch on 63a1f315, built with
cargo run -- --build-only EXIT=0, tree clean after): inside compute_tpoff,
tls.tpoff(..).ok_or(RelocError::TpoffOverflows) →
Ok(tls.tpoff(..).unwrap_or(0)). abuse_elf_loader EXIT=1:

  • executable: spawn: /home/abuse_loader/tpoff_overflow_spawn: failed to allocate TLS (9223372036854775824 bytes),
    and the harness: the spawn TPOFF overflow did not fire for its reason;
  • library: tpoff_overflow.so: dlopen loaded an image the loader must refuse.

One symbol-index bound, and no count to get wrong

rela::parse(rela, rules, symbols) takes
the module's SymTab and bounds every r_sym by symbols.count(), the whole
entries its bytes hold.

  • Executable: read_exe_tables reads .dynstr and .dynsym before it parses,
    and passes SymTab::new(&dynsym, &dynstr), the same Vecs
    ExeTables::symbols() resolves against. The file-declared exe_sym_count
    only sizes that read, which read_file_range clamps to the file.
  • Library: load_shared_lib passes the SymTab over its in-image dynsym and
    dynstr slices, which move unchanged into LoadedLib; the re-parse in
    LoadedLib::relocations passes self.symbols(), the call every resolver in
    reloc.rs makes. declared only sizes the dynsym slice, clamped to the
    image, before any SymTab exists. No count-typed value reaches the parse.
  • Bootloader: SymTab::empty(); it binds no symbol.

A lookup by SymIndex is infallible: elf::relocated_symbol asserts it, and a
miss is a kernel bug. What main did on a miss is deleted: the executable's
GLOB_DAT loop skipped the entry silently, and resolve_dlopen_relocs,
resolve_lib_bind_relocs and resolve_dtpmod read its name as "". The dlopen
log line loses its symbol count, which only the deleted count fed.

Host edge: a_symbol_index_is_bounded_by_the_whole_entries_of_its_table, a
table of four whole entries and eight bytes: index 4 is refused as
SymbolPastTable and index 3 accepted, for every symbol-binding type.

Guest edge: globdat_past_dynsym: .gnu.hash counts 1000 symbols, the read
from DT_SYMTAB 0x1400 to the file end 0x4000 is 0x2C00 bytes (469 whole
entries and 8 bytes), and a GLOB_DAT names symbol 469. The harness asserts
SymbolPastTable's text beside the file.

Negative controls (each a checked patch on eb0d98c8, git apply --check
then apply, built with cargo run -- --build-only, reverted with
git apply -R, tree clean after each):

arm patch build toyos-elf host abuse_elf_loader
A SymTab::count: self.syms.len() / ENTRY_SIZE → self.syms.len().div_ceil(ENTRY_SIZE) EXIT=0 EXIT=101 EXIT=1
B rela::parse: symbols.count() → symbols.count() + 1 EXIT=0 EXIT=101 EXIT=1
C the exe_sym_count control: the executable parses against alloc::vec![0u8; exe_sym_count(..)? * sym::ENTRY_SIZE] EXIT=0 not run EXIT=1
  • Host, A: the new edge case (type 6: Ok(Some(.. Bind(SymIndex(4)) ..)),
    expected Err(SymbolPastTable)) and
    a_symbol_index_at_the_edge_of_the_bytes_reads_nothing red;
    a_symbol_index_past_the_table_is_refused_except_for_relative, whose tables
    are whole entries, stays green under A.
  • Host, B: the new edge case and
    a_symbol_index_past_the_table_is_refused_except_for_relative red.
  • Guest, A, B and C alike: the kernel panics at src/elf/mod.rs:266:26 in
    kernel::elf::relocated_symbol from kernel::loader::spawn, and the harness:
    the executable's GLOB_DAT past .dynsym did not fire for its reason.
  • Restored, abuse_elf_loader EXIT=0 at eb0d98c8.

The harness checks every case's reason even when the guest failed, so a case
the guest never reached is red, never green. Above, only the cases each
mutation targets are claimed. The spawn-before-dlopen order of the two TPOFF
cases is held by nothing structural; a reorder moves attribution, never a red.

C1: a deterministic control

tls-rebase-window (actuator) acts inside TlsBlock::publish's fix closure,
before rebase, so a mutation of the ordering carries it along. It watches only
a thread spawn whose argument is loader::rebase_window::MARK.

  • If the block is present in the running address space there, the spawn holds
    until DTV slot 0 differs from what build_combined wrote. A 10 s bound ends
    the hold with a loud assert.
  • If the block is not present, the kernel logs that and proceeds.

tls_rebase_window boots it with two CPUs and runs tls_dtv_race. It requires
one "not reachable before its rebase" line for each of the 15 watched spawns.
tls_dtv_race is in RUST_SKIP, so the shared boot does not run it.

tls_dtv_race waits, bounded, for the sibling to engage in every round. The
first round places the block and is not watched. thread_join's result is
asserted 0.

Negative control (checked patch on 63a1f315, build EXIT=0, tree clean
after): in Unpublished::publish, fix(&self, at) moves after
space.map_range(..) and drop(space). tls_rebase_window EXIT=1, red
again when the harness re-ran it alone:

tls-rebase-window: pid 7 block at 0xfffde00000 was reachable before its rebase, and a store landed in it
PANIC: panicked at src/loader/tls.rs:143:32:   (rebase's `p - phys`)

The cross-module TLS refusal is reached

tls_defs_so has a PT_TLS of memsz: 0x20 and tls_refs_so uses addend
0x140, so S + A is 0x148. The pair uses its own symbol name (ytls):
dlclose unloads nothing, so f13's 0x200-byte xtls stays loaded and would
otherwise be the module resolved. check_abuse_elf_loader asserts TLS relocation names an offset outside its PT_TLS beside f13_refs_past.so.

Negative control (checked patch on 63a1f315, build EXIT=0, tree clean
after): the cross-module refusal replaced by return Ok(None) (unresolved).
abuse_elf_loader EXIT=1: f13_refs_past.so: dlopen loaded an image the loader must refuse.

A TlsOffset is bounded by the parse's TlsSegment

  • TlsOffset::of(value, addend, TlsSegment) is crate-private.
  • Sym::tls_offset(addend, TlsSegment), Rules.tls: Option<TlsSegment> and
    SymTab::bounded(extent, Option<TlsSegment>).
  • The kernel keeps the segment it was given, in LoadedLib's rules
    (LoadedLib::tls). TlsSegment::occupied (in toyos-elf, pinned by
    only_a_tls_segment_with_bytes_is_occupied) answers whether a PT_TLS is
    given a module.
  • defining_module returns the defining module's TlsSegment, and
    resolve_tls_ref takes the referencing module's.
  • Tests name a segment only through
    Layout::parse (tests/common::tls_segment).

Symbols

  • ExeTables::symbol and symtab_file_off are deleted. The executable's
    GLOB_DAT and TLS lookups go through exe.symbols().
  • address_of answers None for an undefined or STT_TLS symbol. An extent
    miss for a defined one panics in #[cold] bounded_symbol_outside, beside
    reparse_refused.

Oracle: the recorded base failure

The independent oracle is the base kernel failing on these inputs. This
branch's abuse_elf_loader ran on a kernel built at the merge base 16d2e64,
one values_are_bounded_by_the_image case per boot. Each case panicked the base
kernel with a named overflow, and each is refused at head.

case syscall base-kernel panic
relative_addend_past_image spawn loader/mod.rs:432:39 add overflow, kernel::loader::spawn
tpoff_addend_past_tls spawn toyos-elf/src/tls.rs:135:28 sub overflow, kernel::loader::exe_tpoff
export_past_image spawn mm/mod.rs:84:43 add overflow, loader::symbols::map (dynamic_map)
so_relative_addend_past_image dlopen elf/mod.rs:429:25 add overflow
so_tpoff_addend_past_tls dlopen toyos-elf/src/tls.rs:135:28 sub overflow, elf::reloc::apply_tpoff_relocs
so_init_array_past_image dlopen syscall/vm.rs:301:44 add overflow
so_export_past_image dlopen/dlsym mm/mod.rs:84:43 add overflow

Unsure

  • Every guest here is TCG. The actuator makes the C1 control independent of the
    window's width, but no KVM shard has run it.

Lines

git diff --shortstat origin/main...HEAD: +2713 / −973 across 31 files.

part added removed net
production (kernel/ bootloader/ toyos-elf/src/ src/) +1510 −888 +622
tests (toyos-elf/tests/ tests/) +1155 −85 +1070
issues +48 +48

🤖 Generated with Claude Code

Japabu and others added 2 commits September 27, 2026 16:15
Every number the kernel's loader takes from an ELF file now reaches it
only through toyos-elf types with private fields, each made by a parse
that checks it against the image or TLS segment it must name:

- `Rela` exposes only its kind; `rela::parse` against `Rules` yields a
  `Reloc` whose `RELATIVE` value is an `ImageOffset` inside the image,
  whose symbol is a `SymIndex` below the table, and whose `r_sym == 0`
  TLS offset is a `TlsOffset` inside PT_TLS (E1, E2). `validate` is gone.
- `Sym::address` and `Sym::tls_offset` are the only ways to read
  `st_value`; `SymTab::bounded` refuses a library any defined symbol of
  which names nothing in it (E3).
- `InitArray::parse` bounds DT_INIT_ARRAY to whole pointers inside the
  image (E4).
- `Static::tpoff` is checked and answers `None`; TPOFF32 refuses a value
  its 32 bits cannot hold.
- `Layout` hands out `Extent`, `ImageOffset` and `ImageRange` instead of
  public raw vaddrs.

The kernel consumes those types: RELATIVE slots, symbol addresses, the
init array and every TPOFF/DTPOFF are image start plus a checked offset,
and a library's rebase recomputes each RELATIVE slot from its parsed
target rather than reading the slot back. `rela::tables_outside_window`
rounds the window out to the page, so the tables a library's loader
parses again after mapping sit on no page the process can write.

C1: a thread's TLS block is built in `Unpublished` frames and rebased
inside `Unpublished::publish`, which picks the address and maps it under
one address-space lock hold; the DTV walk is bounded by
DTV_INITIAL_CAPACITY, never by the block's length word.

A seeded host fuzzer over the value path (toyos-elf/tests/fuzz.rs)
drives 1,000,000 image loads per run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
abuse_elf_loader gains the value cases its own header said it lacked:
a RELATIVE addend, a TPOFF addend, a defined symbol's st_value and a
DT_INIT_ARRAY set to an address no image holds, spawned and dlopened
from a writable mount. Each was a kernel overflow panic; each is now an
InvalidArgument return, and the heap walk and real spawn at the end of
main assert the machine lived. f13's defining TLS segment grows to hold
its own addend, which the new bound would otherwise refuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014iqcj4jDKpaiDX8B7CMvmK
@Japabu
Japabu marked this pull request as ready for review September 27, 2026 14:25
@Japabu

Japabu commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #544 at 11a850d (merge base 16d2e64)

Gate. PR CI: abi-split passed and host was skipped. host runs only in the merge queue, by design. The body gives exit codes for the toyos-elf suite (EXIT=0), the guest abuse_elf_loader (PASS) and --build-only (EXIT=0), but no logs. The branch does not target hardware. I reviewed it.

Lines (git diff --shortstat origin/main...HEAD): +2198/−875 in 25 files. Production is +1427/−802 (net +625). Tests are +771/−73 (net +698).

Merge. git merge-tree is clean against origin/main a637f5c. It is clean against #543 (e612c1f), and clean on top of main+#543. #543 keeps alloc_region(size: u64, kind) and changes only its body, so Unpublished::publish compiles against it. The two branches touch different vm.rs hunks (mmap vs dlopen/query_modules). No semantic overlap.

Answers to the brief

  1. Raw ELF values into kernel arithmetic. I traced every consumer: the loader, dlopen, TLS, dlsym, query_modules, the bootloader and build.rs. No ELF-derived value still reaches unchecked arithmetic. Some raw integers remain: Reloc::offset, TlsSegment::memsz, Dynamic's pub fields, Segment::filesz/file_offset and ImageRange::len. Each of them reaches either checked arithmetic or a read bounded by the file or the allocation. UserAddr + off.get() is sound only by the span argument, not by type (stage H).
  2. Unforgeable types. No. See B4.
  3. C1. Structural, yes. publish reserves the VA, runs fix and maps, all under one pt hold. A sibling's fault on that range spins on the same lock. After mapping, the kernel only writes to the block (TCB_TID, tls_alloc_block's DTV slot) and never reads it. The DTV walk is bounded by DTV_INITIAL_CAPACITY. But no test can fail on it (B2).
  4. Controls and oracle. Not independent enough (B1). e1–e4 are partial reverts on the branch, measured by the author's own fuzzer. That fuzzer re-implements the kernel's call sequence, and it models the exe with sym_count = symbols.count() where the kernel passes usize::MAX. The llvm-readobj differential is an independent check of acceptance, but the body gives no command, exit code or log for it.
  5. Lines. See the NOTEs. They name about 60–80 lines of production code that could go.
  6. Merge. Clean, as above.

BLOCKER

  • B1. Negative control missing (CLAUDE.md: a negative control reverts the whole change onto the base).
    • toyos-elf/tests/fuzz.rs cannot compile on 16d2e64, so it cannot be red there. e1–e4 each revert one fix on this branch.
    • Run this branch's tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs against a kernel built at 16d2e64. Show each of the 7 new cases in values_are_bounded_by_the_image red, with the kernel panic named. Show it green at 11a850d. Put command, exit code and log in the body.
  • B2. kernel/src/process.rs:166-168: C1's claim has no test that can fail.
    • Patch: move fix(&self, at); after space.map_range(..) and after drop(space). Every test stays green.
    • With that patch, a sibling that stores 0 into a DTV slot panics the kernel at loader/tls.rs rebase's p - phys.
    • Needed: a guest test where sibling threads probe with a syscall that answers BadAddress while the range is unmapped. When the next thread's TLS block appears, they store 0 and u64::MAX into its DTV length word and slots, while the main thread loops spawn_thread. It then asserts the kernel is alive (heap walk or canary) and the new threads' TP/DTV are self-consistent.
    • It must be red under the patch or on 16d2e64, and green at head. If the window cannot be hit, report the measured attempt count.
  • B3. The apply-time TLS refusals are new code that no test reaches: kernel/src/syscall/vm.rs:297-308 (dlopen) and kernel/src/loader/mod.rs:552-557 (spawn).
    • Each guest TLS case uses r_sym == 0, which rela::parse already refuses, so neither path is reached.
    • Mutation: let refused = None; at vm.rs:297 stays green.
    • The f13 fixture was widened from memsz: 0x20 to 0x200 (abuse_elf_loader.rs:828) exactly because this refusal now fires.
    • Keep the 0x20 variant as a dlopen_refused case. Add an exe with a defined STT_TLS symbol and a TPOFF64 whose S + A passes its PT_TLS as a spawn_refused case. Show the mutation red.
  • B4. The types are forgeable, which falsifies the design's "only constructor bounds them".
    • Extent::new (toyos-elf/src/layout.rs:27) and TlsOffset::of (toyos-elf/src/tls.rs:159) are pub and take the bound as an argument. Extent::new(0, u64::MAX)?.offset(raw) and TlsOffset::of(raw, 0, u64::MAX) launder any file number into the type the kernel adds without a check. tests/tables.rs:33 and tests/tls.rs:16 do exactly that.
    • DynamicSegment has pub fields (layout.rs:218).
    • Make them pub(crate), and build test extents through Layout::parse of a crafted image.

NOTE

  • kernel/src/loader/mod.rs:283: sym_count: usize::MAX makes the exe's SymIndex ("below the table") vacuous. Either bound it or say so on SymIndex. The fuzzer does not model this path.
  • kernel/src/elf/reloc.rs:53-92: bind_entries, tls_entries and dtpmod_entries are three copies of one cached-or-scanned combinator. tls_entries, made generic over T, serves all three.
  • kernel/src/loader/mod.rs:848-879: exe_tpoff is a sibling of elf::reloc::resolve_tls + compute_tpoff. This predates the branch, but the branch just merged the lib side into one function. Fold the exe side in behind a symbol-lookup closure.
  • Several helpers have one caller or duplicate an existing one: loader/tls.rs:163 map_block (one caller), rela.rs:225 TlsSymRef::offset_in (forwards to Sym::tls_offset) and layout.rs:442 Layout::tls_memsz (tls().map(memsz)). The layout.rs:279 RawLoad second pass could be a first pass over phdrs for the extent.
  • kernel/src/process.rs:163: Unpublished::publish re-implements alloc_and_map (alloc_region + map_range) without its phys-alignment assert. Build one on the other.
  • kernel/src/elf/mod.rs:427 vs :246: SymTab::bounded and the Option in address_of check the same fact twice. After bounded, a None is a silent "unresolved". Keep one.
  • Guest cases assert only "refused", never the reason. tls_memsz: Some(u64::MAX) at loader/mod.rs:285 or elf/mod.rs:461 stays green: spawn is refused later, by tpoff, for another reason, and a lib's DTPOFF64 with r_sym == 0 is accepted with an unbounded value.
  • toyos-elf/src/rela.rs:491: page has no power-of-two debug_assert, unlike Segment::page_range.
  • Two compromises the branch knows about are not filed in issues/: the deferred domain lint line, and M8 (dlopen writes relocations after map_into, so write_at's "sole writer" # Safety is false there).
  • The body gives no logs. It also does not report the host workspace suite, clippy or the fuzzer's runtime at this head, and CI runs those only in the merge queue.

REMOVE

  • kernel/src/process.rs:140-141: "Nothing is written into the frames with a mapping in place". False: spawn_thread stores TCB_TID and tls_alloc_block stores DTV slots into the mapped block.
  • kernel/src/syscall/vm.rs:432-433: cites setup_tls/setup_combined_tls, which this branch deletes.
  • toyos-elf/src/rela.rs:233-234: "Parsed ahead of the first write…". False for load_shared_lib, which parses and writes entry by entry.
  • toyos-elf/src/sym.rs:7-8: history ("was a kernel panic away").
  • toyos-elf/tests/fuzz.rs:5-6: history ("four kernel panics lived…").
  • tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs:713: history ("Refused at load now; before…").
  • PR body: "Net +1,719/−529". False; the measured diff is +2198/−875.

SEND BACK

Japabu and others added 2 commits September 27, 2026 16:39
…eable types

Answers the round-1 review of the ELF-loader value-checking work.

BLOCKERs

- B1 negative control. `abuse_elf_loader`'s seven `values_are_bounded_by_the_image`
  cases were run one at a time against a kernel built at the merge base 16d2e64,
  each isolated in a throwaway worktree, and each panics the base kernel with the
  named overflow; all seven are refused at head. Logs in the PR body.
- B2. `tls_dtv_race` builds the C1 race: one worker at a time on a reused stack, so
  the kernel's TLS block is reused at one virtual address; a sibling probes that
  address with `random` (BadAddress while unmapped) and, once mapped, hammers 0
  into DTV slot 0. Green at head; with the review's mutation (`fix` after
  `map_range` and `drop(space)`) the kernel panics in `loader/tls.rs` `rebase`'s
  `p - phys`. A store is never in flight against a freeing block: an acknowledged
  pause stands the sibling down before every retire.
- B3. The apply-time TLS refusals are reached: a `dlopen`ed and an executable
  fixture, each with its own defined `STT_TLS` symbol and a `TPOFF64` whose
  `S + A` leaves its `PT_TLS`. Both are refused, and the harness asserts the
  reason (`TLS relocation names an offset outside its PT_TLS`) is named beside the
  file, not merely that the load was refused.
- B4. `Extent::new`, `TlsOffset::of` and `DynamicSegment`'s fields are
  `pub(crate)`; `DynamicSegment` grows `file_offset()`/`image()` readers. No
  `ImageOffset`/`TlsOffset`/`Extent` can be forged outside `toyos-elf`. Tests
  build extents through `Layout::parse` of crafted images and TLS offsets through
  `Sym::tls_offset`.

Simplifications

- One generic `tls_entries<T>` in `elf::reloc` replaces the three cached-or-scanned
  copies; `bind`/`dtpmod` are one-line wrappers over it.
- `exe_tpoff` folds into the shared `resolve_tls_ref`, which resolves a library's
  in-image symbols and the executable's file-backed symbols alike.
- `map_block`, `TlsSymRef::offset_in` and `RawLoad`'s second pass are gone;
  `TlsSymRef::addend` and a first pass over the phdrs replace them.
- The executable's relocations parse against a real `sym_count`, not `usize::MAX`,
  so `SymIndex` bounds them.
- `Unpublished::publish` keeps `alloc_and_map`'s 2 MiB-alignment assert and says
  why it cannot reuse it (the `fix` runs between the reserve and the map).

REMOVEs applied; the two compromises (the deferred `toyos-elf` domain lint, and
M8's `write_at` `# Safety` in the `dlopen` path) are filed in `issues/`.

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

Japabu commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #544, round 2, at 74ed442 (merge base a637f5c; main is now c9ed012)

Gate. PR CI at 74ed442: abi-split passed and host was skipped (it runs only in the merge queue). The body gives EXIT=0 for the toyos-elf suite, the host workspace, clippy, abuse_elf_loader and tls_dtv_race. The branch does not target hardware. I reviewed it.

Lines (git diff --shortstat origin/main...HEAD): +2590/−899 in 30 files. Production (kernel/ bootloader/ toyos-elf/src/ src/) is +1450/−826 (net +624). Tests are +1091/−73. Issues are +49.

Round-1 BLOCKERs

  • B1: CLOSED. The body gives a named panic for each of the 7 cases on a 16d2e64 kernel, and EXIT=0 at head. The command lines are missing (see NOTE).
  • B2: OPEN. By the body's own measurement, the mutation is red only beside other guests, and green when run alone. See B2 below.
  • B3: OPEN in part. The own-module arms are reached, and the vm.rs:297 mutation goes red. The cross-module arm is still reached by nothing. Round 1 asked for the 0x20 f13 variant as a dlopen_refused case, and it is not there. See B3 below.
  • B4: OPEN for TlsOffset. It is closed for Extent and DynamicSegment. TlsOffset can still be minted from a bound the caller hands in. See B4 below.

Answers to the brief

  1. Blockers. B1 is closed. B2, B3 and B4 are open.
  2. The race test. It is not an acceptable negative control.
    • It is red only under host contention, and alone it is green under the mutation. The harness's own alone_line calls "fails only beside other guests" a Sched classification defect.
    • The red was measured once, on TCG. CI's guest shards run KVM, where the map-to-rebase window is far narrower. No run shows it red there.
    • It passes vacuously when the sibling never engages, because nothing asserts ENGAGED.
    • At head, its 60 s bound has no measured wall time behind it.
    • A deterministic way exists, and the tree already has its pattern: copy-meets-a-remap (kernel/src/user_ptr.rs remap_race::hold). That actuator holds the kernel at the racy point until the user-side event lands, bounded by a loud assert.
  3. The fixture. It is not this branch's. It landed in e2c6a06, and the only change here is a 2-line accessor rename in real.rs. real.rs records how it was made (dd of the first 4 KiB of a toyos-ld-linked shell, as a refresh step, not run by any test). As a committed fixture it is legitimate. It is not an oracle for this branch's claims:
    • It holds program headers only: no .rela, no .dynsym, no TLS relocations.
    • It is a toyos-ld artifact, while userland now links with rust-lld. Its header's "the shape the loader actually meets at every boot" is stale. That line predates this branch.
    • The independent oracle this branch has is the B1 recorded base failure. The body's Oracle paragraph overstates the fixture (REMOVE).
  4. The kept NOTEs.
    • Layout::tls_memsz: the count holds (4 production callers and fuzz.rs). But the closed fix for B4 deletes it.
    • address_of: the reason does not hold. gnu_hash::lookup never returns index 0, and bounded covers every defined non-TLS symbol. So the extent arm of the None is reachable only through a kernel bug, which it turns into a silent "not found" (B6).
  5. Size. B5 deletes ExeTables::symbol and symtab_file_off. B4's fix deletes Layout::tls_memsz. The NOTEs delete exe_tpoff's #[allow] wrapper, the dead heap walk and the copied reason literal.
  6. Merge. kernel: refuse an unplaceable mmap length at the boundary, and place only a PageSpan #543 has landed as c9ed012. git merge-tree --write-tree origin/main HEAD is clean (EXIT=0).
    • alloc_region(size: u64, kind) is unchanged.
    • align_2m_checked moved to toyos_userbound and takes a u64. The merged elf/mod.rs:350-351 pass it layout.span() and rw_hi, both u64, so the types line up.
    • The composition has not been built. The merge queue's host builds it, and no guest runs on it.

BLOCKER

  • B2 tests/toyos-rust-tests/src/bin/tls_dtv_race.rs: the test is red under the round-1 mutation only when neighbouring guests perturb its timing. That is a test that cannot fail on C1 when scheduled alone, or on KVM as far as anything measured shows.
    • Needed: a deterministic control, on the remap_race::hold pattern. Add an actuator in TlsBlock::publish's fix closure, before rebase, so that the round-1 patch carries it along.
      • When at is reachable from user mode, it holds until DTV slot 0, read through frames.ptr(), differs from what build_combined wrote, bounded by a loud assert.
      • When at is not reachable, it logs that and proceeds, and the harness asserts that line.
      • This makes the head green on every run and the mutation red on every run, including ALONE, with no flat wait.
    • Also required: assert!(ENGAGED > 0) (ideally engaged in most rounds), and the head's wall time, alone and in the shared boot, against the 60 s bound.
    • The body's head log (800 rounds, kernel alive, TP/DTV consistent) is not what tls_dtv_race.rs:193-196 prints, which includes {} engaged. So no measurement of this source's engagement exists.
  • B3 kernel/src/elf/reloc.rs:301: the cross-module refusal is reached by no test.
    • Patch: defined.tls_offset(s.addend(), module.memsz as u64) → defined.tls_offset(s.addend(), u64::MAX). I expect every test to stay green.
    • Add the f13 pair with tls_defs_so's PT_TLS at memsz: 0x20 and addend 0x140 as a dlopen_refused case, with its reason asserted in check_abuse_elf_loader. Show that patch red.
  • B4 toyos-elf/src/sym.rs:86, toyos-elf/src/rela.rs:157: Sym::tls_offset(addend, memsz: u64) and Rules { tls_memsz: Option<u64> } take the bound from the caller.
    • sym::parse_at(&crafted, 0)?.tls_offset(a, u64::MAX) launders any file number into a TlsOffset, and tests/tls.rs:17-27 and tests/tables.rs:37 do exactly that.
    • In the kernel, resolve_tls_ref(own_memsz: Option<u64>) and module.memsz as u64 let a wrong module's size type-check.
    • Fix: bound by the parse's TlsSegment: Sym::tls_offset(addend, TlsSegment), Rules.tls: Option<TlsSegment> and bounded(extent, layout.tls()). The kernel keeps the TlsSegment it was given. Layout::tls_memsz goes.
  • B5 kernel/src/loader/mod.rs:202-207: ExeTables::symbol re-reads a symbol off the file per relocation, and that read is now a second path.
    • The doc's premise ("the index may exceed the .dynsym length the loader estimated") is gone: SymIndex is now bounded by the same sym_count that sized dynsym.
    • Delete symbol and symtab_file_off (:189). Look up through exe.symbols().get(i.get()) at :482 and :863.
  • B6 kernel/src/elf/mod.rs:247-249: address_of returns None for a defined non-TLS symbol outside the extent. That hides exactly the kernel bug the author says it guards against.
    • Keep the None for undefined and STT_TLS. Make the extent miss a #[cold] panic beside reparse_refused (:256), which already handles the same class of bug loudly.

NOTE

  • PR body, B1: the command that built 16d2e64 and isolated each case is not given.
  • kernel/src/elf/reloc.rs:52: tls_entries now also serves bind_entries. Rename it to entries.
  • kernel/src/elf/reloc.rs:312: the free fn resolve_tls shares a name with LoadedLib::resolve_tls (elf/mod.rs:241), which does something else.
  • kernel/src/loader/mod.rs:849-871: exe_tpoff is now resolve_tls_ref + tpoff behind 7 parameters and #[allow(clippy::too_many_arguments)]. Fold it into its closure at :833 and delete the allow.
  • tests/toyos.rs:3505: TLS_OUTSIDE_SEGMENT copies RelocError::TlsOutsideSegment.as_str(). toyos-build already depends on toyos-elf, so import it.
  • tests/toyos-rust-tests/src/bin/tls_dtv_race.rs:177-181: rounds % 4096 == 0 never holds at ROUNDS = 800. The heap walk is dead, so delete it.
  • tests/toyos-rust-tests/src/bin/tls_dtv_race.rs:174: nobody reads thread_join(tid)'s result.
  • tests/toyos-rust-tests/src/bin/tls_dtv_race.rs:132-135: on non-x86_64 the test prints "skipping" and passes. Read TPIDR_EL0 (variant I: TP+0 is the DTV pointer), or do not register it there.
  • toyos-elf/src/layout.rs:220-221: DynamicSegment's fields are pub(crate) with no crate reader outside layout.rs, so make them private.
  • issues/design-debt/elf-domain-lint-line-not-yet-added.md, issues/kernel/dlopen-write-at-is-not-the-sole-writer-of-a-mapped-module.md: both are status: open and name no owner. A recorded compromise needs one.

REMOVE

  • tests/toyos.rs:18518-18519: "Twenty thousand spawn/retire rounds". False; ROUNDS = 800.
  • tests/toyos-rust-tests/src/bin/tls_dtv_race.rs:20-22: "and the heap still walks". False; that code never runs.
  • tests/toyos-rust-tests/src/bin/tls_dtv_race.rs:127-128: "under the mutation the panic comes in the first rounds". Unmeasured, and contradicted by the green run alone.
  • tests/toyos-rust-tests/src/bin/tls_dtv_race.rs:4-9: "At head … With the review's mutation". This is review chronology in source.
  • toyos-elf/src/tls.rs:159-160: "never a bound a caller hands in". False: Sym::tls_offset takes memsz from the caller.
  • kernel/src/loader/mod.rs:846-848: "The executable's symbols are read off the file, but …". Narration that B5 makes false.
  • issues/design-debt/elf-domain-lint-line-not-yet-added.md: "the design stages that as E1" (no such design is in the tree), "About 100 existing sites" (unmeasured count), and "Parse every ELF value into a type that cannot leave its image (E1-E4, C1) #544 wrote its new code lint-clean but did not add the line" (chronology).
  • issues/kernel/dlopen-write-at-is-not-the-sole-writer-of-a-mapped-module.md: "This predates the value-checking work in Parse every ELF value into a type that cannot leave its image (E1-E4, C1) #544 and was noted there rather than fixed." Chronology.
  • PR body, Oracle: "The independent oracle is the committed positive-control fixture". It is a pre-existing, headers-only fixture that exercises none of this branch's refusals.
  • PR body, B2: "a real race … so it must run beside other guests, never alone". The harness classifies exactly that as a defect.

SEND BACK

Japabu and others added 2 commits September 27, 2026 18:14
…by the parse

BLOCKERs

- B2. `tls-rebase-window` (new actuator): a thread spawn whose argument is
  `loader::rebase_window::MARK` is watched inside `TlsBlock::publish`'s `fix`,
  before `rebase`. If the block is present in the running address space there,
  it holds until DTV slot 0 differs from what `build_combined` wrote (10 s bound,
  loud assert); if not, it logs that and proceeds. `tls_rebase_window` boots it
  with two CPUs and requires one "not reachable" line per watched spawn (15).
  `tls_dtv_race` now waits for the sibling to engage every round (bounded), so
  engagement is asserted rather than hoped for; the first round places the block
  and the rest are watched. The round-1 mutation (`fix` after `map_range` and
  `drop(space)`) is red on three of three runs, wide and alone, with
  `rebase`'s `p - phys` underflow.
- B3. The f13 pair as a `dlopen_refused` case: `tls_defs_so` at `memsz: 0x20`
  and `tls_refs_so` at addend 0x140, under their own symbol name because
  `dlclose` unloads nothing and f13's `xtls` would be resolved first. The
  harness asserts its reason beside `f13_refs_past.so`.
- B4. `TlsOffset::of` takes a `TlsSegment`; `Sym::tls_offset(addend, TlsSegment)`,
  `Rules.tls: Option<TlsSegment>` and `SymTab::bounded(extent, Option<TlsSegment>)`.
  `LoadedLib` keeps the segment in its rules (`LoadedLib::tls`) instead of
  `tls_memsz`/`tls_align`, and `defining_module` hands back the defining
  module's segment. `Layout::tls_memsz` is deleted; tests name a segment through
  `Layout::parse` (`common::tls_segment`).
- B5. `ExeTables::symbol` and `symtab_file_off` are deleted; the executable's
  relocations look up through `exe.symbols().get(i)`, and `resolve_tls_ref`
  takes a `SymTab`.
- B6. `address_of` returns `None` for an undefined or `STT_TLS` symbol and
  panics (`#[cold]` `bounded_symbol_outside`, beside `reparse_refused`) on an
  extent miss for a defined one.

NOTEs

- `tls_entries` is `entries`; `LoadedLib::resolve_tls` is gone (its one caller
  asks `symbols().find_tls`), so the free `resolve_tls` has no namesake.
- `exe_tpoff` is folded into its closure; both `too_many_arguments` allows go.
- The harness imports `RelocError::TlsOutsideSegment.as_str()`.
- `tls_dtv_race` drops the dead heap walk, asserts `thread_join`'s 0, and reads
  no thread pointer: the block base is a thread-local's address, so it runs on
  both variants with no asm.
- `DynamicSegment`'s fields are private.
- Both new issues are assigned to the ELF-loader track.

REMOVEs applied as deletions.

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

Japabu commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #544, round 3, at 19c5b9e (origin/main c9ed012, merged in 5602993)

Gate. PR CI at 19c5b9e: abi-split passed and host was skipped (it runs only in the merge queue). The body gives EXIT=0 for the toyos-elf suite, the host workspace, toyos-build --lib, clippy, abuse_elf_loader, tls_rebase_window (5 of 5 runs) and tls_dtv_race. The branch does not target hardware. I ran no test and no build: every verdict below rests on the body's measurements and on reading the tree.

Lines (git diff --shortstat origin/main...HEAD): +2652/−953 in 31 files.

  • Production (kernel/ bootloader/ toyos-elf/src/ src/): +1512/−871, net +641.
  • Tests (tests/ toyos-elf/tests/): +1092/−82, net +1010.
  • Issues: +48.
  • This round alone (5602993..19c5b9e): production +176/−159 (net +17), tests +149/−157, issues +8/−9.

The production growth is the parsed types. I accept it once the one deletion under BLOCKER below is made.

Earlier BLOCKERs

  • B1: CLOSED. It stands on the recorded panics: seven base-kernel overflows at 16d2e64, each named by file:line, and abuse_elf_loader EXIT=0 at head. The reconstructed command block does not measure anything (see REMOVE). Nothing since round 2 moved the red arm.
  • B2: CLOSED. Evidence:
    • The head arm: tls_rebase_window EXIT=0 on 5 of 5 runs, 4 s against a 30 s bound, and the harness requires exactly 15 "not reachable" lines.
    • The mutation arm: EXIT=1 on 3 of 3 runs, each red again when run alone. Each logged "a store landed" and then panicked on p - phys at tls.rs:143, which is rebase's line at head.
  • B3: CLOSED. The return Ok(None) mutation at head is EXIT=1, with f13_refs_past.so: dlopen loaded an image the loader must refuse. check_abuse_elf_loader asserts the reason beside the file. Two further mutations would also go red:
    • Bounding against the wrong module's segment: f13's 0x200-byte xtls stays loaded, and a separate symbol name keeps it out of the lookup.
    • Bounding against the referencing module's own segment: the f13 positive case would be refused.
  • B4: CLOSED. TlsOffset::of is pub(crate) and takes a TlsSegment. That segment's only constructor is Layout::parse (layout.rs:395). Layout::tls_memsz and LoadedLib::{tls_memsz, tls_align} are gone.
  • B5: CLOSED. ExeTables::symbol and symtab_file_off are deleted. Both lookups go through exe.symbols().
  • B6: CLOSED. address_of returns None only for an undefined or STT_TLS symbol, and an extent miss panics in bounded_symbol_outside. I checked that this panic cannot be reached from userland:
    • .dynsym and .dynstr are refused on any page of the writable window (rela::tables_outside_window).
    • There is no mprotect, so the bytes bounded checked cannot move.

Answers to the brief

  1. The actuator forces the interleaving, in both arms. Neither arm depends on timing.
    • Head arm: the check is a page-table walk (present_in_current_tables(at)) at entry to fix. At head it always answers "not reachable", and the harness requires that 15 times. The sibling's behaviour does not enter the kernel's verdict.
    • Mutation arm: hold waits for the store itself, bounded by a loud 10 s assert. If the store never comes, the kernel still panics, so the arm is red either way.
    • No machine can pass it with the window closed under the mutation. The mutation that splits only the lock hold (fix before map, lock dropped between them) is not a defect: a fault in a Mapped region with no page is refused (process.rs:1355), and no page table entry exists before map_range.
  2. Mutations.
    • B2's patch counts. It reverts the whole of C1's ordering, map and lock drop before the rebase, which is what base 16d2e64 did (rebase_block after vma_map). It is applied onto the head's tls.rs, where the green arm was measured, and the panic line matches head.
    • B3's Ok(None) at head counts.
    • B3's u64::MAX run was measured before B4, on a different tree from the green arm, so it is not a control for this head (REMOVE).
    • The whole-change control for the branch is B1: the base kernel against this branch's cases.
  3. B1's command lines. They may not stay. A command nobody ran, presented next to a measurement, tells a reader it reproduces that measurement. Delete the block and the "Unsure" bullet about it. A rerun is not required, because B1 stands on the recorded panics.
  4. Deletions. The BLOCKER below is one. The NOTEs name two more: tls_dtv_race should leave the shared boot, and the kernel repeats the zero-size-TLS filter five times.
  5. Lines. As above.

BLOCKER

  • kernel/src/loader/mod.rs:820-825 and kernel/src/elf/reloc.rs:322-333: the executable's exe_tpoff closure repeats compute_tpoff's body word for word. Both say "unresolved → 0, overflow → TpoffOverflows". That is a sibling of a function the tree already has, and no test can see the two drift apart.
    • Fix: make compute_tpoff(r, own_base_offset, own_tls: Option<TlsSegment>, symbols: SymTab<'_>, tls, tls_info) pub, calling resolve_tls_ref.
    • The library callers pass lib.tls(), lib.symbols(). The closure becomes |r| elf::compute_tpoff(r, exe_base_offset, layout.tls(), exe.symbols(), tls, &tls_info).
    • Then delete the one-line forwarder resolve_tls (reloc.rs:311), or keep it only for the two DTPOFF callers.

NOTE

  • kernel/src/loader/mod.rs:471: a GLOB_DAT whose SymIndex the short-read .dynsym does not hold is skipped with continue, silently and without a log. resolve_tls_ref refuses the same condition as SymbolPastTable.
    • It is reachable: read_file_range clamps to the file, and DT_GNU_HASH without DT_SYMTAB leaves dynsym empty while sym_count > 0.
    • The behaviour predates this branch. Refuse it by the same name.
  • tests/toyos.rs:200: tls_dtv_race is missing from RUST_SKIP, so it also runs unarmed in the shared boot. There it repeats what the armed run already exercises, and it can fail on C1 only by timing. Add it with its one-line reason, as copy_out_races_munmap has.
  • The kernel repeats lib.tls().filter(|t| t.memsz() > 0) / layout.tls().filter(..) at five sites: kernel/src/loader/tls.rs:235, kernel/src/loader/tls.rs:249, kernel/src/loader/mod.rs:505, kernel/src/syscall/vm.rs:289 and kernel/src/elf/reloc.rs:240. One accessor answering "occupies a TLS slot" would serve every reader.
  • issues/design-debt/elf-domain-lint-line-not-yet-added.md:9 and issues/kernel/dlopen-write-at-is-not-the-sole-writer-of-a-mapped-module.md:9: the "orchestrator's ELF-loader track" they are assigned to has no file under issues/. The orchestrator either confirms it holds them, or they go back to open.
  • PR body, B2 "Negative control": it does not name the commit the mutation was applied to. The panic line evidences head's tls.rs, but the rule asks for a named commit.

REMOVE

  • PR body, Oracle: the reconstructed command block, and "Round 1 did not record its literal command lines, so these are reconstructed. They were not re-run in round 2." A command nobody ran is not a record.
  • PR body, Unsure: "The B1 command lines above are a reconstruction".
  • PR body, B3: the "applied before B4 changed the signature … u64::MAX" bullet. It was measured on another tree, and the at-head Ok(None) control replaces it.
  • PR body, B5: "whose length is the same sym_count that bounds every SymIndex". False: read_file_range short-reads, and DT_GNU_HASH without DT_SYMTAB leaves dynsym empty.
  • PR body, B2: "so it runs on both TLS variants". Unmeasured; nothing ran it on variant I.
  • PR body, gates: "The first run panicked at init's spawn, which has no thread, and hold now asks percpu::current_pid()". This is investigation story.
  • PR body: the "## NOTEs" and "## REMOVEs" sections and the "This round alone" line. They are review chronology in what becomes main's merge record.
  • tests/toyos-rust-tests/src/bin/tls_dtv_race.rs:15-16: "Unarmed, the window is the scheduler's to hit." At head there is no window, and unarmed runs go with the RUST_SKIP NOTE.
  • issues/design-debt/elf-domain-lint-line-not-yet-added.md:11: "The design's". No design is in the tree.
  • issues/design-debt/elf-domain-lint-line-not-yet-added.md:9 and issues/kernel/dlopen-write-at-is-not-the-sole-writer-of-a-mapped-module.md:9: "its next brief carries this". It is a promise that rots.
  • issues/kernel/dlopen-write-at-is-not-the-sole-writer-of-a-mapped-module.md:7: "(M8)". It is a review-internal label.

SEND BACK

Japabu and others added 3 commits September 27, 2026 19:01
- `compute_tpoff` is the one `S + A - tp` rule. It takes the referencing
  module's segment and symbol table, so the executable's closure is a single
  call to it; the forwarder `resolve_tls` is deleted and `resolve_tls_ref` is
  private to `reloc.rs`.
- The executable's `GLOB_DAT` refuses a symbol index its short-read `.dynsym`
  does not hold, as `SymbolPastTable`, through `SymTab::at` — the same check
  the TLS path now uses. It used to be skipped silently.
- `elf::occupied_tls` answers "does this `PT_TLS` get a module" for all five
  readers. The false "a zero-size PT_TLS still gets a DTV slot" clause is
  deleted: no module is given for one.
- `abuse_elf_loader` gains the TPOFF overflow for a dlopen and for a spawn,
  and the GLOB_DAT case; the harness asserts each one's reason, and checks
  every reason even when the guest failed.
- `tls_dtv_race` joins `RUST_SKIP`; `tls_rebase_window` runs it.
- The two issues go back to `open`, with no owner claimed.

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

The TPOFF overflow's spawn runs before its dlopen, and the GLOB_DAT case
before the TLS cases. A mutation that lets the dlopen through panics the
guest; run first, it left the spawn case unreached, so the harness's red on
it measured nothing.

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

Japabu commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #544, round 4, at 63a1f31 (origin/main f345b67 merged in 8bbc7a8)

Gate. PR CI at 63a1f31: host concluded success (run 36336913967, headSha 63a1f31). The body gives EXIT=0 at 63a1f31 for clippy, abuse_elf_loader and tls_rebase_window, and EXIT=0 for the three host suites on 411ded8 less one doc comment. The branch does not target hardware. I ran no test and no build.

Lines (git diff --shortstat origin/main...HEAD): +2701/−960 in 31 files.

  • Production (kernel/ bootloader/ toyos-elf/src/ src/): +1519/−878, net +641.
  • Tests (tests/ toyos-elf/tests/): +1138/−82, net +1056.
  • Issues: +44.
  • This round (8bbc7a8..63a1f31): production +47/−47, tests +69/−23, issues +4/−8.

Earlier BLOCKER

  • Round 3's exe_tpoff sibling: CLOSED. The closure is one call to elf::compute_tpoff (kernel/src/loader/mod.rs:820-821), resolve_tls is gone, and .tpoff( has one caller (kernel/src/elf/reloc.rs:323). The measurement: the overflow→0 patch inside compute_tpoff is EXIT=1 and red in both the spawn case and the dlopen case, so both loaders reach the one function.

Round 3's NOTEs and REMOVEs

  • Every REMOVE is applied.
  • The RUST_SKIP, occupied_tls and named-commit NOTEs are applied, and both issue files are open.
  • The GLOB_DAT NOTE is applied, but its shape is the BLOCKER below.

Answers to the brief

  1. TPOFF rule: one. Symbol-index bound: two.
    • A library's SymIndex is bounded at parse by the .dynsym it holds (kernel/src/elf/mod.rs:426-435).
    • The executable's SymIndex is bounded at parse by a count the file declares (loader/mod.rs:268,277), then checked again at each use through the new SymTab::at.
    • The use-time check has no case on the TLS side. The patch kernel/src/elf/reloc.rs:293 let sym = symbols.at(s.sym())?; → let Some(sym) = symbols.get(s.sym().get()) else { return Ok(None) }; passes every case. A library index cannot miss, and no executable TPOFF names a symbol past a short .dynsym.
    • toyos-symbols indexes no symbol. SymTab::resolve, find, find_tls and defined walk 0..count(), and gnu_hash.rs:125 goes through get.
  2. The spawn case is a real negative control.
    • The reason is RelocError::TpoffOverflows.as_str(), which the harness reads from the enum it imports. It is not a copied string.
    • Under the mutation, the line beside the file is failed to allocate TLS, and nothing else can supply the missing reason.
    • What it pins is the one-line <path>: <reason> shape that both refusal sites share (loader/mod.rs:545, syscall/vm.rs:311).
    • No smaller spawn case exists: an overflow needs a segment past 2^63 bytes, which no allocation holds. The log is therefore the only discriminator.
  3. Not every case is reached in every arm, and nothing structural makes it so.
    • A guest panic ends main. In the Ok(None) arm, both TPOFF cases go unreached.
    • What is structural is that an unreached case is red, never green: reasons are checked whatever the guest's status (tests/toyos.rs:3516-3538).
    • Each measured arm names its own target in its failure line, so each arm stands. Order only decides which case a red is attributed to.
  4. Deletions and prose. The BLOCKER deletes SymTab::at and the map_err block, and it is net negative. The prose is under REMOVE.

BLOCKER

  • kernel/src/loader/mod.rs:268-297, toyos-elf/src/sym.rs:128-132: one index has a second bound. The tree already bounds a SymIndex by the table read (kernel/src/elf/mod.rs:426-435). The executable parses against a declared exe_sym_count, reads .dynsym afterwards through the clamping read_file_range, and patches the gap with a use-time SymTab::at whose TLS arm no case covers (mutation above).
    • Fix: in read_exe_tables, read dynsym before parse_rela_entries and set Rules.sym_count to dynsym.len() / sym::ENTRY_SIZE.
    • An r_sym past what was read is then refused at parse as SymbolPastTable. The log line is the same, so globdat_past_dynsym stays.
    • SymTab::at and loader/mod.rs:471-474 go.
    • A lookup by SymIndex becomes infallible. The silent ""/false on a miss at reloc.rs:103,130,261,264 must then fail fast, not default.
    • Negative control: on the fixed head, Rules.sym_count back to exe_sym_count(..). globdat_past_dynsym must go red.

NOTE

  • tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs:869-870: the spawn TPOFF arm means something only while the spawn precedes the dlopen, and only a comment holds that order. A reorder costs attribution, not a red.
  • kernel/src/elf/mod.rs:78-82: occupied_tls is a pure decision about a toyos-elf type, living in the kernel. It belongs in toyos-elf as a TlsSegment/Layout method.
  • issues/design-debt/elf-domain-lint-line-not-yet-added.md, issues/kernel/dlopen-write-at-is-not-the-sole-writer-of-a-mapped-module.md: both compromises are open with no owner. The orchestrator assigns them or records that it accepts them unheld.

REMOVE

  • toyos-elf/src/layout.rs:189-190: "Absent TLS is None, never a zero memsz." A zero-memsz TlsSegment exists, which is why occupied_tls does, and the trimmed sentence reads as an invariant.
  • kernel/src/elf/reloc.rs:313: "the one rule for a library's relocations and the executable's." It is a claim about callers that rots.
  • tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs:868-869: "An executable needing the defining library at startup, then a dlopen." It narrates the code below it.
  • PR body: "git merge-tree --write-tree origin/main HEAD against the newer main 6c9e2cb2 (toyos-gpt: a Partition is only what parsing proved, and a bent GPT is refused (B1) #547) is clean (EXIT=0); that composition was not built." It rots in main's record.
  • PR body, Unsure: the bullet saying the two compromises are status: open. The files' frontmatter says it, and this copy rots.

SEND BACK

Japabu and others added 2 commits September 27, 2026 19:43
The executable's relocations were parsed against a symbol count the file
declared (`exe_sym_count`), `.dynsym` was read afterwards through the
clamping `read_file_range`, and `SymTab::at` refused the gap at use. Now
`read_exe_tables` reads `.dynstr` and `.dynsym` first and parses against
`SymTab::count` of the table it holds, the count `load_shared_lib`
already parses a library's relocations against. An `r_sym` past what
was read is refused at parse as `SymbolPastTable`, so the harness's
`globdat_past_dynsym` line is unchanged.

A lookup by `SymIndex` is then infallible: `elf::relocated_symbol`
asserts it, replacing `SymTab::at`, the `GLOB_DAT` `map_err` block and
the silent `""`/`false` defaults in `resolve_dlopen_relocs`,
`resolve_lib_bind_relocs` and `resolve_dtpmod`.

`occupied_tls` moves into `toyos-elf` as `TlsSegment::occupied`, and
the fuzz oracle's copy of the rule goes through it; a host case pins
zero against one byte. The two recorded compromises are held by the
orchestrator. Prose the review marked REMOVE is deleted, as is the
`globdat_past_short_dynsym` clause the new bound made false. A false
doc line already on main is filed, not fixed.

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

Japabu commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #544, round 5, at 237494e (origin/main 6c9e2cb merged in 945feb6)

Gate.

  • PR CI at 237494e: host concluded success (run 36338989659, headSha 237494e).
  • The body gives EXIT=0 at 237494e for the three host suites, clippy and abuse_elf_loader.
  • tls_rebase_window is a test this branch adds. It was last green at 63a1f31, not at this head (see answer 3).
  • The branch does not target hardware. I ran no test and no build.

Lines (git diff --shortstat origin/main...HEAD): +2721/−967 in 32 files.

  • Production: +1518/−885, net +633.
  • Tests: +1141/−82, net +1059.
  • Issues: +62.
  • This round, 945feb6..237494e: production +47/−55 (net −8), tests +10/−7, issues +20/−2.

Earlier BLOCKER

  • Round 4's second symbol-index bound: CLOSED.
    • The code: Rules.sym_count is SymTab::new(&dynsym, &dynstr).count() over the same Vecs that ExeTables::symbols() returns (kernel/src/loader/mod.rs:287, :194-196). SymTab::at and the GLOB_DAT map_err are gone. The ""/false defaults now go through elf::relocated_symbol.
    • The measurement: with exe_sym_count restored, abuse_elf_loader gives EXIT=1, a panic in relocated_symbol and the globdat_past_dynsym red. The head gives EXIT=0.

Round 4's NOTEs and REMOVEs

  • All three NOTEs are applied: the order is recorded in the body, TlsSegment::occupied is pinned at 0 and 1, and both issues are assigned.
  • All five REMOVEs are applied. A sibling of the reloc.rs:313 claim survives at :280-282 (REMOVE below).

Answers to the brief

  1. One bound for both loaders, and no crafted input reaches the expect.
    • Each loader bounds its parse with SymTab::count() over the exact bytes it keeps (loader/mod.rs:287, elf/mod.rs:444-446).
    • A cached clone carries the same dynsym slice and Rules in its Snapshot. Every SymIndex is paired with the table of the module that parsed it: reloc.rs:103,130,162,221,261,294 and loader/mod.rs:468,814.
    • Probes:
      • A size that is not a multiple of 24: count floors, so SymIndex < count implies (i+1)*24 <= len, and get is Some.
      • DT_SYMTAB without DT_STRTAB: the table is empty or the names are "", and no index is dropped.
      • .gnu.hash counts: they only size the read, which is clamped to the file or refused above one allocation.
      • An overlapping .dynsym: the executable's table is a private Vec. A library's is refused against the window, and in any case count depends on the length, not the contents.
      • A short read: the count follows the short Vec.
    • What no test holds is where the bound sits relative to the table (BLOCKER).
  2. The filed issue. Yes, delete the clause here, and the issue file with it.
    • The doc on a_zero_size_tls_segment_is_present_not_absent opens with the sentence round 4 removed from layout.rs.
    • It sits directly above only_a_tls_segment_with_bytes_is_occupied, which asserts the opposite of its DTV-slot clause.
  3. tls_rebase_window: yes, at the landing head.
    • The diff does not demand it. The loader/tls.rs delta is two call sites in build_tls_layout, with the same memsz > 0 predicate, now host-pinned, and nothing in TlsBlock::publish/fix/rebase.
    • The gate demands it: it is a test the branch adds, and it has not been green since the merge of 6c9e2cb.
    • The changes named below move the head anyway. Run it and abuse_elf_loader there.
  4. Deletions and prose.
    • Net production this round is −8.
    • The BLOCKER's structural option deletes Rules.sym_count and both kernel count sites.
    • The prose is under REMOVE.

BLOCKER

  • The executable's bound is untested at its edge.
    • Site: kernel/src/loader/mod.rs:287, tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs:899.
    • The mutation passes every case: sym_count: SymTab::new(&dynsym, &dynstr).count() → sym_count: dynsym.len().div_ceil(sym::ENTRY_SIZE), or … .count() + 1.
    • Why it passes: the read is 0x2C00 bytes, 469 whole entries plus 8. r_sym 999 is refused either way, but r_sym 469 would pass the parse and panic the kernel in relocated_symbol.
    • Fix: name symbol 469, not 999, and the doc above it with it. It must go red under this patch and still go red under the exe_sym_count control.
  • The library's bound has no case at all.
    • Site: kernel/src/elf/mod.rs:446.
    • The mutation passes every case: symbols.count() → declared. No library in abuse_elf_loader declares more .dynsym than its image holds from DT_SYMTAB, with a relocation naming an index between the two.
    • Before this branch a miss there was a silent "". It is now a kernel panic.
    • Fix, either of:
      • A dlopen case: a .dynsym clamped by the image end in a read-only segment after the writable one, so tables_outside_window passes, with .gnu.hash declaring more and a GLOB_DAT naming the first clamped-off index. The harness asserts SymbolPastTable beside it, and the case is red under the patch.
      • Or make it unrepresentable: drop Rules.sym_count and let rela::parse take the module's SymTab and bound by its count(). The kernel then keeps no count to get wrong, and tables.rs's a_symbol_index_past_the_table_is_refused_except_for_relative pins it on the host. The executable case above is still owed.

NOTE

  • kernel/src/loader/mod.rs:78-82,92-96 — read_file_range ends a read at a page the store refuses, and returns it short.
    • read_elf_table's doc says it "refuses instead of clamping to a table nothing downstream could tell was short".
    • So a device error on an executable's .dynsym is refused as a malformed file (SymbolPastTable).
    • This is on main, outside this branch's fence. The orchestrator files it.

REMOVE

  • toyos-elf/tests/crafted.rs:247-249 — the whole doc comment. It is false beside the test that pins occupied, and its first clause is round 4's REMOVE from layout.rs.
  • issues/design-debt/zero-size-tls-test-doc-claims-a-dtv-slot.md — goes with the deletion above. A false doc line is deleted, not filed.
  • kernel/src/elf/reloc.rs:280-282 — "— a library's in-image one, or the executable's read off the file — so both loaders resolve through this one function". A claim about callers, the sibling of round 4's :313.
  • PR body — "for all five readers and the fuzz oracle". A count another landing moves.

SEND BACK

`rela::Rules.sym_count` is gone. `rela::parse` takes the module's
`SymTab` and bounds every `r_sym` by its `count()`, so neither loader
keeps a count that could disagree with the table a `SymIndex` is later
looked up in. The executable's loader passes the `SymTab` over the
`.dynsym` and `.dynstr` it read; the library's passes the one over its
in-image slices at load, and `LoadedLib::symbols()` on the re-parse. The
bootloader, which binds no symbol, passes `SymTab::empty()`.

The dlopen log line loses its symbol count, which only the deleted count
fed; the `dynamic: loaded` line still prints one.

A host case pins the edge: four whole entries and eight bytes, where
index 4 is refused and 3 accepted for every symbol-binding type.
`globdat_past_dynsym` names symbol 469, the first past the 469 whole
entries and 8 bytes the executable's read holds, instead of 999.

Prose the review marked REMOVE is deleted, with the issue that filed one
of those lines.

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

Japabu commented Sep 27, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #544, round 6, at eb0d98c (origin/main 6c9e2cb, merged in 945feb6)

Gate.

  • PR CI at eb0d98c: host concluded success (run 36340721499, headSha eb0d98c; gh run watch --exit-status EXIT=0).
  • The body reports EXIT=0 at eb0d98c for the toyos-elf host suite, the host workspace, toyos-build --lib, clippy, abuse_elf_loader and tls_rebase_window.
  • The branch does not target hardware. I ran no test and no build.

Lines (git diff --shortstat origin/main...HEAD): +2713/−973 in 31 files.

  • Production: +1510/−888, net +622.
  • Tests: +1155/−85.
  • Issues: +48.
  • Since 237494e: production +20/−31, tests +23/−12.

Earlier BLOCKER

  • Round 5, executable bound untested at its edge: CLOSED.
    • globdat_past_short_dynsym names symbol 469 against 469 whole entries plus 8 bytes.
    • The body measures three arms on eb0d98c, each abuse_elf_loader EXIT=1 with that case's reason missing: A (count → div_ceil), B (count() + 1) and C (the exe_sym_count table).
    • Head: EXIT=0.
  • Round 5, library bound with no case: CLOSED by the structural option.
    • Rules.sym_count is gone, and rela::parse bounds by symbols.count() in toyos-elf.
    • The new host case a_symbol_index_is_bounded_by_the_whole_entries_of_its_table is red under A and B (host EXIT=101). Those are the only places the bound can now be weakened, and both loaders share them.

Round 5's REMOVEs

  • All four are applied:
    • the crafted.rs:247-249 doc;
    • the issue file, with no citation left in the tree;
    • reloc.rs:280-282;
    • the "five readers" line in the PR body.

Answers to the brief

  1. Is the bound unrepresentable? Yes, as a count.
    • No count exists outside SymTab::count(), and SymIndex::below is pub(crate), called only from rela::parse.
    • What stays representable is pairing an index with a different table: SymIndex carries no brand. At head every pair is the same module's table:
      • executable: loader/mod.rs:290 and :195, two SymTab::new over the same moved Vecs;
      • library: elf/mod.rs:219 and reloc.rs:99,128,162,221,260, all self.symbols()/lib.symbols() on one LoadedLib.
    • The author's library reading is correct. declared only sizes the KernelSlice both sides read, and Snapshot copies that slice unchanged into every clone.
    • load_shared_lib's own parse keeps only Op::Relative. Every library SymIndex a resolver sees comes from relocations(), directly or through prescan_relocs, against self.symbols().
  2. Can crafted input reach relocated_symbol's panic? No.
    • count = len / 24 floors. i < count gives (i+1)*24 <= len, so parse_at reads whole and get is Some.
    • The count depends on the slice length alone, which no write changes. A file therefore cannot make the lookup miss.
    • The load-time and re-parse tables have the same length, so reparse_refused is not reachable through the symbol bound either.
  3. The bootloader's SymTab::empty() is right, and it is loud, not silent.
    • GLOB_DAT/JUMP_SLOT, DTPMOD64 with r_sym != 0, and TLS relocations with r_sym != 0 get SymbolPastTable, which becomes panic!("kernel.elf: {e}").
    • Every other non-Relative type hits the "unsupported relocation type" panic.
  4. Deletions and prose: see NOTE and REMOVE below.
  5. PR body: four lines narrate rounds or branch-internal intermediates (REMOVE below). The rest is fit for main's record.

BLOCKER

None.

NOTE

  • kernel/src/loader/mod.rs:290 — the executable pairs parse and lookup with two separate SymTab::new(&dynsym, &dynstr) calls. The library path pairs them with one accessor. Build ExeTables first and parse against exe.symbols(), so both loaders are paired by construction, not by inspection. Arm C is the test that turns red if that pairing breaks.
  • kernel/src/elf/mod.rs:196-198 — LoadedLib::sym_count has one caller, the log at loader/mod.rs:750. Inline lib.symbols().count() there and delete the method.

REMOVE

  • PR body — "; git merge origin/main at this round said already up to date." Round chronology.
  • PR body — "This round (237494e3..eb0d98c8): production +20/−31 (net −11), tests +23/−12, issues −14." Round chronology.
  • PR body — "rela::Rules holds no symbol count." Rules does not exist on main, so this narrates an intermediate state.
  • PR body — "Layout::tls_memsz is deleted." It never existed on main; the same kind of narration.

LAND AFTER NAMED CHANGES

Japabu and others added 3 commits September 27, 2026 20:52
read_exe_tables built ExeTables and then re-derived its own SymTab from the
same moved Vecs to parse against, pairing the two by inspection instead of
by construction. Build ExeTables first and parse against exe.symbols(), the
same accessor every later lookup uses, matching the library path.

LoadedLib::sym_count had one caller, a log line; inline lib.symbols().count()
there and delete the method.

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

Building ExeTables with an empty relocation set to borrow its table
added a state that should not exist and twelve lines. Both tables come
from the same two Vecs `ExeTables::symbols()` reads, so the direct
construction already pairs parse and lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu enabled auto-merge September 27, 2026 18:57
@Japabu
Japabu added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 3462349 Sep 27, 2026
1 check passed
Japabu added a commit that referenced this pull request Sep 27, 2026
#544 made `Layout`'s fields private; the merge of main left
`tests/common/clang.rs` naming the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu deleted the wt/toyos-elffix branch September 28, 2026 09:46
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