Parse every ELF value into a type that cannot leave its image (E1-E4, C1) - #544
Conversation
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
Review of #544 at 11a850d (merge base 16d2e64)Gate. PR CI: Lines ( Merge. Answers to the brief
BLOCKER
NOTE
REMOVE
SEND BACK |
…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>
Review of #544, round 2, at 74ed442 (merge base a637f5c; main is now c9ed012)Gate. PR CI at 74ed442: Lines ( Round-1 BLOCKERs
Answers to the brief
BLOCKER
NOTE
REMOVE
SEND BACK |
…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>
Review of #544, round 3, at 19c5b9e (origin/main c9ed012, merged in 5602993)Gate. PR CI at 19c5b9e: Lines (
The production growth is the parsed types. I accept it once the one deletion under BLOCKER below is made. Earlier BLOCKERs
Answers to the brief
BLOCKER
NOTE
REMOVE
SEND BACK |
- `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>
Review of #544, round 4, at 63a1f31 (origin/main f345b67 merged in 8bbc7a8)Gate. PR CI at 63a1f31: Lines (
Earlier BLOCKER
Round 3's NOTEs and REMOVEs
Answers to the brief
BLOCKER
NOTE
REMOVE
SEND BACK |
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>
Review of #544, round 5, at 237494e (origin/main 6c9e2cb merged in 945feb6)Gate.
Lines (
Earlier BLOCKER
Round 4's NOTEs and REMOVEs
Answers to the brief
BLOCKER
NOTE
REMOVE
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>
Review of #544, round 6, at eb0d98c (origin/main 6c9e2cb, merged in 945feb6)Gate.
Lines (
Earlier BLOCKER
Round 5's REMOVEs
Answers to the brief
BLOCKERNone. NOTE
REMOVE
LAND AFTER NAMED CHANGES |
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>
#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>
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,TlsOffsetandSymIndexare made only bytoyos-elf's parses,and a
TlsOffsetis bounded only by aTlsSegmentthatLayout::parsederived.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, withorigin/mainat6c9e2cb2(#547) merged in945feb6f.Gates at
eb0d98c8(dev host, TCG; each the command's own exit code)toyos-elfhost suite (cargo testintoyos-elf/): EXIT=0.cargo test --workspace --exclude toyos-build): EXIT=0.toyos-buildlib, source gates included (cargo test --lib): EXIT=0, 389 passed.cargo run -- --clippy,-D warnings, kernel and bootloader on bothtargets 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 theone
S + A - tprule: unresolved → 0, overflow →TpoffOverflows. Librarycallers pass
lib.tls(), lib.symbols(); the executable's closure is a singlecall with
layout.tls(), exe.symbols(). The forwarderresolve_tlsis deleted,and
resolve_tls_refis private toreloc.rs.abuse_elf_loaderreaches the overflow both ways:S + A= 8 +i64::MAXinside a
PT_TLSdeclared as0x8000_0000_0000_0010bytes, so onlyS + A - tpleaves ani64.tpoff_overflow_spawnis an executable whosestartup library defines the symbol;
tpoff_overflow.sois a dlopen against adlopened definer. The harness asserts
TpoffOverflows's text beside each file.Negative control (checked patch on
63a1f315, built withcargo run -- --build-onlyEXIT=0, tree clean after): insidecompute_tpoff,tls.tpoff(..).ok_or(RelocError::TpoffOverflows)→Ok(tls.tpoff(..).unwrap_or(0)).abuse_elf_loaderEXIT=1: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;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)takesthe module's
SymTaband bounds everyr_symbysymbols.count(), the wholeentries its bytes hold.
read_exe_tablesreads.dynstrand.dynsymbefore it parses,and passes
SymTab::new(&dynsym, &dynstr), the sameVecsExeTables::symbols()resolves against. The file-declaredexe_sym_countonly sizes that read, which
read_file_rangeclamps to the file.load_shared_libpasses theSymTabover its in-imagedynsymanddynstrslices, which move unchanged intoLoadedLib; the re-parse inLoadedLib::relocationspassesself.symbols(), the call every resolver inreloc.rsmakes.declaredonly sizes thedynsymslice, clamped to theimage, before any
SymTabexists. No count-typed value reaches the parse.SymTab::empty(); it binds no symbol.A lookup by
SymIndexis infallible:elf::relocated_symbolasserts it, and amiss is a kernel bug. What
maindid on a miss is deleted: the executable'sGLOB_DATloop skipped the entry silently, andresolve_dlopen_relocs,resolve_lib_bind_relocsandresolve_dtpmodread its name as"". The dlopenlog 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, atable of four whole entries and eight bytes: index 4 is refused as
SymbolPastTableand index 3 accepted, for every symbol-binding type.Guest edge:
globdat_past_dynsym:.gnu.hashcounts 1000 symbols, the readfrom
DT_SYMTAB0x1400 to the file end 0x4000 is 0x2C00 bytes (469 wholeentries and 8 bytes), and a
GLOB_DATnames symbol 469. The harness assertsSymbolPastTable's text beside the file.Negative controls (each a checked patch on
eb0d98c8,git apply --checkthen apply, built with
cargo run -- --build-only, reverted withgit apply -R, tree clean after each):toyos-elfhostabuse_elf_loaderSymTab::count:self.syms.len() / ENTRY_SIZE→self.syms.len().div_ceil(ENTRY_SIZE)rela::parse:symbols.count()→symbols.count() + 1exe_sym_countcontrol: the executable parses againstalloc::vec![0u8; exe_sym_count(..)? * sym::ENTRY_SIZE]type 6: Ok(Some(.. Bind(SymIndex(4)) ..)),expected
Err(SymbolPastTable)) anda_symbol_index_at_the_edge_of_the_bytes_reads_nothingred;a_symbol_index_past_the_table_is_refused_except_for_relative, whose tablesare whole entries, stays green under A.
a_symbol_index_past_the_table_is_refused_except_for_relativered.src/elf/mod.rs:266:26inkernel::elf::relocated_symbolfromkernel::loader::spawn, and the harness:the executable's GLOB_DAT past .dynsym did not fire for its reason.abuse_elf_loaderEXIT=0 ateb0d98c8.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 insideTlsBlock::publish'sfixclosure,before
rebase, so a mutation of the ordering carries it along. It watches onlya thread spawn whose argument is
loader::rebase_window::MARK.until DTV slot 0 differs from what
build_combinedwrote. A 10 s bound endsthe hold with a loud assert.
tls_rebase_windowboots it with two CPUs and runstls_dtv_race. It requiresone "not reachable before its rebase" line for each of the 15 watched spawns.
tls_dtv_raceis inRUST_SKIP, so the shared boot does not run it.tls_dtv_racewaits, bounded, for the sibling to engage in every round. Thefirst round places the block and is not watched.
thread_join's result isasserted 0.
Negative control (checked patch on
63a1f315, build EXIT=0, tree cleanafter): in
Unpublished::publish,fix(&self, at)moves afterspace.map_range(..)anddrop(space).tls_rebase_windowEXIT=1, redagain when the harness re-ran it alone:
The cross-module TLS refusal is reached
tls_defs_sohas aPT_TLSofmemsz: 0x20andtls_refs_souses addend0x140, so
S + Ais 0x148. The pair uses its own symbol name (ytls):dlcloseunloads nothing, so f13's 0x200-bytextlsstays loaded and wouldotherwise be the module resolved.
check_abuse_elf_loaderassertsTLS relocation names an offset outside its PT_TLSbesidef13_refs_past.so.Negative control (checked patch on
63a1f315, build EXIT=0, tree cleanafter): the cross-module refusal replaced by
return Ok(None)(unresolved).abuse_elf_loaderEXIT=1:f13_refs_past.so: dlopen loaded an image the loader must refuse.A
TlsOffsetis bounded by the parse'sTlsSegmentTlsOffset::of(value, addend, TlsSegment)is crate-private.Sym::tls_offset(addend, TlsSegment),Rules.tls: Option<TlsSegment>andSymTab::bounded(extent, Option<TlsSegment>).LoadedLib's rules(
LoadedLib::tls).TlsSegment::occupied(intoyos-elf, pinned byonly_a_tls_segment_with_bytes_is_occupied) answers whether aPT_TLSisgiven a module.
defining_modulereturns the defining module'sTlsSegment, andresolve_tls_reftakes the referencing module's.Layout::parse(tests/common::tls_segment).Symbols
ExeTables::symbolandsymtab_file_offare deleted. The executable'sGLOB_DATand TLS lookups go throughexe.symbols().address_ofanswersNonefor an undefined orSTT_TLSsymbol. An extentmiss for a defined one panics in
#[cold]bounded_symbol_outside, besidereparse_refused.Oracle: the recorded base failure
The independent oracle is the base kernel failing on these inputs. This
branch's
abuse_elf_loaderran on a kernel built at the merge base 16d2e64,one
values_are_bounded_by_the_imagecase per boot. Each case panicked the basekernel with a named overflow, and each is refused at head.
relative_addend_past_imageloader/mod.rs:432:39add overflow,kernel::loader::spawntpoff_addend_past_tlstoyos-elf/src/tls.rs:135:28sub overflow,kernel::loader::exe_tpoffexport_past_imagemm/mod.rs:84:43add overflow,loader::symbols::map(dynamic_map)so_relative_addend_past_imageelf/mod.rs:429:25add overflowso_tpoff_addend_past_tlstoyos-elf/src/tls.rs:135:28sub overflow,elf::reloc::apply_tpoff_relocsso_init_array_past_imagesyscall/vm.rs:301:44add overflowso_export_past_imagemm/mod.rs:84:43add overflowUnsure
window's width, but no KVM shard has run it.
Lines
git diff --shortstat origin/main...HEAD: +2713 / −973 across 31 files.kernel/ bootloader/ toyos-elf/src/ src/)toyos-elf/tests/ tests/)🤖 Generated with Claude Code