Skip to content

libc: dl_iterate_phdr, from program headers the kernel reports - #599

Merged
Japabu merged 12 commits into
mainfrom
wt/toyos-phdr
Sep 29, 2026
Merged

Japabu merged 12 commits into
mainfrom
wt/toyos-phdr

Conversation

@Japabu

@Japabu Japabu commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

libunwind finds a module's unwind tables through dl_iterate_phdr, and M3 of issues/build/toyos-builds-itself.md needs it. Nothing told userland where a loaded module's program headers are. This adds that to the kernel's module query and builds dl_iterate_phdr in libc on top of it. Closes issues/build/libc-has-no-dl-iterate-phdr.md.

ABI change

toyos_abi::syscall::ModuleInfo, which SYS_QUERY_MODULES writes, gains two fields between eh_frame_hdr_size and path_offset:

  • phdr: u64: the absolute address of the module's program header table;
  • phnum: u64: e_phnum.

The record grows from 40 to 56 bytes and has no padding. Its const assert and the wire-decoding host test both check this. phnum is a u64 rather than a u32 so that no padding is needed. There is no phentsize field: Layout refuses any e_phentsize other than 56, so the field could only ever hold one value. No syscall number changes and no auxiliary vector is added.

toyos_abi::syscall::modules(answer) is new. It decodes one query_modules answer into its records and paths, and libc uses it. It has host tests, including one where a path runs past the end of the answer, which panics.

Consumers of ModuleInfo:

  • the kernel (sys_query_modules);
  • toyos-abi's own test;
  • tests/toyos-rust-tests (query_modules_size, abuse_kernel_addr), which size records by size_of;
  • the std fork's unwinder (library/std/src/sys/pal/toyos/mod.rs, eh_frame::load_modules);
  • the backtrace crate in the fork (library/backtrace/src/symbolize/gimli/libs_toyos.rs, native_libraries).

Both fork readers read fields by name and step by size_of, so they build unchanged, and no fork commit or gitlink bump was needed. Both also read the call's byte count as a record count. That is an older defect, filed below. toyos/src has no consumer.

Decisions

  • The table's location comes from the file layout, not from PT_PHDR. toyos_elf::Layout::program_headers() finds the first PT_LOAD whose file bytes (p_offset..p_offset+p_filesz) hold all of e_phoff..+e_phnum*56.
    • It returns that range as an image offset, or None if no segment holds it.
    • PT_PHDR is only the file's claim about where the table is.
    • Bytes past p_filesz are zero-fill and do not count.
    • Layout::parse itself does not refuse a file without the table, because the bootloader loads the kernel with the same Layout.
    • The table's length (e_phnum * 56, from a u16) and its image start (inside the segment's own range) are computed with plain arithmetic, because neither can overflow. If that invariant broke, the kernel's and bootloader's overflow checks would panic, rather than the loader quietly reporting "no table".
  • The kernel refuses such a module rather than mapping or copying the table in. spawn checks right after rebase_base, and load_shared_lib right after its vaddr-0 check.
    • Refusing means the reported address is always a real mapping, and no new region or kernel-written page is needed.
    • This is musl's rule: map_library takes the first PT_LOAD whose file bytes hold the table. glibc copies an unmapped table instead, so ToyOS is stricter than glibc. That difference is a refusal made on purpose.
    • Evidence that real ELFs are not refused: every guest suite spawns or dlopens the in-tree ELFs, and a refusal would turn it red.
  • Both refusals are reached from userland. abuse_elf_loader case 21 is a 0x2000-byte ELF whose only PT_LOAD is file 0x1000..0x2000 at vaddr 0, with entry 0.
    • The table at e_phoff 0x40 lies in no segment's file bytes.
    • vaddr_min is 0, so rebase_base and the vaddr-0 check both pass it.
    • The same bytes are spawned (spawn_refused) and dlopened (dlopen_refused).
    • The toyos-elf crafted test pins the premise on the host: those bytes parse with extent minimum 0 and no table.
    • dlopen_refused's doc no longer claims that every case is a relocation write.
  • dlpi_addr is the load bias, so dlpi_addr + p_vaddr is a runtime address.
    • For the executable that is ElfInfo::elf_base (USER_VM_BASE - vaddr_min), and its table is placed from image_start, the image's first byte.
    • For a library it is user_base, because libraries start at vaddr 0.
  • The libc contract is glibc's dl_iterate_phdr(3), which the LSB adopts:
    • the executable comes first and is named "", then every library in load order;
    • the first non-zero callback return stops the walk and is returned, otherwise the walk returns 0.
  • Where this differs from glibc:
    • dlpi_name is valid only until the callback returns;
    • dlpi_adds/dlpi_subs are not declared, so the size a callback receives ends at dlpi_phnum;
    • the walk covers one snapshot, so a module loaded during it is not visited.
  • link.h declares struct dl_phdr_info and ElfW. It includes a new elf.h that holds the Elf64_* scalar types, Elf64_Phdr, PT_* and PF_*, because users of link.h (libunwind among them) expect it to pull those in.
  • libc's dlopen handle is the kernel's module index plus one.
    • SYS_DLOPEN answers with the library's index, so a C program with no startup libraries gets index 0 for its first dlopen. libc handed that back as the pointer 0, which C reads as failure.
    • dlsym and dlclose subtract the one again. dlclose of a handle that dlopen never returned answers -1.
    • dlsym(RTLD_DEFAULT) now panics as unimplemented instead of reading module 0, as libc's longjmp stub already does. issues/build/libc-dlsym-rtld-default-panics-as-unimplemented.md records it. It is a separate file from the kernel's global-scope issue because the fix is in libc and it has its own exit.
    • The Rust callers (toyos_abi::syscall::dl_open/dl_sym) keep the raw index.
    • The dlopen: unresolved: main line in 204's kernel log is not a refusal. libtls_lib.so carries a global undefined main, the C executable exports none, and resolve_dlopen_relocs logs an unresolved slot and leaves it, which is its contract (kernel/src/elf/reloc.rs).

Tests

tests/testcases/tinycc/204_dl_iterate_phdr.c + .expect. Each module is checked against facts that did not come from the kernel's answer:

  • its dlpi_phnum is e_phnum, and its dlpi_phdr is dlpi_addr + e_phoff, both read from the ELF header at dlpi_addr. Both modules have that header at dlpi_addr: llvm-readobj -h -l, run on the host-linked 204 and on libtls_lib.so, shows each linked at vaddr 0, with its first PT_LOAD at file offset 0, e_phoff 0x40 and 10 headers;
  • its table lies in one of its own PT_LOADs at dlpi_addr + p_vaddr;
  • where the linker emitted a PT_PHDR, it names the same address;
  • the executable's main lies in an executable PT_LOAD;
  • its PT_DYNAMIC sits at the linker's _DYNAMIC and inside a PT_LOAD.

The case then calls dlopen("/system/lib/libtls_lib.so"). That is the program's first dlopen, so the library is also module index 0. The library must then be visited under that name, and dlsym's tls_get_label must lie inside one of its executable segments. Callbacks that return 1, 7 and -1 must stop the walk where they say, and the walk must return their value.

tests/testcases/tinycc/205_dl_iterate_phdr_image_base.c + .expect covers an executable whose lowest vaddr is not 0.

  • It is linked with -Wl,--image-base=0x200000. The flag comes from tests/common/compile.rs's LINK_FLAGS, keyed by case name and applied in link_toyos, which every C link goes through.
  • It compares the executable's dlpi_phdr with __ehdr_start + e_phoff, and dlpi_phnum with e_phnum. Both are read from the linker's own ELF header.
  • It also checks that __ehdr_start equals dlpi_addr plus the lowest p_vaddr.
  • It prints that lowest p_vaddr, so its .expect turns red if the flag stops reaching the case.
  • Linked on the host at af62dd5 with the worktree's clang and sysroot, both steps exited 0. llvm-readobj -h -l gives e_phoff 0x40, 10 headers, the first PT_LOAD at offset 0 and vaddr 0x200000, and PT_PHDR at 0x200040. llvm-nm gives __ehdr_start at 0x200000.

Host tests:

  • toyos-elf:
    • the table placed through the first or a later segment;
    • absent when the file image stops one header short, when the segment is zero-fill only, when the segment starts past the table, and for case 21's bytes;
    • the toyos-ld fixture gives (0x40, 336, 6), which matches readelf's e_phoff/e_phnum;
    • the fuzz loop places every derived table inside the image.
  • toyos-abi: the 56-byte wire layout, and modules decoding two records and their paths from an unaligned buffer.

Gates at a7305ee

origin/main 576e558 is merged in and is the merge base.

gate exit
cargo run -- --ci host 0
cargo run -- --build-only 0
cargo test --test toyos-build -- --list 0 (lists Fast 204_dl_iterate_phdr, Fast 205_dl_iterate_phdr_image_base, Fast abuse_elf_loader, Fast query_modules_size)
host clang compile + link of 204 as changed in 293af00 0, 0

Guest results at f7fae8c, as the round-4 review records them: 204, 205, abuse_elf_loader, abuse_elf_segments, query_modules_size, dlopen_dedup, std_unwind and Fast exit 0. m1 and m1b each turn abuse_elf_loader red with exit 1. m2 turns 205 red with exit 1 and leaves 204 green. a, b and c turn 204 red with exit 1. c2 leaves query_modules_size and abuse_elf_loader green. 204 has changed since then (293af00). No round-4 patch has had a guest run.

Negative controls

The round-3 patches are in #599 (comment). The round-4 patches are in #599 (comment), with their sha256s.

Every patch below was put through the same checks, and each step exited 0: git apply --check, apply, cargo run -- --build-only, git apply -R, then git status --porcelain, which came back empty. The m1, m1b, m2, a, b and c patches were built this way in earlier rounds. At a7305ee each still passes git apply --check.

The harness builds every C case before any test runs, and one case that fails to build makes every test exit 101. A control that removes what a C case needs therefore measures only that case, and never measures abuse_elf_loader.

patch what it does expected guest result
m1-refusals-panic the review's mutation: both refusals become .expect("phdr table") abuse_elf_loader red: kernel panic at case 21's spawn
m1b-dlopen-refusal-panics only load_shared_lib's refusal becomes .expect abuse_elf_loader red: kernel panic at case 21's dlopen, after its spawn is refused
m2-exe-phdr-from-load-bias the review's mutation: the executable's phdr added to base instead of image_start 205_dl_iterate_phdr_image_base red, exit 1 (dlpi_phdr is __ehdr_start + e_phoff: no); 204_dl_iterate_phdr green, because its vaddr_min is 0
mutation-a-kernel-phnum-zero the kernel reports phnum 0 for every module 204_dl_iterate_phdr red, exit 1 (FAIL no program headers)
mutation-b-libc-ignores-stop libc returns 0 in place of the callback's stop value 204_dl_iterate_phdr red, exit 1 (stopped at 1 of 1: visited 1, returned 0)
mutation-c-libc-handle-is-the-index bf35e83 reverted: libc's dlopen hands out the raw index 204_dl_iterate_phdr red, exit 1 (FAIL dlopen /system/lib/libtls_lib.so)
mphnum3-lib-phnum-three the review's mutation: the kernel reports every library's phnum as 3 204_dl_iterate_phdr red, exit 1 (FAIL dlpi_phnum is not e_phnum)
mphdr56-lib-phdr-one-entry-late the review's mutation: every library's phdr is 56 bytes late 204_dl_iterate_phdr red, exit 1 (FAIL dlpi_phdr is not dlpi_addr + e_phoff)
control-c1r4-revert-kernel-abi-elf-libc kernel/, toyos-abi/, toyos-elf/ and userland/libc/ reverted to 4de5ecd, tests kept 204 and 205 red. Measured on the host: the harness's --list exits 101 while building the C corpus, 'link.h' file not found on both
control-c3-revert-and-drop-204-205 c1r4, plus 204, 205 and the licence count dropped; case 21 kept abuse_elf_loader red, exit 1 (phdrs_unmapped: spawn returned pid)
control-c3b-c3-without-the-spawn-arm c3, plus case 21's spawn_refused line deleted abuse_elf_loader red, exit 1 (phdrs_unmapped.so: dlopen loaded an image the loader must refuse)
control-c2r4-revert-and-drop-cases c3, plus case 21 dropped query_modules_size and abuse_elf_loader green, exit 0

The whole-change controls:

  • c1r4 shows that 204 and 205 cannot be built on the base.
  • c3 runs case 21's spawn arm on the base, and c3b runs its dlopen arm.
  • c2r4 shows that the reverted tree boots, answers the module query and passes the rest of abuse_elf_loader.

m1, m1b, m2, mphnum3 and mphdr56 are the behavioural controls for the review blockers. a, b and c are the behavioural controls for the walk and the handle.

Independent oracle

  • The glibc/LSB dl_iterate_phdr(3) contract: walk order, "" for the executable, stop-and-return. The case asserts each of these.
  • POSIX dlopen(3): a NULL return means failure, so no successful load may answer NULL.
  • The linker's own facts, which the guest checks the kernel's answer against: main, _DYNAMIC, the PT_PHDR address, dlsym, and __ehdr_start with the e_phoff/e_phnum it heads.
  • llvm-readobj/llvm-nm: the toyos-ld fixture's e_phoff/e_phnum, and 205's image base, table and __ehdr_start.
  • musl's map_library: the rule that decides whether the table is mapped.

Unsure

  • Whether c3b turns red. I found nothing in the base's load_shared_lib that refuses case 21's bytes before its table would be checked, but only the guest run can show it.
  • dlsym(RTLD_DEFAULT) panicking is new behaviour for any C program that probes the global scope. No program in the tree calls it.

Filed

  • issues/diagnostics/std-reads-a-query-modules-byte-count-as-a-module-count.md: std's unwinder and the backtrace crate both take query_modules's byte count as a record count.
  • issues/kernel/a-dlopened-library-never-binds-to-the-executables-exports.md: resolve_dlopen_relocs searches other libraries only, unlike glibc's global scope and unlike the startup path.
  • issues/build/libc-dlsym-rtld-default-panics-as-unimplemented.md: dlsym(RTLD_DEFAULT) dies as unimplemented.
  • issues/build/libc-dl-iterate-phdr-allocates-on-every-walk.md: every walk allocates twice and makes at least two syscalls. libunwind calls the walk while unwinding, so throwing bad_alloc with the heap exhausted dies in libc's allocator. The answer grows with every library and path, so the walk cannot be made allocation-free without an ABI change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs

libunwind finds a module's unwind tables through `dl_iterate_phdr`, which M3
of issues/build/toyos-builds-itself.md needs. Nothing told userland where a
loaded module's program headers are: a process starts with no auxiliary
vector, and `SYS_QUERY_MODULES`'s `ModuleInfo` carried a base, an extent and
`.eh_frame_hdr` but no `PT_*` table.

ABI: `ModuleInfo` gains `phdr` (the absolute address of the module's program
header table), `phnum` and `phentsize`, between `eh_frame_hdr_size` and
`path_offset`. It grows from 40 to 56 bytes, still with no padding, which its
const assert and the wire-decoding test now say. No auxiliary vector is added.

toyos-elf: `Layout::program_headers()` is where the image holds
`e_phoff..+e_phnum*56`: the first `PT_LOAD` whose file bytes hold all of it,
as an `ImageRange`, or `None`. It is derived from where the loader copies the
file, not from `PT_PHDR`, which is only the file's claim; bytes past
`p_filesz` are zeroes and hold nothing. The bootloader parses the kernel with
the same `Layout`, so this is an answer, not a refusal.

Kernel: an executable or library whose table no `PT_LOAD` maps is refused —
`spawn` right after the rebase check, `load_shared_lib` right after the
vaddr-0 check — rather than mapped or copied in. Refusing keeps the answer
true with no new mapping, and every ELF the tree builds already maps its
table: `llvm-readobj -h -l` over the 24 guest ELFs `--build-only` makes, the
98 test binaries and four test libraries of the primary's last build, and
this branch's C test, found all 127 with `e_phoff` 64 and the table inside
one `PT_LOAD`'s file bytes. The crafted ELFs in abuse_elf_loader and abuse_elf_segments
that do not map their table (filesz-0 segments at kernel-half and
arena vaddrs) are refused by `rebase_base` before this check, so each still
exercises the refusal it was written for. `ElfInfo` carries the executable's
table as (address, count), `LoadedLib` and the cache snapshot carry a
library's, and `sys_query_modules` reports both.

toyos-abi: `syscall::modules` decodes one `query_modules` answer into its
records and paths, which libc uses; host-tested beside the layout test.

libc: `dl_iterate_phdr` and `link.h` (`struct dl_phdr_info` with `dlpi_addr`,
`dlpi_name`, `dlpi_phdr`, `dlpi_phnum` — no `dlpi_adds`/`dlpi_subs`), with an
`elf.h` holding the program header vocabulary `link.h` needs. The contract is
glibc's dl_iterate_phdr(3), which the LSB adopts: the executable first, named
"", then each library in load order; the first non-zero callback return stops
the walk and is returned, else 0. `dlpi_addr` is the load bias, so
`dlpi_addr + p_vaddr` is a runtime address.

Test: tests/testcases/tinycc/204_dl_iterate_phdr walks every module and checks
each against facts the kernel did not report: its table lies in one of its
own `PT_LOAD`s, `PT_PHDR` (where the linker emits one) names the same address,
the executable's `main` is in an executable `PT_LOAD` and its `PT_DYNAMIC` is
at the linker's `_DYNAMIC`; after `dlopen("/system/lib/libtls_lib.so")` the
library is visited by that name with `dlsym`'s `tls_get_label` in one of its
executable segments; callbacks returning 1, 7 and -1 stop the walk where they
say and are returned. The corpus licence counts two more files of ours.

The std fork's unwinder reads `ModuleInfo` through `size_of` and by field
name, so it builds unchanged against the larger record; it needed no fork
commit. Reading it found it takes the byte count `query_modules` returns as a
record count, filed as
issues/diagnostics/std-reads-a-query-modules-byte-count-as-a-module-count.md.

Closes issues/build/libc-has-no-dl-iterate-phdr.md, and drops its citation
from issues/build/toyos-builds-itself.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu marked this pull request as ready for review September 29, 2026 04:40
Japabu and others added 3 commits September 29, 2026 08:40
A C program with no startup libraries gets module index 0 from its first
SYS_DLOPEN, and libc handed that back as the pointer 0, so dlopen reported
failure for a load the kernel had committed. 204_dl_iterate_phdr reds on
exactly that: the kernel log shows the module registered (its DTPMOD64
relocations applied under module_id 2), and the case prints
"FAIL dlopen /system/lib/libtls_lib.so".

The log's "dlopen: unresolved: main" is not the refusal: libtls_lib.so
carries a global undefined `main`, the C executable exports none, and
resolve_dlopen_relocs logs and leaves an unresolved slot by contract
(kernel/src/elf/reloc.rs's module header).

libc now hands out index + 1 and takes one off in dlsym and dlclose.
dlsym(RTLD_DEFAULT) panics as unimplemented instead of reading module 0,
and dlclose of a handle dlopen never returned answers -1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dlopen'ed library resolves only against other libraries, never the
executable's exports; and one host gate run on this branch reddened two
build-system tests on a TempDir removal that a plain rerun of the step did
not reproduce.

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

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #599 at 11b89c8 (high-risk: ABI, kernel ELF loader). Evidence: CI host green at this head; guest runs by the orchestrator at 11b89c8 (204_dl_iterate_phdr, query_modules_size, dlopen_dedup, std_unwind, abuse_elf_loader, abuse_elf_segments, Fast: EXIT=0; mutations a/b/c: EXIT=1; control c2: EXIT=0).

Net: +590 −40. Production +262 −14 (kernel +30 −2, toyos-abi +34 −5, toyos-elf +56 −4, libc +142 −3). Tests +262 −5. Issues +66 −21.

BLOCKER

  • kernel/src/loader/mod.rs:401, kernel/src/elf/mod.rs:364 — no test reaches either new refusal of untrusted input. Every crafted ELF in abuse_elf_loader/abuse_elf_segments either maps its table (base_exe's PT_LOAD at offset 0) or is refused earlier (rebase_base, vaddr 0). This mutation passes every suite: replace the let Some(phdrs) = layout.program_headers() else { log!(..); return Err(..) }; with let phdrs = layout.program_headers().expect("phdr table");, and replace .ok_or("ELF: no PT_LOAD maps the program header table")? with .expect("phdr table"). That is a kernel panic caused by a file. Add spawn_refused and dlopen_refused cases to abuse_elf_loader, on a 0x2000-byte ELF whose only PT_LOAD is Phdr::load(0x1000, 0, 0x1000, 0x1000, PF_R | PF_X) with entry 0. Its table at 0x40 lies outside every segment's file bytes, and vaddr_min is 0, so both earlier checks pass. Both cases must turn red under the patch.
  • kernel/src/loader/mod.rs:606 — the executable's phdr is right only because it adds image_start. Every executable the guest runs has vaddr_min 0, so image_start == base. This mutation passes 204: exe_phdrs: ((UserAddr::new(base) + phdrs.image().start().get()).raw(), phdrs.count()), which is wrong by vaddr_min for any other executable. It needs a guest check on an executable with a non-zero lowest vaddr, for example one linked with -Wl,--image-base=0x200000. Compare the reported phdr with a linker fact: 204's existing PT_PHDR/_DYNAMIC checks, or __ehdr_start + e_phoff. The check must turn red under that patch.

NOTE

  • toyos-abi/src/syscall.rs:2193 — phentsize has one value, because Layout refuses any e_phentsize but 56, and nothing reads it (libc's link.rs does not). A u64 phnum fills the same 8 bytes with no dead field, and the record stays 56 bytes with no padding.
  • issues/diagnostics/std-reads-a-query-modules-byte-count-as-a-module-count.md — the issue omits its sibling. rust/library/backtrace/src/symbolize/gimli/libs_toyos.rs misreads the byte count as a record count the same way, and computes text_end - base on the garbage records. So the PR body's claim that every consumer of ModuleInfo was checked is false. Name both sites and both exits.
  • issues/kernel/a-dlopened-library-never-binds-to-the-executables-exports.md — it has no exit condition.
  • userland/libc/src/misc.rs:456 — making dlsym(RTLD_DEFAULT) die loudly is right under CLAUDE.md, and libc's own longjmp stub panics the same way. But no issue records it as unimplemented; its natural home is the global-scope issue above.
  • Refusing an unmapped table: the 127-ELF llvm-readobj sweep is a one-off that nothing in the tree reproduces. The standing check covers in-tree ELFs only: every guest suite spawns or dlopens them, and a refusal would red it. Nothing measures foreign output. The rule matches musl's map_library, which takes the first PT_LOAD whose file bytes hold e_phoff..+phnum*phentsize. glibc copies an unmapped table instead, so ToyOS is stricter than glibc. That is acceptable as a refusal made by name.

REMOVE

  • toyos-abi/src/syscall.rs:2203-2206 — the rewritten "four u32s pair up after five u64s" sentence restates field counts that the next field moves, and the const assert already says it.
  • issues/build/two-build-system-tests-red-once-on-a-tempdir-something-still-wrote-into.md — the ENOTEMPTY failure in the compiler/sysroot git fixtures is what pr fixture: no auto maintenance, so no repack outlives the test into its TempDir #600 (bab6ed8, already on main) fixes, so the issue is stale once merged.
  • userland/libc/include/elf.h:4 — "The program header vocabulary <link.h> needs" is false: it also declares PT_INTERP, PT_SHLIB, Elf64_Sword and Elf64_Sxword.
  • PR body — "Guest tests at this head have not run" and the Unsure line about the second walk being unmeasured are both false at 11b89c8.

SEND BACK

Japabu and others added 3 commits September 29, 2026 11:03
…cutable off vaddr 0 checks its table

Review of 11b89c8, SEND BACK.

BLOCKER 1. abuse_elf_loader gains case 21, `phdrs_unmapped`: a 0x2000-byte
ELF whose only PT_LOAD is file 0x1000..0x2000 at vaddr 0, entry 0. The table
at e_phoff 0x40 lies in no segment's file bytes, and vaddr_min is 0, so
`rebase_base` and `load_shared_lib`'s vaddr-0 check pass it; the same bytes
are spawned (`spawn_refused`) and dlopened (`dlopen_refused`). toyos-elf's
crafted tests pin the premise on the host: `Layout::parse` accepts those
bytes with extent min 0 and no table.

BLOCKER 2. 205_dl_iterate_phdr_image_base is linked with
-Wl,--image-base=0x200000 (tests/common/compile.rs, LINK_FLAGS, keyed by case
name, applied in `link_toyos`, which every C link goes through). It compares
the executable's dlpi_phdr with __ehdr_start + e_phoff and dlpi_phnum with
e_phnum, both read PC-relative from the linker's own header, and __ehdr_start
with dlpi_addr + the lowest p_vaddr. It prints that lowest p_vaddr, so a flag
that stops reaching the case reds its .expect. Linked on the host with the
toolchain's clang: llvm-readobj -h -l gives e_phoff 0x40, 10 headers, first
PT_LOAD at offset 0 vaddr 0x200000, PT_PHDR at 0x200040; llvm-nm gives
__ehdr_start 0x200000. Corpus licence: 318 -> 320 files.

NOTEs.
- ModuleInfo drops `phentsize`: Layout refuses any e_phentsize but 56 and
  nothing read it. `phnum` becomes a u64: after six u64s the two u32 path
  fields fill one 8-byte slot, so the record stays 56 bytes with no padding,
  where a u32 phnum would have left 4 bytes of tail padding the const assert
  refuses.
- issues/diagnostics/std-reads-a-query-modules-byte-count-as-a-module-count.md
  names the backtrace crate's `native_libraries` beside std's `load_modules`,
  with both exits.
- issues/kernel/a-dlopened-library-never-binds-to-the-executables-exports.md
  gains its exit.
- issues/build/libc-dlsym-rtld-default-panics-as-unimplemented.md records
  dlsym(RTLD_DEFAULT) dying as unimplemented.

REMOVEs.
- The tempdir-flake issue goes: #600 (bab6ed8, merged in with origin/main
  7e15181) fixed the ENOTEMPTY it recorded.
- toyos-abi's field-count sentence over ModuleInfo's const assert goes.
- elf.h's opening comment goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its doc named relocation writes as what each case is, which the
unmapped-table case (21) is not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Negative-control patches for round 3, against af62dd5 (whole-change controls against the merge base 7e15181). Each one: git apply --check 0, apply 0, cargo run -- --build-only 0, git apply -R 0, git status --porcelain empty afterwards.

m1-refusals-panic.patch
diff --git a/kernel/src/elf/mod.rs b/kernel/src/elf/mod.rs
index 41618d3d..0bd74496 100644
--- a/kernel/src/elf/mod.rs
+++ b/kernel/src/elf/mod.rs
@@ -361,7 +361,7 @@ pub fn load_shared_lib(
         return Err("ELF: a shared object must begin at vaddr 0");
     }
     // `SYS_QUERY_MODULES` answers with the mapped table.
-    let phdrs = layout.program_headers().ok_or("ELF: no PT_LOAD maps the program header table")?;
+    let phdrs = layout.program_headers().expect("phdr table");
     // No writable segment yields an empty window; no relocation can target it.
     let (rw_lo, rw_hi) = layout.writable_window().unwrap_or((layout.span(), layout.span()));
 
diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs
index fffdbc7f..c7dc0a45 100644
--- a/kernel/src/loader/mod.rs
+++ b/kernel/src/loader/mod.rs
@@ -398,10 +398,7 @@ pub fn spawn(
     let image_start = UserAddr::new(USER_VM_BASE);
     // `SYS_QUERY_MODULES` answers with the mapped table, so an image without
     // one has no true answer to give.
-    let Some(phdrs) = layout.program_headers() else {
-        log!("spawn: {}: no PT_LOAD maps the program header table", path);
-        return Err(SyscallError::InvalidArgument.into());
-    };
+    let phdrs = layout.program_headers().expect("phdr table");
 
     let exe = read_exe_tables(backing.as_ref(), &layout, path)?;
     let t1 = crate::clock::nanos_since_boot();
m1b-dlopen-refusal-panics.patch
diff --git a/kernel/src/elf/mod.rs b/kernel/src/elf/mod.rs
index 41618d3d..0bd74496 100644
--- a/kernel/src/elf/mod.rs
+++ b/kernel/src/elf/mod.rs
@@ -361,7 +361,7 @@ pub fn load_shared_lib(
         return Err("ELF: a shared object must begin at vaddr 0");
     }
     // `SYS_QUERY_MODULES` answers with the mapped table.
-    let phdrs = layout.program_headers().ok_or("ELF: no PT_LOAD maps the program header table")?;
+    let phdrs = layout.program_headers().expect("phdr table");
     // No writable segment yields an empty window; no relocation can target it.
     let (rw_lo, rw_hi) = layout.writable_window().unwrap_or((layout.span(), layout.span()));
 
m2-exe-phdr-from-load-bias.patch
diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs
index fffdbc7f..8bde6a06 100644
--- a/kernel/src/loader/mod.rs
+++ b/kernel/src/loader/mod.rs
@@ -603,7 +603,7 @@ pub fn spawn(
                 .eh_frame_hdr()
                 .map_or((0, 0), |r| ((image_start + r.start().get()).raw(), r.len())),
             exe_vaddr_max: image_end,
-            exe_phdrs: ((image_start + phdrs.image().start().get()).raw(), phdrs.count()),
+            exe_phdrs: ((UserAddr::new(base) + phdrs.image().start().get()).raw(), phdrs.count()),
             lib_paths,
         },
         mmap_regions: Vec::new(),
mutation-a-kernel-phnum-zero.patch
diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs
index fb2b798f..9298c3d2 100644
--- a/kernel/src/syscall/vm.rs
+++ b/kernel/src/syscall/vm.rs
@@ -484,14 +484,14 @@ pub(super) fn sys_query_modules(out: &mut UserBytesMut) -> u64 {
         let mut path_offset = (module_count * info_size) as u32;
 
         let (eh_addr, eh_size) = data.elf.exe_eh_frame_hdr;
-        let (phdr, phnum) = data.elf.exe_phdrs;
+        let (phdr, _) = data.elf.exe_phdrs;
         let exe_info = ModuleInfo {
             base: data.elf.elf_base.raw(),
             text_end: data.elf.exe_vaddr_max,
             eh_frame_hdr: eh_addr,
             eh_frame_hdr_size: eh_size,
             phdr,
-            phnum: phnum.into(),
+            phnum: 0,
             path_offset,
             path_len: exe_path_bytes.len() as u32,
         };
@@ -511,7 +511,7 @@ pub(super) fn sys_query_modules(out: &mut UserBytesMut) -> u64 {
                 eh_frame_hdr: lib.eh_frame_hdr.map_or(0, |r| (lib.user_base + r.start().get()).raw()),
                 eh_frame_hdr_size: lib.eh_frame_hdr.map_or(0, |r| r.len()),
                 phdr: (lib.user_base + lib.phdrs.image().start().get()).raw(),
-                phnum: lib.phdrs.count().into(),
+                phnum: 0,
                 path_offset,
                 path_len: lib_path_bytes.len() as u32,
             };
mutation-b-libc-ignores-stop.patch
diff --git a/userland/libc/src/link.rs b/userland/libc/src/link.rs
index 7faffb24..32d1f347 100644
--- a/userland/libc/src/link.rs
+++ b/userland/libc/src/link.rs
@@ -40,7 +40,7 @@ pub unsafe extern "C" fn dl_iterate_phdr(callback: Callback, data: *mut u8) -> i
         // SAFETY: the caller's function, handed a record valid for the call.
         let stop = unsafe { callback(&mut info, core::mem::size_of::<DlPhdrInfo>(), data) };
         if stop != 0 {
-            return stop;
+            return 0;
         }
     }
     0
mutation-c-libc-handle-is-the-index.patch
diff --git b/userland/libc/src/misc.rs a/userland/libc/src/misc.rs
index 4443d6cd..fee86cf4 100644
--- b/userland/libc/src/misc.rs
+++ a/userland/libc/src/misc.rs
@@ -432,20 +432,13 @@ pub unsafe extern "C" fn longjmp(_env: *mut u8, _val: i32) -> ! {
 }
 
 // dlopen/dlsym/dlclose
-//
-// A C handle is the kernel's module index plus one: index 0 is a module, and
-// NULL is dlopen's failure.
-
-fn module_index(handle: *mut u8) -> Option<u64> {
-    (handle as u64).checked_sub(1)
-}
 
 #[no_mangle]
 pub unsafe extern "C" fn dlopen(path: *const u8, _flags: i32) -> *mut u8 {
     if path.is_null() { return ptr::null_mut(); }
     let path_bytes = super::posix_io::c_str_to_bytes(path);
     match syscall::dl_open(path_bytes) {
-        Ok(index) => (index + 1) as *mut u8,
+        Ok(handle) => handle as *mut u8,
         Err(_) => ptr::null_mut(),
     }
 }
@@ -453,10 +446,9 @@ pub unsafe extern "C" fn dlopen(path: *const u8, _flags: i32) -> *mut u8 {
 #[no_mangle]
 pub unsafe extern "C" fn dlsym(handle: *mut u8, symbol: *const u8) -> *mut u8 {
     if symbol.is_null() { return ptr::null_mut(); }
-    let index = module_index(handle).expect("dlsym: RTLD_DEFAULT not implemented");
     let name = super::posix_io::c_str_to_bytes(symbol);
     // SAFETY: handle is from a prior dlopen, name is a valid C string
-    match unsafe { syscall::dl_sym(index, name) } {
+    match unsafe { syscall::dl_sym(handle as u64, name) } {
         Ok(addr) => addr as *mut u8,
         Err(_) => ptr::null_mut(),
     }
@@ -464,10 +456,7 @@ pub unsafe extern "C" fn dlsym(handle: *mut u8, symbol: *const u8) -> *mut u8 {
 
 #[no_mangle]
 pub unsafe extern "C" fn dlclose(handle: *mut u8) -> i32 {
-    match module_index(handle) {
-        Some(index) => syscall::dl_close(index) as i32,
-        None => -1,
-    }
+    syscall::dl_close(handle as u64) as i32
 }
 
 #[no_mangle]
control-c1-revert-kernel-abi-elf-libc.patch
diff --git a/kernel/src/elf/cache.rs b/kernel/src/elf/cache.rs
index 96322295..7eb03a5d 100644
--- a/kernel/src/elf/cache.rs
+++ b/kernel/src/elf/cache.rs
@@ -22,7 +22,7 @@ use crate::vfs::BackingId;
 use crate::UserAddr;
 use toyos_elf::dynamic::InitArray;
 use toyos_elf::rela::Rules;
-use toyos_elf::{ImageRange, Op, ProgramHeaderTable, RelaCounts, RelocKind, SymIndex, TlsRef};
+use toyos_elf::{ImageRange, Op, RelaCounts, RelocKind, SymIndex, TlsRef};
 
 /// A module's non-`RELATIVE` relocations, parsed and extracted once at cache
 /// time, each as `(r_offset, what it names)`.
@@ -82,7 +82,6 @@ struct Snapshot {
     gnu_hash: Option<KernelSlice>,
     rules: Rules,
     eh_frame_hdr: Option<ImageRange>,
-    phdrs: ProgramHeaderTable,
     init_array: Option<InitArray>,
     span: u64,
     rw_lo: u64,
@@ -101,7 +100,6 @@ impl Snapshot {
             gnu_hash: lib.gnu_hash,
             rules: lib.rules,
             eh_frame_hdr: lib.eh_frame_hdr,
-            phdrs: lib.phdrs,
             init_array: lib.init_array,
             span: lib.span,
             rw_lo: lib.rw_lo,
@@ -129,7 +127,6 @@ impl Snapshot {
             cached_relocs,
             rules: self.rules,
             eh_frame_hdr: self.eh_frame_hdr,
-            phdrs: self.phdrs,
             init_array: self.init_array,
             span: self.span,
             rw_lo: self.rw_lo,
diff --git a/kernel/src/elf/mod.rs b/kernel/src/elf/mod.rs
index 41618d3d..9df1d73a 100644
--- a/kernel/src/elf/mod.rs
+++ b/kernel/src/elf/mod.rs
@@ -27,10 +27,7 @@ use crate::UserAddr;
 use toyos_elf::dynamic::{Dynamic, InitArray};
 use toyos_elf::section::{SectionTable, SHT_DYNSYM};
 use toyos_elf::sym::{Sym, SymTab};
-use toyos_elf::{
-    rela, Extent, GnuHash, ImageRange, Layout, ProgramHeaderTable, Rela, RelaTable, Reloc, RelocError, SymIndex,
-    TlsSegment,
-};
+use toyos_elf::{rela, Extent, GnuHash, ImageRange, Layout, Rela, RelaTable, Reloc, RelocError, SymIndex, TlsSegment};
 
 /// `toyos_elf::MAX_TLS_ALIGN` must equal the kernel's largest page.
 const _: () = assert!(toyos_elf::MAX_TLS_ALIGN == PAGE_2M);
@@ -104,8 +101,6 @@ pub struct LoadedLib {
     rules: rela::Rules,
     /// `PT_GNU_EH_FRAME`, for DWARF unwinding.
     pub eh_frame_hdr: Option<ImageRange>,
-    /// The program header table, which `load_shared_lib` refuses a module without.
-    pub phdrs: ProgramHeaderTable,
     /// `DT_INIT_ARRAY`.
     pub init_array: Option<InitArray>,
     /// Bytes between the image's lowest and highest virtual address.
@@ -360,8 +355,6 @@ pub fn load_shared_lib(
     if extent.min() != 0 {
         return Err("ELF: a shared object must begin at vaddr 0");
     }
-    // `SYS_QUERY_MODULES` answers with the mapped table.
-    let phdrs = layout.program_headers().ok_or("ELF: no PT_LOAD maps the program header table")?;
     // No writable segment yields an empty window; no relocation can target it.
     let (rw_lo, rw_hi) = layout.writable_window().unwrap_or((layout.span(), layout.span()));
 
@@ -520,7 +513,6 @@ pub fn load_shared_lib(
             cached_relocs: None,
             rules,
             eh_frame_hdr: layout.eh_frame_hdr(),
-            phdrs,
             init_array,
             span: layout.span(),
             rw_lo,
diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs
index fffdbc7f..ddd821de 100644
--- a/kernel/src/loader/mod.rs
+++ b/kernel/src/loader/mod.rs
@@ -396,12 +396,6 @@ pub fn spawn(
     // Where the image's first byte lands: every `ImageOffset` the file's
     // numbers were parsed into is added to it, and its span fits above it.
     let image_start = UserAddr::new(USER_VM_BASE);
-    // `SYS_QUERY_MODULES` answers with the mapped table, so an image without
-    // one has no true answer to give.
-    let Some(phdrs) = layout.program_headers() else {
-        log!("spawn: {}: no PT_LOAD maps the program header table", path);
-        return Err(SyscallError::InvalidArgument.into());
-    };
 
     let exe = read_exe_tables(backing.as_ref(), &layout, path)?;
     let t1 = crate::clock::nanos_since_boot();
@@ -603,7 +597,6 @@ pub fn spawn(
                 .eh_frame_hdr()
                 .map_or((0, 0), |r| ((image_start + r.start().get()).raw(), r.len())),
             exe_vaddr_max: image_end,
-            exe_phdrs: ((image_start + phdrs.image().start().get()).raw(), phdrs.count()),
             lib_paths,
         },
         mmap_regions: Vec::new(),
diff --git a/kernel/src/process.rs b/kernel/src/process.rs
index b747865c..f8680f7b 100644
--- a/kernel/src/process.rs
+++ b/kernel/src/process.rs
@@ -492,8 +492,6 @@ pub struct ElfInfo {
     pub exe_eh_frame_hdr: (u64, u64),
     /// One past the executable's last byte.
     pub exe_vaddr_max: u64,
-    /// The executable's program header table as (address, count), `(0, 0)` with no executable.
-    pub exe_phdrs: (u64, u16),
     /// Paths of dlopen'd libraries (parallel to loaded_libs).
     pub lib_paths: Vec<String>,
 }
@@ -512,7 +510,6 @@ impl ElfInfo {
             elf_base: UserAddr::new(0),
             exe_eh_frame_hdr: (0, 0),
             exe_vaddr_max: 0,
-            exe_phdrs: (0, 0),
             lib_paths: Vec::new(),
         }
     }
diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs
index fb2b798f..fb52c534 100644
--- a/kernel/src/syscall/vm.rs
+++ b/kernel/src/syscall/vm.rs
@@ -484,14 +484,11 @@ pub(super) fn sys_query_modules(out: &mut UserBytesMut) -> u64 {
         let mut path_offset = (module_count * info_size) as u32;
 
         let (eh_addr, eh_size) = data.elf.exe_eh_frame_hdr;
-        let (phdr, phnum) = data.elf.exe_phdrs;
         let exe_info = ModuleInfo {
             base: data.elf.elf_base.raw(),
             text_end: data.elf.exe_vaddr_max,
             eh_frame_hdr: eh_addr,
             eh_frame_hdr_size: eh_size,
-            phdr,
-            phnum: phnum.into(),
             path_offset,
             path_len: exe_path_bytes.len() as u32,
         };
@@ -510,8 +507,6 @@ pub(super) fn sys_query_modules(out: &mut UserBytesMut) -> u64 {
                 text_end: lib.user_end(),
                 eh_frame_hdr: lib.eh_frame_hdr.map_or(0, |r| (lib.user_base + r.start().get()).raw()),
                 eh_frame_hdr_size: lib.eh_frame_hdr.map_or(0, |r| r.len()),
-                phdr: (lib.user_base + lib.phdrs.image().start().get()).raw(),
-                phnum: lib.phdrs.count().into(),
                 path_offset,
                 path_len: lib_path_bytes.len() as u32,
             };
diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs
index 4a84a27c..62e20a02 100644
--- a/toyos-abi/src/syscall.rs
+++ b/toyos-abi/src/syscall.rs
@@ -2185,11 +2185,6 @@ pub struct ModuleInfo {
     pub eh_frame_hdr: u64,
     /// Size of `.eh_frame_hdr` in bytes.
     pub eh_frame_hdr_size: u64,
-    /// Absolute virtual address of the module's program header table: the
-    /// kernel loads no module whose table a `PT_LOAD` does not map.
-    pub phdr: u64,
-    /// Entries in that table, `e_phnum`: a `u64` so the record has no padding.
-    pub phnum: u64,
     /// Byte offset of the module's path string within the buffer.
     pub path_offset: u32,
     /// Length of the path string in bytes.
@@ -2199,9 +2194,11 @@ pub struct ModuleInfo {
 /// Every byte belongs to a field: this crosses the boundary through
 /// [`ModuleInfo::as_bytes`], so a gap would publish whatever the kernel stack
 /// held. **This is the type where that matters most**, because the buffer it
-/// is written into is a user address. A field of any other width added here
-/// reds here rather than publishing kernel stack bytes to userland.
-const _: () = assert!(core::mem::size_of::<ModuleInfo>() == 8 + 8 + 8 + 8 + 8 + 8 + 4 + 4);
+/// is written into is a user address: the two `u32`s sit at the end of four
+/// `u64`s, which is 8 bytes together at an 8-aligned offset, so there is no
+/// tail padding today — and this is what says so. A field of any other width
+/// added here reds here rather than publishing kernel stack bytes to userland.
+const _: () = assert!(core::mem::size_of::<ModuleInfo>() == 8 + 8 + 8 + 8 + 4 + 4);
 
 impl ModuleInfo {
     /// The record's own bytes, which is what `SYS_QUERY_MODULES` writes.
@@ -2236,28 +2233,6 @@ pub fn query_modules(buf: &mut [u8]) -> Result<usize, SyscallError> {
     check(syscall(SYS_QUERY_MODULES, buf.as_mut_ptr() as u64, buf.len() as u64, 0, 0)).map(|n| n as usize)
 }
 
-/// Every record of one [`query_modules`] answer with its path, executable
-/// first: `answer` is the `n` bytes the call reported.
-///
-/// A record or path outside `answer` is a kernel that broke the layout above,
-/// and panics.
-pub fn modules(answer: &[u8]) -> impl Iterator<Item = (ModuleInfo, &[u8])> {
-    let size = core::mem::size_of::<ModuleInfo>();
-    let record = move |i: usize| {
-        let bytes = &answer[i * size..(i + 1) * size];
-        // SAFETY: `bytes` holds `size_of::<ModuleInfo>()` bytes, read without
-        // an alignment requirement, and every bit pattern of its integer
-        // fields is a `ModuleInfo`.
-        unsafe { (bytes.as_ptr() as *const ModuleInfo).read_unaligned() }
-    };
-    let count = if answer.is_empty() { 0 } else { record(0).path_offset as usize / size };
-    (0..count).map(move |i| {
-        let info = record(i);
-        let path = info.path_offset as usize;
-        (info, &answer[path..path + info.path_len as usize])
-    })
-}
-
 /// Scheduler info for the calling process.
 #[repr(C)]
 #[derive(Clone, Copy, Debug)]
@@ -2342,70 +2317,17 @@ mod tests {
             text_end: 0x2233_4455_6677_8899,
             eh_frame_hdr: 0x3344_5566_7788_99aa,
             eh_frame_hdr_size: 0x44,
-            phdr: 0x5566_7788_99aa_bbcc,
-            phnum: 0x77,
             path_offset: 0x55,
             path_len: 0x66,
         };
         let b = info.as_bytes();
-        assert_eq!(b.len(), 56);
+        assert_eq!(b.len(), 40);
         assert_eq!(u64::from_ne_bytes(b[0..8].try_into().unwrap()), info.base);
         assert_eq!(u64::from_ne_bytes(b[8..16].try_into().unwrap()), info.text_end);
         assert_eq!(u64::from_ne_bytes(b[16..24].try_into().unwrap()), info.eh_frame_hdr);
         assert_eq!(u64::from_ne_bytes(b[24..32].try_into().unwrap()), info.eh_frame_hdr_size);
-        assert_eq!(u64::from_ne_bytes(b[32..40].try_into().unwrap()), info.phdr);
-        assert_eq!(u64::from_ne_bytes(b[40..48].try_into().unwrap()), info.phnum);
-        assert_eq!(u32::from_ne_bytes(b[48..52].try_into().unwrap()), info.path_offset);
-        assert_eq!(u32::from_ne_bytes(b[52..56].try_into().unwrap()), info.path_len);
-    }
-
-    fn record(base: u64, path_offset: u32, path_len: u32) -> ModuleInfo {
-        ModuleInfo {
-            base,
-            text_end: base + 0x1000,
-            eh_frame_hdr: 0,
-            eh_frame_hdr_size: 0,
-            phdr: base + 0x40,
-            phnum: 3,
-            path_offset,
-            path_len,
-        }
-    }
-
-    /// An answer laid out as the kernel writes one — records, then the paths
-    /// packed in module order — decodes to those records and paths, in order,
-    /// at an offset whatever alignment the buffer has.
-    #[test]
-    fn modules_decodes_every_record_and_its_path() {
-        let size = core::mem::size_of::<ModuleInfo>() as u32;
-        let exe = record(0x100_0000_0000, 2 * size, 9);
-        let lib = record(0x200_0000_0000, 2 * size + 9, 13);
-        let mut answer = [0u8; 1 + 2 * 56 + 9 + 13];
-        let wire = &mut answer[1..];
-        wire[..56].copy_from_slice(exe.as_bytes());
-        wire[56..112].copy_from_slice(lib.as_bytes());
-        wire[112..121].copy_from_slice(b"/bin/prog");
-        wire[121..].copy_from_slice(b"/lib/libx.so\0");
-        let got: [(u64, u64, &[u8]); 2] = {
-            let mut it = modules(&answer[1..]).map(|(m, path)| (m.base, m.phdr, path));
-            let pair = [it.next().unwrap(), it.next().unwrap()];
-            assert!(it.next().is_none(), "two records, and no third read out of the paths");
-            pair
-        };
-        assert_eq!(got[0], (exe.base, exe.phdr, &b"/bin/prog"[..]));
-        assert_eq!(got[1], (lib.base, lib.phdr, &b"/lib/libx.so\0"[..]));
-        assert_eq!(modules(&[]).count(), 0);
-    }
-
-    /// A path the kernel says runs past its own answer is a broken layout,
-    /// not a shorter name.
-    #[test]
-    #[should_panic]
-    fn modules_refuses_a_path_past_the_answer() {
-        let size = core::mem::size_of::<ModuleInfo>() as u32;
-        let mut answer = [0u8; 56 + 4];
-        answer[..56].copy_from_slice(record(0, size, 5).as_bytes());
-        modules(&answer).for_each(drop);
+        assert_eq!(u32::from_ne_bytes(b[32..36].try_into().unwrap()), info.path_offset);
+        assert_eq!(u32::from_ne_bytes(b[36..40].try_into().unwrap()), info.path_len);
     }
 
     /// Four lowercase hex digits each and nothing else, because two spellings
diff --git a/toyos-elf/src/layout.rs b/toyos-elf/src/layout.rs
index 397acacd..c51bab16 100644
--- a/toyos-elf/src/layout.rs
+++ b/toyos-elf/src/layout.rs
@@ -10,8 +10,8 @@
 //! address it placed the image at. A raw `p_vaddr` never leaves this module.
 
 use crate::header::{
-    FileHeader, Machine, ProgramHeader, PROGRAM_HEADER_SIZE, PT_DYNAMIC, PT_GNU_EH_FRAME, PT_LOAD,
-    PT_TLS, SECTION_HEADER_SIZE,
+    FileHeader, Machine, ProgramHeader, PT_DYNAMIC, PT_GNU_EH_FRAME, PT_LOAD, PT_TLS,
+    SECTION_HEADER_SIZE,
 };
 use crate::{Error, MAX_LOAD_SEGMENTS, MAX_TLS_ALIGN};
 
@@ -237,29 +237,6 @@ impl DynamicSegment {
     }
 }
 
-/// Where the loaded image holds its own program header table.
-///
-/// Derived from where a `PT_LOAD` copies `e_phoff` out of the file, not from
-/// `PT_PHDR`: that header is the file's claim about this, and this is what the
-/// loader actually puts there.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct ProgramHeaderTable {
-    image: ImageRange,
-    count: u16,
-}
-
-impl ProgramHeaderTable {
-    /// The table's bytes, inside the file-backed part of one `PT_LOAD`.
-    pub const fn image(&self) -> ImageRange {
-        self.image
-    }
-
-    /// `e_phnum`, at least one.
-    pub const fn count(&self) -> u16 {
-        self.count
-    }
-}
-
 /// Where the section header table is, when the file has a usable one.
 ///
 /// Section headers are optional metadata — symbol names for backtraces, and
@@ -294,8 +271,7 @@ impl SectionTableRef {
 /// - the extent runs from the smallest `p_vaddr` to the largest
 ///   `p_vaddr + p_memsz` over those segments, so it covers every one of them;
 /// - the entry point, the file-backed extent of `PT_TLS`, and all of
-///   `PT_DYNAMIC` and `PT_GNU_EH_FRAME`, lie inside the extent, and the
-///   program header table, when one is held, inside one `PT_LOAD`'s file bytes;
+///   `PT_DYNAMIC` and `PT_GNU_EH_FRAME`, lie inside the extent;
 /// - the TLS alignment is zero or a power of two no larger than
 ///   [`MAX_TLS_ALIGN`], so that `!(align - 1)` is a mask and the TLS block's
 ///   size cannot be dominated by a number the file chose.
@@ -313,7 +289,6 @@ pub struct Layout {
     dynamic: Option<DynamicSegment>,
     section_headers: Option<SectionTableRef>,
     eh_frame_hdr: Option<ImageRange>,
-    program_headers: Option<ProgramHeaderTable>,
 }
 
 impl Layout {
@@ -435,7 +410,6 @@ impl Layout {
             None => None,
             Some(e) => Some(extent.range(e.vaddr, e.memsz).ok_or(Error::EhFrameOutsideImage)?),
         };
-        let program_headers = program_header_table(&ehdr, segments.get(..placed).unwrap_or(&[]));
 
         Ok(Layout {
             extent,
@@ -446,7 +420,6 @@ impl Layout {
             dynamic,
             section_headers: section_table(&ehdr),
             eh_frame_hdr,
-            program_headers,
         })
     }
 
@@ -487,13 +460,6 @@ impl Layout {
         self.eh_frame_hdr
     }
 
-    /// Where the loaded image holds the program header table, or `None` when
-    /// no `PT_LOAD`'s file bytes hold all of it and the table exists only in
-    /// the file.
-    pub const fn program_headers(&self) -> Option<ProgramHeaderTable> {
-        self.program_headers
-    }
-
     /// The writable window `[lo, hi)` in image offsets, or `None` when no
     /// segment is writable.
     ///
@@ -594,24 +560,6 @@ impl Layout {
     }
 }
 
-/// The program header table as the first `PT_LOAD` whose file bytes hold all
-/// of it places it in the image.
-///
-/// Only `p_filesz` counts: past it a segment is zeroes, not the file.
-fn program_header_table(ehdr: &FileHeader, segments: &[Segment]) -> Option<ProgramHeaderTable> {
-    let len = u64::from(ehdr.phnum).checked_mul(PROGRAM_HEADER_SIZE as u64)?;
-    segments.iter().find_map(|seg| {
-        let within = ehdr.phoff.checked_sub(seg.file_offset)?;
-        if within.checked_add(len)? > seg.filesz {
-            return None;
-        }
-        // Inside the segment's own range, which the extent holds: `within +
-        // len <= filesz <= memsz`.
-        let start = seg.image.start.checked_add(within)?;
-        Some(ProgramHeaderTable { image: ImageRange { start, len }, count: ehdr.phnum })
-    })
-}
-
 /// A section header table the loader can index, or `None`.
 ///
 /// `e_shentsize` must be exactly 64: consumers divide a byte count by it and
diff --git a/toyos-elf/src/lib.rs b/toyos-elf/src/lib.rs
index 1d9685d7..46ee094f 100644
--- a/toyos-elf/src/lib.rs
+++ b/toyos-elf/src/lib.rs
@@ -47,7 +47,7 @@ pub use gnu_hash::GnuHash;
 pub use header::{FileHeader, Machine};
 pub use layout::{
     DynamicSegment, Extent, ImageOffset, ImageRange, Layout, Segment, SegmentFlags,
-    ProgramHeaderTable, SectionTableRef, StackedImage, TlsSegment,
+    SectionTableRef, StackedImage, TlsSegment,
 };
 pub use rela::{Op, Rela, RelaCounts, RelaTable, Reloc, RelocError, RelocKind, Rules, TlsRef};
 pub use section::{SectionHeader, SectionTable};
diff --git a/toyos-elf/tests/crafted.rs b/toyos-elf/tests/crafted.rs
index 4d44324f..baff9336 100644
--- a/toyos-elf/tests/crafted.rs
+++ b/toyos-elf/tests/crafted.rs
@@ -347,56 +347,6 @@ fn the_file_bytes_behind_a_vaddr_are_the_containing_segments() {
     assert_eq!(layout.file_bytes_from(0x2400), Some(0x400));
 }
 
-/// `(image offset, bytes, count)` of the table a layout places, for comparing.
-fn table_of(layout: &Layout) -> Option<(u64, u64, u16)> {
-    layout.program_headers().map(|t| (t.image().start().get(), t.image().len(), t.count()))
-}
-
-/// The table is where the segment holding its file bytes puts them, measured
-/// from the image's lowest address, whichever segment that is.
-#[test]
-fn the_program_header_table_is_where_its_segment_places_it() {
-    let two = 2 * PROGRAM_HEADER_SIZE as u64;
-    let at_zero = accepted(Elf::new(0x1000).ph(Phdr::load(0, 0, 0x1000, 0x1000, PF_R)).ph(Phdr::tls(0x800, 0, 8, 8)).build());
-    assert_eq!(table_of(&at_zero), Some((PH_OFF as u64, two, 2)));
-
-    // The first segment does not hold the table and the second does, at a
-    // vaddr above the first's: the offset is from the extent's minimum.
-    let second = accepted(
-        Elf::new(0x2000)
-            .entry(0x1000)
-            .ph(Phdr::load(0x1000, 0x1000, 0x1000, 0x1000, PF_R | PF_X))
-            .ph(Phdr::load(0, 0x10_0000, 0x1000, 0x1000, PF_R))
-            .build(),
-    );
-    assert_eq!(table_of(&second), Some((0x10_0000 + PH_OFF as u64 - 0x1000, two, 2)));
-}
-
-/// Held only where the file's bytes are: a segment whose file image stops
-/// inside the table, or one that holds it only as zero-filled memory, holds
-/// no table.
-#[test]
-fn a_table_no_segment_holds_whole_is_absent() {
-    let one = PROGRAM_HEADER_SIZE as u64;
-    let short = PH_OFF as u64 + one;
-    // Two headers; the file image ends one header in.
-    let cut = Elf::new(0x1000).ph(Phdr::load(0, 0, short, 0x1000, PF_R)).ph(Phdr::tls(0x800, 0, 8, 8));
-    assert_eq!(table_of(&accepted(cut.build())), None);
-    // Exactly the table's end and it is held.
-    let whole = Elf::new(0x1000).ph(Phdr::load(0, 0, short + one, 0x1000, PF_R)).ph(Phdr::tls(0x800, 0, 8, 8));
-    assert_eq!(table_of(&accepted(whole.build())), Some((PH_OFF as u64, 2 * one, 2)));
-    // Memory but no file bytes.
-    let zeroes = Elf::new(0x1000).ph(Phdr::load(0, 0, 0, 0x1000, PF_R));
-    assert_eq!(table_of(&accepted(zeroes.build())), None);
-    // A segment that starts past the table's first byte.
-    let after = Elf::new(0x2000).entry(0x1000).ph(Phdr::load(PH_OFF as u64 + 8, 0x1000, 0x1000, 0x1000, PF_R));
-    assert_eq!(table_of(&accepted(after.build())), None);
-    // And one at vaddr 0, so a loader's vaddr-0 checks pass and only the table
-    // refuses it: `abuse_elf_loader`'s `phdrs_unmapped`.
-    let unmapped = accepted(Elf::new(0x2000).ph(Phdr::load(0x1000, 0, 0x1000, 0x1000, PF_R | PF_X)).build());
-    assert_eq!((unmapped.extent().min(), table_of(&unmapped)), (0, None));
-}
-
 #[test]
 fn the_writable_window_spans_every_writable_segment() {
     let layout = accepted(
diff --git a/toyos-elf/tests/fuzz.rs b/toyos-elf/tests/fuzz.rs
index 2314fb05..871ba397 100644
--- a/toyos-elf/tests/fuzz.rs
+++ b/toyos-elf/tests/fuzz.rs
@@ -309,9 +309,6 @@ fn load(case: &Case, placement: Placement, reached: &mut Reached) -> Result<(),
         assert!(offset <= span, "offset {offset:#x} past the span {span:#x}");
         image_start.checked_add(offset).expect("an offset inside the span leaves the placement")
     };
-    if let Some(table) = layout.program_headers() {
-        place(table.image().end().get());
-    }
 
     let dyn_bytes = at(&case.bytes, layout.dynamic().ok_or(())?.image());
     let dynamic = Dynamic::parse(dyn_bytes);
diff --git a/toyos-elf/tests/real.rs b/toyos-elf/tests/real.rs
index 3fe96367..6da7bdaa 100644
--- a/toyos-elf/tests/real.rs
+++ b/toyos-elf/tests/real.rs
@@ -37,11 +37,6 @@ fn a_toyos_ld_binary_parses_to_what_readelf_says() {
     assert_eq!(layout.tls().unwrap().memsz(), 0x90);
     assert_eq!(layout.tls().unwrap().align(), 0x40);
 
-    // `e_phoff` 0x40, six headers of 56 bytes, inside the text segment's file
-    // bytes at offset 0.
-    let table = layout.program_headers().map(|t| (t.image().start().get(), t.image().len(), t.count()));
-    assert_eq!(table, Some((0x40, 6 * 56, 6)));
-
     let sections = layout.section_headers().expect("a section header table");
     assert_eq!((sections.count, sections.entry_size), (9, 64));
 
diff --git a/userland/libc/include/elf.h b/userland/libc/include/elf.h
deleted file mode 100644
index e6ff35bb..00000000
--- a/userland/libc/include/elf.h
+++ /dev/null
@@ -1,41 +0,0 @@
-#ifndef _ELF_H
-#define _ELF_H
-
-#include <stdint.h>
-
-typedef uint64_t Elf64_Addr;
-typedef uint64_t Elf64_Off;
-typedef uint16_t Elf64_Half;
-typedef uint32_t Elf64_Word;
-typedef int32_t Elf64_Sword;
-typedef uint64_t Elf64_Xword;
-typedef int64_t Elf64_Sxword;
-
-typedef struct {
-    Elf64_Word p_type;
-    Elf64_Word p_flags;
-    Elf64_Off p_offset;
-    Elf64_Addr p_vaddr;
-    Elf64_Addr p_paddr;
-    Elf64_Xword p_filesz;
-    Elf64_Xword p_memsz;
-    Elf64_Xword p_align;
-} Elf64_Phdr;
-
-#define PT_NULL         0
-#define PT_LOAD         1
-#define PT_DYNAMIC      2
-#define PT_INTERP       3
-#define PT_NOTE         4
-#define PT_SHLIB        5
-#define PT_PHDR         6
-#define PT_TLS          7
-#define PT_GNU_EH_FRAME 0x6474e550
-#define PT_GNU_STACK    0x6474e551
-#define PT_GNU_RELRO    0x6474e552
-
-#define PF_X 0x1
-#define PF_W 0x2
-#define PF_R 0x4
-
-#endif
diff --git a/userland/libc/include/link.h b/userland/libc/include/link.h
deleted file mode 100644
index 2eef0a32..00000000
--- a/userland/libc/include/link.h
+++ /dev/null
@@ -1,22 +0,0 @@
-#ifndef _LINK_H
-#define _LINK_H
-
-#include <elf.h>
-#include <stddef.h>
-
-#define ElfW(type) Elf64_##type
-
-/* The first four fields of glibc's layout. There is no dlpi_adds or
-   dlpi_subs, so the size a callback is passed ends at dlpi_phnum.
-   dlpi_name is "" for the executable, and lives only until the callback
-   returns. */
-struct dl_phdr_info {
-    ElfW(Addr) dlpi_addr;
-    const char *dlpi_name;
-    const ElfW(Phdr) *dlpi_phdr;
-    ElfW(Half) dlpi_phnum;
-};
-
-int dl_iterate_phdr(int (*callback)(struct dl_phdr_info *info, size_t size, void *data), void *data);
-
-#endif
diff --git a/userland/libc/src/lib.rs b/userland/libc/src/lib.rs
index 1052461c..9acf206a 100644
--- a/userland/libc/src/lib.rs
+++ b/userland/libc/src/lib.rs
@@ -6,7 +6,6 @@ extern crate alloc;
 mod arch;
 mod ctype;
 mod errno;
-mod link;
 mod math;
 mod memory;
 mod misc;
diff --git a/userland/libc/src/link.rs b/userland/libc/src/link.rs
deleted file mode 100644
index 7faffb24..00000000
--- a/userland/libc/src/link.rs
+++ /dev/null
@@ -1,61 +0,0 @@
-//! `dl_iterate_phdr`, from the kernel's `SYS_QUERY_MODULES` answer.
-//!
-//! The contract is glibc's `dl_iterate_phdr(3)`, which the LSB adopts: the
-//! executable first, named by the empty string, then every library in load
-//! order; the walk stops at the first callback that returns non-zero and
-//! returns that value, else 0. The walk is over one answer, so a module a
-//! callback or another thread loads meanwhile is not visited.
-
-use alloc::vec::Vec;
-
-use toyos_abi::syscall;
-
-/// `struct dl_phdr_info`, as `link.h` declares it.
-#[repr(C)]
-pub struct DlPhdrInfo {
-    dlpi_addr: u64,
-    dlpi_name: *const u8,
-    dlpi_phdr: *const u8,
-    dlpi_phnum: u16,
-}
-
-type Callback = unsafe extern "C" fn(*mut DlPhdrInfo, usize, *mut u8) -> i32;
-
-#[no_mangle]
-pub unsafe extern "C" fn dl_iterate_phdr(callback: Callback, data: *mut u8) -> i32 {
-    let answer = answer();
-    let mut name = Vec::new();
-    for (i, (module, path)) in syscall::modules(&answer).enumerate() {
-        name.clear();
-        if i > 0 {
-            name.extend_from_slice(path);
-        }
-        name.push(0);
-        let mut info = DlPhdrInfo {
-            dlpi_addr: module.base,
-            dlpi_name: name.as_ptr(),
-            dlpi_phdr: module.phdr as *const u8,
-            dlpi_phnum: u16::try_from(module.phnum).expect("SYS_QUERY_MODULES: a phnum past e_phnum's u16"),
-        };
-        // SAFETY: the caller's function, handed a record valid for the call.
-        let stop = unsafe { callback(&mut info, core::mem::size_of::<DlPhdrInfo>(), data) };
-        if stop != 0 {
-            return stop;
-        }
-    }
-    0
-}
-
-/// One whole `SYS_QUERY_MODULES` answer: asked again while a `dlopen`
-/// elsewhere grows it between the size and the read.
-fn answer() -> Vec<u8> {
-    let mut buf = Vec::new();
-    loop {
-        let need = syscall::query_modules(&mut buf).expect("SYS_QUERY_MODULES refused a buffer this process owns");
-        if need <= buf.len() {
-            buf.truncate(need);
-            return buf;
-        }
-        buf.resize(need, 0);
-    }
-}
diff --git a/userland/libc/src/misc.rs b/userland/libc/src/misc.rs
index 4443d6cd..fee86cf4 100644
--- a/userland/libc/src/misc.rs
+++ b/userland/libc/src/misc.rs
@@ -432,20 +432,13 @@ pub unsafe extern "C" fn longjmp(_env: *mut u8, _val: i32) -> ! {
 }
 
 // dlopen/dlsym/dlclose
-//
-// A C handle is the kernel's module index plus one: index 0 is a module, and
-// NULL is dlopen's failure.
-
-fn module_index(handle: *mut u8) -> Option<u64> {
-    (handle as u64).checked_sub(1)
-}
 
 #[no_mangle]
 pub unsafe extern "C" fn dlopen(path: *const u8, _flags: i32) -> *mut u8 {
     if path.is_null() { return ptr::null_mut(); }
     let path_bytes = super::posix_io::c_str_to_bytes(path);
     match syscall::dl_open(path_bytes) {
-        Ok(index) => (index + 1) as *mut u8,
+        Ok(handle) => handle as *mut u8,
         Err(_) => ptr::null_mut(),
     }
 }
@@ -453,10 +446,9 @@ pub unsafe extern "C" fn dlopen(path: *const u8, _flags: i32) -> *mut u8 {
 #[no_mangle]
 pub unsafe extern "C" fn dlsym(handle: *mut u8, symbol: *const u8) -> *mut u8 {
     if symbol.is_null() { return ptr::null_mut(); }
-    let index = module_index(handle).expect("dlsym: RTLD_DEFAULT not implemented");
     let name = super::posix_io::c_str_to_bytes(symbol);
     // SAFETY: handle is from a prior dlopen, name is a valid C string
-    match unsafe { syscall::dl_sym(index, name) } {
+    match unsafe { syscall::dl_sym(handle as u64, name) } {
         Ok(addr) => addr as *mut u8,
         Err(_) => ptr::null_mut(),
     }
@@ -464,10 +456,7 @@ pub unsafe extern "C" fn dlsym(handle: *mut u8, symbol: *const u8) -> *mut u8 {
 
 #[no_mangle]
 pub unsafe extern "C" fn dlclose(handle: *mut u8) -> i32 {
-    match module_index(handle) {
-        Some(index) => syscall::dl_close(index) as i32,
-        None => -1,
-    }
+    syscall::dl_close(handle as u64) as i32
 }
 
 #[no_mangle]
control-c2-revert-and-drop-cases.patch
diff --git a/kernel/src/elf/cache.rs b/kernel/src/elf/cache.rs
index 96322295..7eb03a5d 100644
--- a/kernel/src/elf/cache.rs
+++ b/kernel/src/elf/cache.rs
@@ -22,7 +22,7 @@ use crate::vfs::BackingId;
 use crate::UserAddr;
 use toyos_elf::dynamic::InitArray;
 use toyos_elf::rela::Rules;
-use toyos_elf::{ImageRange, Op, ProgramHeaderTable, RelaCounts, RelocKind, SymIndex, TlsRef};
+use toyos_elf::{ImageRange, Op, RelaCounts, RelocKind, SymIndex, TlsRef};
 
 /// A module's non-`RELATIVE` relocations, parsed and extracted once at cache
 /// time, each as `(r_offset, what it names)`.
@@ -82,7 +82,6 @@ struct Snapshot {
     gnu_hash: Option<KernelSlice>,
     rules: Rules,
     eh_frame_hdr: Option<ImageRange>,
-    phdrs: ProgramHeaderTable,
     init_array: Option<InitArray>,
     span: u64,
     rw_lo: u64,
@@ -101,7 +100,6 @@ impl Snapshot {
             gnu_hash: lib.gnu_hash,
             rules: lib.rules,
             eh_frame_hdr: lib.eh_frame_hdr,
-            phdrs: lib.phdrs,
             init_array: lib.init_array,
             span: lib.span,
             rw_lo: lib.rw_lo,
@@ -129,7 +127,6 @@ impl Snapshot {
             cached_relocs,
             rules: self.rules,
             eh_frame_hdr: self.eh_frame_hdr,
-            phdrs: self.phdrs,
             init_array: self.init_array,
             span: self.span,
             rw_lo: self.rw_lo,
diff --git a/kernel/src/elf/mod.rs b/kernel/src/elf/mod.rs
index 41618d3d..9df1d73a 100644
--- a/kernel/src/elf/mod.rs
+++ b/kernel/src/elf/mod.rs
@@ -27,10 +27,7 @@ use crate::UserAddr;
 use toyos_elf::dynamic::{Dynamic, InitArray};
 use toyos_elf::section::{SectionTable, SHT_DYNSYM};
 use toyos_elf::sym::{Sym, SymTab};
-use toyos_elf::{
-    rela, Extent, GnuHash, ImageRange, Layout, ProgramHeaderTable, Rela, RelaTable, Reloc, RelocError, SymIndex,
-    TlsSegment,
-};
+use toyos_elf::{rela, Extent, GnuHash, ImageRange, Layout, Rela, RelaTable, Reloc, RelocError, SymIndex, TlsSegment};
 
 /// `toyos_elf::MAX_TLS_ALIGN` must equal the kernel's largest page.
 const _: () = assert!(toyos_elf::MAX_TLS_ALIGN == PAGE_2M);
@@ -104,8 +101,6 @@ pub struct LoadedLib {
     rules: rela::Rules,
     /// `PT_GNU_EH_FRAME`, for DWARF unwinding.
     pub eh_frame_hdr: Option<ImageRange>,
-    /// The program header table, which `load_shared_lib` refuses a module without.
-    pub phdrs: ProgramHeaderTable,
     /// `DT_INIT_ARRAY`.
     pub init_array: Option<InitArray>,
     /// Bytes between the image's lowest and highest virtual address.
@@ -360,8 +355,6 @@ pub fn load_shared_lib(
     if extent.min() != 0 {
         return Err("ELF: a shared object must begin at vaddr 0");
     }
-    // `SYS_QUERY_MODULES` answers with the mapped table.
-    let phdrs = layout.program_headers().ok_or("ELF: no PT_LOAD maps the program header table")?;
     // No writable segment yields an empty window; no relocation can target it.
     let (rw_lo, rw_hi) = layout.writable_window().unwrap_or((layout.span(), layout.span()));
 
@@ -520,7 +513,6 @@ pub fn load_shared_lib(
             cached_relocs: None,
             rules,
             eh_frame_hdr: layout.eh_frame_hdr(),
-            phdrs,
             init_array,
             span: layout.span(),
             rw_lo,
diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs
index fffdbc7f..ddd821de 100644
--- a/kernel/src/loader/mod.rs
+++ b/kernel/src/loader/mod.rs
@@ -396,12 +396,6 @@ pub fn spawn(
     // Where the image's first byte lands: every `ImageOffset` the file's
     // numbers were parsed into is added to it, and its span fits above it.
     let image_start = UserAddr::new(USER_VM_BASE);
-    // `SYS_QUERY_MODULES` answers with the mapped table, so an image without
-    // one has no true answer to give.
-    let Some(phdrs) = layout.program_headers() else {
-        log!("spawn: {}: no PT_LOAD maps the program header table", path);
-        return Err(SyscallError::InvalidArgument.into());
-    };
 
     let exe = read_exe_tables(backing.as_ref(), &layout, path)?;
     let t1 = crate::clock::nanos_since_boot();
@@ -603,7 +597,6 @@ pub fn spawn(
                 .eh_frame_hdr()
                 .map_or((0, 0), |r| ((image_start + r.start().get()).raw(), r.len())),
             exe_vaddr_max: image_end,
-            exe_phdrs: ((image_start + phdrs.image().start().get()).raw(), phdrs.count()),
             lib_paths,
         },
         mmap_regions: Vec::new(),
diff --git a/kernel/src/process.rs b/kernel/src/process.rs
index b747865c..f8680f7b 100644
--- a/kernel/src/process.rs
+++ b/kernel/src/process.rs
@@ -492,8 +492,6 @@ pub struct ElfInfo {
     pub exe_eh_frame_hdr: (u64, u64),
     /// One past the executable's last byte.
     pub exe_vaddr_max: u64,
-    /// The executable's program header table as (address, count), `(0, 0)` with no executable.
-    pub exe_phdrs: (u64, u16),
     /// Paths of dlopen'd libraries (parallel to loaded_libs).
     pub lib_paths: Vec<String>,
 }
@@ -512,7 +510,6 @@ impl ElfInfo {
             elf_base: UserAddr::new(0),
             exe_eh_frame_hdr: (0, 0),
             exe_vaddr_max: 0,
-            exe_phdrs: (0, 0),
             lib_paths: Vec::new(),
         }
     }
diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs
index fb2b798f..fb52c534 100644
--- a/kernel/src/syscall/vm.rs
+++ b/kernel/src/syscall/vm.rs
@@ -484,14 +484,11 @@ pub(super) fn sys_query_modules(out: &mut UserBytesMut) -> u64 {
         let mut path_offset = (module_count * info_size) as u32;
 
         let (eh_addr, eh_size) = data.elf.exe_eh_frame_hdr;
-        let (phdr, phnum) = data.elf.exe_phdrs;
         let exe_info = ModuleInfo {
             base: data.elf.elf_base.raw(),
             text_end: data.elf.exe_vaddr_max,
             eh_frame_hdr: eh_addr,
             eh_frame_hdr_size: eh_size,
-            phdr,
-            phnum: phnum.into(),
             path_offset,
             path_len: exe_path_bytes.len() as u32,
         };
@@ -510,8 +507,6 @@ pub(super) fn sys_query_modules(out: &mut UserBytesMut) -> u64 {
                 text_end: lib.user_end(),
                 eh_frame_hdr: lib.eh_frame_hdr.map_or(0, |r| (lib.user_base + r.start().get()).raw()),
                 eh_frame_hdr_size: lib.eh_frame_hdr.map_or(0, |r| r.len()),
-                phdr: (lib.user_base + lib.phdrs.image().start().get()).raw(),
-                phnum: lib.phdrs.count().into(),
                 path_offset,
                 path_len: lib_path_bytes.len() as u32,
             };
diff --git a/tests/testcases/LICENSE b/tests/testcases/LICENSE
index 2e02f9cc..8eb68294 100644
--- a/tests/testcases/LICENSE
+++ b/tests/testcases/LICENSE
@@ -22,9 +22,9 @@ against tinycc upstream at 64552b3faa39ee7948a9ea21bfcc11045b90c70d
 (repo.or.cz/tinycc.git, 2026-08-05), allowing for the `-` → `_` renames this
 project made to some filenames.
 
-  tinycc/    320 files: 239 byte-identical to tinycc's `tests/tests2`,
+  tinycc/    316 files: 239 byte-identical to tinycc's `tests/tests2`,
                         17 tinycc files this project modified,
-                        64 not upstream at all — 63 cases written here, plus
+                        60 not upstream at all — 59 cases written here, plus
                         `fred.txt`, which is output `40_stdio.c` writes when it
                         runs and which was committed by accident.
 
diff --git a/tests/testcases/tinycc/204_dl_iterate_phdr.c b/tests/testcases/tinycc/204_dl_iterate_phdr.c
deleted file mode 100644
index d0f9b152..00000000
--- a/tests/testcases/tinycc/204_dl_iterate_phdr.c
+++ /dev/null
@@ -1,139 +0,0 @@
-/* dl_iterate_phdr visits the executable and every loaded library with the
-   program headers the loader mapped, and stops where its callback says.
-   Each module's addresses are checked against where its own code and
-   _DYNAMIC really are, not against the kernel's other answers. */
-
-#include <dlfcn.h>
-#include <link.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <string.h>
-
-/* The image's test library; naming it is also what stages it beside this
-   case. */
-#define LIB "/system/lib/libtls_lib.so"
-
-extern const char _DYNAMIC[] __attribute__((visibility("hidden")));
-int main(void);
-
-static int failed;
-
-static void fail(const char *what, const char *name) {
-    printf("FAIL %s: \"%s\"\n", what, name);
-    failed = 1;
-}
-
-/* The PT_LOAD holding [addr, addr + len) at dlpi_addr + p_vaddr, or NULL. */
-static const ElfW(Phdr) *load_holding(const struct dl_phdr_info *info, uintptr_t addr, size_t len) {
-    for (int i = 0; i < info->dlpi_phnum; i++) {
-        const ElfW(Phdr) *ph = &info->dlpi_phdr[i];
-        uintptr_t lo = info->dlpi_addr + ph->p_vaddr;
-        if (ph->p_type == PT_LOAD && addr >= lo && addr + len <= lo + ph->p_memsz)
-            return ph;
-    }
-    return NULL;
-}
-
-/* Whether `code` lies in an executable PT_LOAD of this module. */
-static int runs(const struct dl_phdr_info *info, const void *code) {
-    const ElfW(Phdr) *ph = load_holding(info, (uintptr_t)code, 1);
-    return ph && (ph->p_flags & PF_X);
-}
-
-struct walk {
-    int visited;
-    int stop_at;
-    int stop_with;
-    void *lib_code;
-};
-
-static int visit(struct dl_phdr_info *info, size_t size, void *data) {
-    struct walk *walk = data;
-    walk->visited++;
-    if (walk->stop_at)
-        return walk->visited == walk->stop_at ? walk->stop_with : 0;
-
-    const char *name = info->dlpi_name;
-    if (size != sizeof(struct dl_phdr_info))
-        fail("the size passed is not the record's", name);
-    if (info->dlpi_phnum == 0 || info->dlpi_phdr == NULL) {
-        fail("no program headers", name);
-        return 0;
-    }
-    uintptr_t table = (uintptr_t)info->dlpi_phdr;
-    if (!load_holding(info, table, info->dlpi_phnum * sizeof(ElfW(Phdr))))
-        fail("the program headers lie in no PT_LOAD", name);
-    for (int i = 0; i < info->dlpi_phnum; i++) {
-        const ElfW(Phdr) *ph = &info->dlpi_phdr[i];
-        if (ph->p_type == PT_PHDR && info->dlpi_addr + ph->p_vaddr != table)
-            fail("PT_PHDR names another address", name);
-    }
-
-    if (walk->visited == 1) {
-        if (strcmp(name, "") != 0)
-            fail("the executable is not first, or is not named by the empty string", name);
-        if (!runs(info, (const void *)main))
-            fail("main lies in no executable PT_LOAD", name);
-        int dynamic = 0;
-        for (int i = 0; i < info->dlpi_phnum; i++) {
-            const ElfW(Phdr) *ph = &info->dlpi_phdr[i];
-            if (ph->p_type != PT_DYNAMIC)
-                continue;
-            dynamic = 1;
-            if (info->dlpi_addr + ph->p_vaddr != (uintptr_t)_DYNAMIC)
-                fail("PT_DYNAMIC is not at _DYNAMIC", name);
-            if (!load_holding(info, info->dlpi_addr + ph->p_vaddr, ph->p_memsz))
-                fail("PT_DYNAMIC lies in no PT_LOAD", name);
-        }
-        if (!dynamic)
-            fail("no PT_DYNAMIC", name);
-        printf("executable: \"%s\"\n", name);
-    } else {
-        if (strcmp(name, LIB) != 0)
-            fail("a library other than the one loaded", name);
-        if (!runs(info, walk->lib_code))
-            fail("tls_get_label lies in no executable PT_LOAD", name);
-        printf("library: %s\n", name);
-    }
-    return 0;
-}
-
-/* Walk every module, checking each; the count visited. */
-static int walk_all(void *lib_code) {
-    struct walk walk = { 0, 0, 0, lib_code };
-    int ret = dl_iterate_phdr(visit, &walk);
-    if (ret != 0) {
-        printf("FAIL a walk no callback stopped returned %d\n", ret);
-        failed = 1;
-    }
-    return walk.visited;
-}
-
-/* A walk the callback stops at module `at` with `with`. */
-static void stop(int at, int with, int of) {
-    struct walk walk = { 0, at, with, NULL };
-    int ret = dl_iterate_phdr(visit, &walk);
-    printf("stopped at %d of %d: visited %d, returned %d\n", at, of, walk.visited, ret);
-    if (walk.visited != at || ret != with)
-        failed = 1;
-}
-
-int main(void) {
-    printf("modules: %d\n", walk_all(NULL));
-    stop(1, 1, 1);
-
-    void *lib = dlopen(LIB, RTLD_NOW);
-    if (!lib) {
-        printf("FAIL dlopen %s\n", LIB);
-        return 1;
-    }
-    void *code = dlsym(lib, "tls_get_label");
-    if (!code) {
-        printf("FAIL dlsym tls_get_label\n");
-        return 1;
-    }
-    printf("modules: %d\n", walk_all(code));
-    stop(1, 7, 2);
-    stop(2, -1, 2);
-    return failed;
-}
diff --git a/tests/testcases/tinycc/204_dl_iterate_phdr.expect b/tests/testcases/tinycc/204_dl_iterate_phdr.expect
deleted file mode 100644
index a07ee158..00000000
--- a/tests/testcases/tinycc/204_dl_iterate_phdr.expect
+++ /dev/null
@@ -1,8 +0,0 @@
-executable: ""
-modules: 1
-stopped at 1 of 1: visited 1, returned 1
-executable: ""
-library: /system/lib/libtls_lib.so
-modules: 2
-stopped at 1 of 2: visited 1, returned 7
-stopped at 2 of 2: visited 2, returned -1
diff --git a/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.c b/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.c
deleted file mode 100644
index 2e5ade2e..00000000
--- a/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.c
+++ /dev/null
@@ -1,53 +0,0 @@
-/* dl_iterate_phdr reports the executable's program headers where they are when
-   its lowest address is not 0: tests/common/compile.rs links this case at
-   --image-base=0x200000, so the load bias and the image's first byte differ.
-   Every fact the answer is checked against is the linker's, reached through
-   __ehdr_start and never through the kernel's answer. */
-
-#include <link.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <string.h>
-
-extern const unsigned char __ehdr_start[] __attribute__((visibility("hidden")));
-
-/* The executable is visited first; keep its record and stop. */
-static int first(struct dl_phdr_info *info, size_t size, void *data) {
-    (void)size;
-    *(struct dl_phdr_info *)data = *info;
-    return 1;
-}
-
-static int failed;
-
-static void check(int holds, const char *what) {
-    printf("%s: %s\n", what, holds ? "yes" : "no");
-    if (!holds)
-        failed = 1;
-}
-
-int main(void) {
-    struct dl_phdr_info exe;
-    if (dl_iterate_phdr(first, &exe) != 1) {
-        printf("FAIL the walk did not stop at the executable\n");
-        return 1;
-    }
-
-    /* e_phoff and e_phnum, at their System V gABI offsets in Elf64_Ehdr. */
-    uint64_t phoff;
-    uint16_t phnum;
-    memcpy(&phoff, __ehdr_start + 32, sizeof phoff);
-    memcpy(&phnum, __ehdr_start + 56, sizeof phnum);
-    const ElfW(Phdr) *table = (const ElfW(Phdr) *)(__ehdr_start + phoff);
-
-    uintptr_t lowest = UINTPTR_MAX;
-    for (int i = 0; i < phnum; i++)
-        if (table[i].p_type == PT_LOAD && table[i].p_vaddr < lowest)
-            lowest = table[i].p_vaddr;
-    printf("lowest PT_LOAD: 0x%lx\n", (unsigned long)lowest);
-
-    check(exe.dlpi_phnum == phnum, "dlpi_phnum is e_phnum");
-    check(exe.dlpi_phdr == table, "dlpi_phdr is __ehdr_start + e_phoff");
-    check((uintptr_t)__ehdr_start == exe.dlpi_addr + lowest, "__ehdr_start is dlpi_addr + the lowest p_vaddr");
-    return failed;
-}
diff --git a/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.expect b/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.expect
deleted file mode 100644
index 17a85849..00000000
--- a/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.expect
+++ /dev/null
@@ -1,4 +0,0 @@
-lowest PT_LOAD: 0x200000
-dlpi_phnum is e_phnum: yes
-dlpi_phdr is __ehdr_start + e_phoff: yes
-__ehdr_start is dlpi_addr + the lowest p_vaddr: yes
diff --git a/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs b/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
index e471c1e7..36b42ab8 100644
--- a/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
+++ b/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
@@ -552,14 +552,6 @@ fn main() {
     // 20. The apply-time TLS refusals, each named by its reason in the log.
     tls_apply_time_refusals_are_reached();
 
-    // 21. A program header table no `PT_LOAD` maps: the one segment's file
-    //     bytes start at 0x1000, past the table at 0x40. Its vaddr is 0, so
-    //     `rebase_base` and `load_shared_lib`'s vaddr-0 check pass it and only
-    //     the table refuses it, in `spawn` and in `load_shared_lib` alike.
-    let unmapped = Elf::new(0x2000).ph(Phdr::load(0x1000, 0, 0x1000, 0x1000, PF_R | PF_X)).entry(0).build();
-    spawn_refused("phdrs_unmapped", &unmapped);
-    dlopen_refused("phdrs_unmapped.so", &unmapped);
-
     // The kernel heap is intact: allocate and touch enough to walk it, then
     // prove the real loader still works.
     let mut blocks: Vec<Vec<u8>> = Vec::new();
diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs
index 4a84a27c..62e20a02 100644
--- a/toyos-abi/src/syscall.rs
+++ b/toyos-abi/src/syscall.rs
@@ -2185,11 +2185,6 @@ pub struct ModuleInfo {
     pub eh_frame_hdr: u64,
     /// Size of `.eh_frame_hdr` in bytes.
     pub eh_frame_hdr_size: u64,
-    /// Absolute virtual address of the module's program header table: the
-    /// kernel loads no module whose table a `PT_LOAD` does not map.
-    pub phdr: u64,
-    /// Entries in that table, `e_phnum`: a `u64` so the record has no padding.
-    pub phnum: u64,
     /// Byte offset of the module's path string within the buffer.
     pub path_offset: u32,
     /// Length of the path string in bytes.
@@ -2199,9 +2194,11 @@ pub struct ModuleInfo {
 /// Every byte belongs to a field: this crosses the boundary through
 /// [`ModuleInfo::as_bytes`], so a gap would publish whatever the kernel stack
 /// held. **This is the type where that matters most**, because the buffer it
-/// is written into is a user address. A field of any other width added here
-/// reds here rather than publishing kernel stack bytes to userland.
-const _: () = assert!(core::mem::size_of::<ModuleInfo>() == 8 + 8 + 8 + 8 + 8 + 8 + 4 + 4);
+/// is written into is a user address: the two `u32`s sit at the end of four
+/// `u64`s, which is 8 bytes together at an 8-aligned offset, so there is no
+/// tail padding today — and this is what says so. A field of any other width
+/// added here reds here rather than publishing kernel stack bytes to userland.
+const _: () = assert!(core::mem::size_of::<ModuleInfo>() == 8 + 8 + 8 + 8 + 4 + 4);
 
 impl ModuleInfo {
     /// The record's own bytes, which is what `SYS_QUERY_MODULES` writes.
@@ -2236,28 +2233,6 @@ pub fn query_modules(buf: &mut [u8]) -> Result<usize, SyscallError> {
     check(syscall(SYS_QUERY_MODULES, buf.as_mut_ptr() as u64, buf.len() as u64, 0, 0)).map(|n| n as usize)
 }
 
-/// Every record of one [`query_modules`] answer with its path, executable
-/// first: `answer` is the `n` bytes the call reported.
-///
-/// A record or path outside `answer` is a kernel that broke the layout above,
-/// and panics.
-pub fn modules(answer: &[u8]) -> impl Iterator<Item = (ModuleInfo, &[u8])> {
-    let size = core::mem::size_of::<ModuleInfo>();
-    let record = move |i: usize| {
-        let bytes = &answer[i * size..(i + 1) * size];
-        // SAFETY: `bytes` holds `size_of::<ModuleInfo>()` bytes, read without
-        // an alignment requirement, and every bit pattern of its integer
-        // fields is a `ModuleInfo`.
-        unsafe { (bytes.as_ptr() as *const ModuleInfo).read_unaligned() }
-    };
-    let count = if answer.is_empty() { 0 } else { record(0).path_offset as usize / size };
-    (0..count).map(move |i| {
-        let info = record(i);
-        let path = info.path_offset as usize;
-        (info, &answer[path..path + info.path_len as usize])
-    })
-}
-
 /// Scheduler info for the calling process.
 #[repr(C)]
 #[derive(Clone, Copy, Debug)]
@@ -2342,70 +2317,17 @@ mod tests {
             text_end: 0x2233_4455_6677_8899,
             eh_frame_hdr: 0x3344_5566_7788_99aa,
             eh_frame_hdr_size: 0x44,
-            phdr: 0x5566_7788_99aa_bbcc,
-            phnum: 0x77,
             path_offset: 0x55,
             path_len: 0x66,
         };
         let b = info.as_bytes();
-        assert_eq!(b.len(), 56);
+        assert_eq!(b.len(), 40);
         assert_eq!(u64::from_ne_bytes(b[0..8].try_into().unwrap()), info.base);
         assert_eq!(u64::from_ne_bytes(b[8..16].try_into().unwrap()), info.text_end);
         assert_eq!(u64::from_ne_bytes(b[16..24].try_into().unwrap()), info.eh_frame_hdr);
         assert_eq!(u64::from_ne_bytes(b[24..32].try_into().unwrap()), info.eh_frame_hdr_size);
-        assert_eq!(u64::from_ne_bytes(b[32..40].try_into().unwrap()), info.phdr);
-        assert_eq!(u64::from_ne_bytes(b[40..48].try_into().unwrap()), info.phnum);
-        assert_eq!(u32::from_ne_bytes(b[48..52].try_into().unwrap()), info.path_offset);
-        assert_eq!(u32::from_ne_bytes(b[52..56].try_into().unwrap()), info.path_len);
-    }
-
-    fn record(base: u64, path_offset: u32, path_len: u32) -> ModuleInfo {
-        ModuleInfo {
-            base,
-            text_end: base + 0x1000,
-            eh_frame_hdr: 0,
-            eh_frame_hdr_size: 0,
-            phdr: base + 0x40,
-            phnum: 3,
-            path_offset,
-            path_len,
-        }
-    }
-
-    /// An answer laid out as the kernel writes one — records, then the paths
-    /// packed in module order — decodes to those records and paths, in order,
-    /// at an offset whatever alignment the buffer has.
-    #[test]
-    fn modules_decodes_every_record_and_its_path() {
-        let size = core::mem::size_of::<ModuleInfo>() as u32;
-        let exe = record(0x100_0000_0000, 2 * size, 9);
-        let lib = record(0x200_0000_0000, 2 * size + 9, 13);
-        let mut answer = [0u8; 1 + 2 * 56 + 9 + 13];
-        let wire = &mut answer[1..];
-        wire[..56].copy_from_slice(exe.as_bytes());
-        wire[56..112].copy_from_slice(lib.as_bytes());
-        wire[112..121].copy_from_slice(b"/bin/prog");
-        wire[121..].copy_from_slice(b"/lib/libx.so\0");
-        let got: [(u64, u64, &[u8]); 2] = {
-            let mut it = modules(&answer[1..]).map(|(m, path)| (m.base, m.phdr, path));
-            let pair = [it.next().unwrap(), it.next().unwrap()];
-            assert!(it.next().is_none(), "two records, and no third read out of the paths");
-            pair
-        };
-        assert_eq!(got[0], (exe.base, exe.phdr, &b"/bin/prog"[..]));
-        assert_eq!(got[1], (lib.base, lib.phdr, &b"/lib/libx.so\0"[..]));
-        assert_eq!(modules(&[]).count(), 0);
-    }
-
-    /// A path the kernel says runs past its own answer is a broken layout,
-    /// not a shorter name.
-    #[test]
-    #[should_panic]
-    fn modules_refuses_a_path_past_the_answer() {
-        let size = core::mem::size_of::<ModuleInfo>() as u32;
-        let mut answer = [0u8; 56 + 4];
-        answer[..56].copy_from_slice(record(0, size, 5).as_bytes());
-        modules(&answer).for_each(drop);
+        assert_eq!(u32::from_ne_bytes(b[32..36].try_into().unwrap()), info.path_offset);
+        assert_eq!(u32::from_ne_bytes(b[36..40].try_into().unwrap()), info.path_len);
     }
 
     /// Four lowercase hex digits each and nothing else, because two spellings
diff --git a/toyos-elf/src/layout.rs b/toyos-elf/src/layout.rs
index 397acacd..c51bab16 100644
--- a/toyos-elf/src/layout.rs
+++ b/toyos-elf/src/layout.rs
@@ -10,8 +10,8 @@
 //! address it placed the image at. A raw `p_vaddr` never leaves this module.
 
 use crate::header::{
-    FileHeader, Machine, ProgramHeader, PROGRAM_HEADER_SIZE, PT_DYNAMIC, PT_GNU_EH_FRAME, PT_LOAD,
-    PT_TLS, SECTION_HEADER_SIZE,
+    FileHeader, Machine, ProgramHeader, PT_DYNAMIC, PT_GNU_EH_FRAME, PT_LOAD, PT_TLS,
+    SECTION_HEADER_SIZE,
 };
 use crate::{Error, MAX_LOAD_SEGMENTS, MAX_TLS_ALIGN};
 
@@ -237,29 +237,6 @@ impl DynamicSegment {
     }
 }
 
-/// Where the loaded image holds its own program header table.
-///
-/// Derived from where a `PT_LOAD` copies `e_phoff` out of the file, not from
-/// `PT_PHDR`: that header is the file's claim about this, and this is what the
-/// loader actually puts there.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct ProgramHeaderTable {
-    image: ImageRange,
-    count: u16,
-}
-
-impl ProgramHeaderTable {
-    /// The table's bytes, inside the file-backed part of one `PT_LOAD`.
-    pub const fn image(&self) -> ImageRange {
-        self.image
-    }
-
-    /// `e_phnum`, at least one.
-    pub const fn count(&self) -> u16 {
-        self.count
-    }
-}
-
 /// Where the section header table is, when the file has a usable one.
 ///
 /// Section headers are optional metadata — symbol names for backtraces, and
@@ -294,8 +271,7 @@ impl SectionTableRef {
 /// - the extent runs from the smallest `p_vaddr` to the largest
 ///   `p_vaddr + p_memsz` over those segments, so it covers every one of them;
 /// - the entry point, the file-backed extent of `PT_TLS`, and all of
-///   `PT_DYNAMIC` and `PT_GNU_EH_FRAME`, lie inside the extent, and the
-///   program header table, when one is held, inside one `PT_LOAD`'s file bytes;
+///   `PT_DYNAMIC` and `PT_GNU_EH_FRAME`, lie inside the extent;
 /// - the TLS alignment is zero or a power of two no larger than
 ///   [`MAX_TLS_ALIGN`], so that `!(align - 1)` is a mask and the TLS block's
 ///   size cannot be dominated by a number the file chose.
@@ -313,7 +289,6 @@ pub struct Layout {
     dynamic: Option<DynamicSegment>,
     section_headers: Option<SectionTableRef>,
     eh_frame_hdr: Option<ImageRange>,
-    program_headers: Option<ProgramHeaderTable>,
 }
 
 impl Layout {
@@ -435,7 +410,6 @@ impl Layout {
             None => None,
             Some(e) => Some(extent.range(e.vaddr, e.memsz).ok_or(Error::EhFrameOutsideImage)?),
         };
-        let program_headers = program_header_table(&ehdr, segments.get(..placed).unwrap_or(&[]));
 
         Ok(Layout {
             extent,
@@ -446,7 +420,6 @@ impl Layout {
             dynamic,
             section_headers: section_table(&ehdr),
             eh_frame_hdr,
-            program_headers,
         })
     }
 
@@ -487,13 +460,6 @@ impl Layout {
         self.eh_frame_hdr
     }
 
-    /// Where the loaded image holds the program header table, or `None` when
-    /// no `PT_LOAD`'s file bytes hold all of it and the table exists only in
-    /// the file.
-    pub const fn program_headers(&self) -> Option<ProgramHeaderTable> {
-        self.program_headers
-    }
-
     /// The writable window `[lo, hi)` in image offsets, or `None` when no
     /// segment is writable.
     ///
@@ -594,24 +560,6 @@ impl Layout {
     }
 }
 
-/// The program header table as the first `PT_LOAD` whose file bytes hold all
-/// of it places it in the image.
-///
-/// Only `p_filesz` counts: past it a segment is zeroes, not the file.
-fn program_header_table(ehdr: &FileHeader, segments: &[Segment]) -> Option<ProgramHeaderTable> {
-    let len = u64::from(ehdr.phnum).checked_mul(PROGRAM_HEADER_SIZE as u64)?;
-    segments.iter().find_map(|seg| {
-        let within = ehdr.phoff.checked_sub(seg.file_offset)?;
-        if within.checked_add(len)? > seg.filesz {
-            return None;
-        }
-        // Inside the segment's own range, which the extent holds: `within +
-        // len <= filesz <= memsz`.
-        let start = seg.image.start.checked_add(within)?;
-        Some(ProgramHeaderTable { image: ImageRange { start, len }, count: ehdr.phnum })
-    })
-}
-
 /// A section header table the loader can index, or `None`.
 ///
 /// `e_shentsize` must be exactly 64: consumers divide a byte count by it and
diff --git a/toyos-elf/src/lib.rs b/toyos-elf/src/lib.rs
index 1d9685d7..46ee094f 100644
--- a/toyos-elf/src/lib.rs
+++ b/toyos-elf/src/lib.rs
@@ -47,7 +47,7 @@ pub use gnu_hash::GnuHash;
 pub use header::{FileHeader, Machine};
 pub use layout::{
     DynamicSegment, Extent, ImageOffset, ImageRange, Layout, Segment, SegmentFlags,
-    ProgramHeaderTable, SectionTableRef, StackedImage, TlsSegment,
+    SectionTableRef, StackedImage, TlsSegment,
 };
 pub use rela::{Op, Rela, RelaCounts, RelaTable, Reloc, RelocError, RelocKind, Rules, TlsRef};
 pub use section::{SectionHeader, SectionTable};
diff --git a/toyos-elf/tests/crafted.rs b/toyos-elf/tests/crafted.rs
index 4d44324f..baff9336 100644
--- a/toyos-elf/tests/crafted.rs
+++ b/toyos-elf/tests/crafted.rs
@@ -347,56 +347,6 @@ fn the_file_bytes_behind_a_vaddr_are_the_containing_segments() {
     assert_eq!(layout.file_bytes_from(0x2400), Some(0x400));
 }
 
-/// `(image offset, bytes, count)` of the table a layout places, for comparing.
-fn table_of(layout: &Layout) -> Option<(u64, u64, u16)> {
-    layout.program_headers().map(|t| (t.image().start().get(), t.image().len(), t.count()))
-}
-
-/// The table is where the segment holding its file bytes puts them, measured
-/// from the image's lowest address, whichever segment that is.
-#[test]
-fn the_program_header_table_is_where_its_segment_places_it() {
-    let two = 2 * PROGRAM_HEADER_SIZE as u64;
-    let at_zero = accepted(Elf::new(0x1000).ph(Phdr::load(0, 0, 0x1000, 0x1000, PF_R)).ph(Phdr::tls(0x800, 0, 8, 8)).build());
-    assert_eq!(table_of(&at_zero), Some((PH_OFF as u64, two, 2)));
-
-    // The first segment does not hold the table and the second does, at a
-    // vaddr above the first's: the offset is from the extent's minimum.
-    let second = accepted(
-        Elf::new(0x2000)
-            .entry(0x1000)
-            .ph(Phdr::load(0x1000, 0x1000, 0x1000, 0x1000, PF_R | PF_X))
-            .ph(Phdr::load(0, 0x10_0000, 0x1000, 0x1000, PF_R))
-            .build(),
-    );
-    assert_eq!(table_of(&second), Some((0x10_0000 + PH_OFF as u64 - 0x1000, two, 2)));
-}
-
-/// Held only where the file's bytes are: a segment whose file image stops
-/// inside the table, or one that holds it only as zero-filled memory, holds
-/// no table.
-#[test]
-fn a_table_no_segment_holds_whole_is_absent() {
-    let one = PROGRAM_HEADER_SIZE as u64;
-    let short = PH_OFF as u64 + one;
-    // Two headers; the file image ends one header in.
-    let cut = Elf::new(0x1000).ph(Phdr::load(0, 0, short, 0x1000, PF_R)).ph(Phdr::tls(0x800, 0, 8, 8));
-    assert_eq!(table_of(&accepted(cut.build())), None);
-    // Exactly the table's end and it is held.
-    let whole = Elf::new(0x1000).ph(Phdr::load(0, 0, short + one, 0x1000, PF_R)).ph(Phdr::tls(0x800, 0, 8, 8));
-    assert_eq!(table_of(&accepted(whole.build())), Some((PH_OFF as u64, 2 * one, 2)));
-    // Memory but no file bytes.
-    let zeroes = Elf::new(0x1000).ph(Phdr::load(0, 0, 0, 0x1000, PF_R));
-    assert_eq!(table_of(&accepted(zeroes.build())), None);
-    // A segment that starts past the table's first byte.
-    let after = Elf::new(0x2000).entry(0x1000).ph(Phdr::load(PH_OFF as u64 + 8, 0x1000, 0x1000, 0x1000, PF_R));
-    assert_eq!(table_of(&accepted(after.build())), None);
-    // And one at vaddr 0, so a loader's vaddr-0 checks pass and only the table
-    // refuses it: `abuse_elf_loader`'s `phdrs_unmapped`.
-    let unmapped = accepted(Elf::new(0x2000).ph(Phdr::load(0x1000, 0, 0x1000, 0x1000, PF_R | PF_X)).build());
-    assert_eq!((unmapped.extent().min(), table_of(&unmapped)), (0, None));
-}
-
 #[test]
 fn the_writable_window_spans_every_writable_segment() {
     let layout = accepted(
diff --git a/toyos-elf/tests/fuzz.rs b/toyos-elf/tests/fuzz.rs
index 2314fb05..871ba397 100644
--- a/toyos-elf/tests/fuzz.rs
+++ b/toyos-elf/tests/fuzz.rs
@@ -309,9 +309,6 @@ fn load(case: &Case, placement: Placement, reached: &mut Reached) -> Result<(),
         assert!(offset <= span, "offset {offset:#x} past the span {span:#x}");
         image_start.checked_add(offset).expect("an offset inside the span leaves the placement")
     };
-    if let Some(table) = layout.program_headers() {
-        place(table.image().end().get());
-    }
 
     let dyn_bytes = at(&case.bytes, layout.dynamic().ok_or(())?.image());
     let dynamic = Dynamic::parse(dyn_bytes);
diff --git a/toyos-elf/tests/real.rs b/toyos-elf/tests/real.rs
index 3fe96367..6da7bdaa 100644
--- a/toyos-elf/tests/real.rs
+++ b/toyos-elf/tests/real.rs
@@ -37,11 +37,6 @@ fn a_toyos_ld_binary_parses_to_what_readelf_says() {
     assert_eq!(layout.tls().unwrap().memsz(), 0x90);
     assert_eq!(layout.tls().unwrap().align(), 0x40);
 
-    // `e_phoff` 0x40, six headers of 56 bytes, inside the text segment's file
-    // bytes at offset 0.
-    let table = layout.program_headers().map(|t| (t.image().start().get(), t.image().len(), t.count()));
-    assert_eq!(table, Some((0x40, 6 * 56, 6)));
-
     let sections = layout.section_headers().expect("a section header table");
     assert_eq!((sections.count, sections.entry_size), (9, 64));
 
diff --git a/userland/libc/include/elf.h b/userland/libc/include/elf.h
deleted file mode 100644
index e6ff35bb..00000000
--- a/userland/libc/include/elf.h
+++ /dev/null
@@ -1,41 +0,0 @@
-#ifndef _ELF_H
-#define _ELF_H
-
-#include <stdint.h>
-
-typedef uint64_t Elf64_Addr;
-typedef uint64_t Elf64_Off;
-typedef uint16_t Elf64_Half;
-typedef uint32_t Elf64_Word;
-typedef int32_t Elf64_Sword;
-typedef uint64_t Elf64_Xword;
-typedef int64_t Elf64_Sxword;
-
-typedef struct {
-    Elf64_Word p_type;
-    Elf64_Word p_flags;
-    Elf64_Off p_offset;
-    Elf64_Addr p_vaddr;
-    Elf64_Addr p_paddr;
-    Elf64_Xword p_filesz;
-    Elf64_Xword p_memsz;
-    Elf64_Xword p_align;
-} Elf64_Phdr;
-
-#define PT_NULL         0
-#define PT_LOAD         1
-#define PT_DYNAMIC      2
-#define PT_INTERP       3
-#define PT_NOTE         4
-#define PT_SHLIB        5
-#define PT_PHDR         6
-#define PT_TLS          7
-#define PT_GNU_EH_FRAME 0x6474e550
-#define PT_GNU_STACK    0x6474e551
-#define PT_GNU_RELRO    0x6474e552
-
-#define PF_X 0x1
-#define PF_W 0x2
-#define PF_R 0x4
-
-#endif
diff --git a/userland/libc/include/link.h b/userland/libc/include/link.h
deleted file mode 100644
index 2eef0a32..00000000
--- a/userland/libc/include/link.h
+++ /dev/null
@@ -1,22 +0,0 @@
-#ifndef _LINK_H
-#define _LINK_H
-
-#include <elf.h>
-#include <stddef.h>
-
-#define ElfW(type) Elf64_##type
-
-/* The first four fields of glibc's layout. There is no dlpi_adds or
-   dlpi_subs, so the size a callback is passed ends at dlpi_phnum.
-   dlpi_name is "" for the executable, and lives only until the callback
-   returns. */
-struct dl_phdr_info {
-    ElfW(Addr) dlpi_addr;
-    const char *dlpi_name;
-    const ElfW(Phdr) *dlpi_phdr;
-    ElfW(Half) dlpi_phnum;
-};
-
-int dl_iterate_phdr(int (*callback)(struct dl_phdr_info *info, size_t size, void *data), void *data);
-
-#endif
diff --git a/userland/libc/src/lib.rs b/userland/libc/src/lib.rs
index 1052461c..9acf206a 100644
--- a/userland/libc/src/lib.rs
+++ b/userland/libc/src/lib.rs
@@ -6,7 +6,6 @@ extern crate alloc;
 mod arch;
 mod ctype;
 mod errno;
-mod link;
 mod math;
 mod memory;
 mod misc;
diff --git a/userland/libc/src/link.rs b/userland/libc/src/link.rs
deleted file mode 100644
index 7faffb24..00000000
--- a/userland/libc/src/link.rs
+++ /dev/null
@@ -1,61 +0,0 @@
-//! `dl_iterate_phdr`, from the kernel's `SYS_QUERY_MODULES` answer.
-//!
-//! The contract is glibc's `dl_iterate_phdr(3)`, which the LSB adopts: the
-//! executable first, named by the empty string, then every library in load
-//! order; the walk stops at the first callback that returns non-zero and
-//! returns that value, else 0. The walk is over one answer, so a module a
-//! callback or another thread loads meanwhile is not visited.
-
-use alloc::vec::Vec;
-
-use toyos_abi::syscall;
-
-/// `struct dl_phdr_info`, as `link.h` declares it.
-#[repr(C)]
-pub struct DlPhdrInfo {
-    dlpi_addr: u64,
-    dlpi_name: *const u8,
-    dlpi_phdr: *const u8,
-    dlpi_phnum: u16,
-}
-
-type Callback = unsafe extern "C" fn(*mut DlPhdrInfo, usize, *mut u8) -> i32;
-
-#[no_mangle]
-pub unsafe extern "C" fn dl_iterate_phdr(callback: Callback, data: *mut u8) -> i32 {
-    let answer = answer();
-    let mut name = Vec::new();
-    for (i, (module, path)) in syscall::modules(&answer).enumerate() {
-        name.clear();
-        if i > 0 {
-            name.extend_from_slice(path);
-        }
-        name.push(0);
-        let mut info = DlPhdrInfo {
-            dlpi_addr: module.base,
-            dlpi_name: name.as_ptr(),
-            dlpi_phdr: module.phdr as *const u8,
-            dlpi_phnum: u16::try_from(module.phnum).expect("SYS_QUERY_MODULES: a phnum past e_phnum's u16"),
-        };
-        // SAFETY: the caller's function, handed a record valid for the call.
-        let stop = unsafe { callback(&mut info, core::mem::size_of::<DlPhdrInfo>(), data) };
-        if stop != 0 {
-            return stop;
-        }
-    }
-    0
-}
-
-/// One whole `SYS_QUERY_MODULES` answer: asked again while a `dlopen`
-/// elsewhere grows it between the size and the read.
-fn answer() -> Vec<u8> {
-    let mut buf = Vec::new();
-    loop {
-        let need = syscall::query_modules(&mut buf).expect("SYS_QUERY_MODULES refused a buffer this process owns");
-        if need <= buf.len() {
-            buf.truncate(need);
-            return buf;
-        }
-        buf.resize(need, 0);
-    }
-}
diff --git a/userland/libc/src/misc.rs b/userland/libc/src/misc.rs
index 4443d6cd..fee86cf4 100644
--- a/userland/libc/src/misc.rs
+++ b/userland/libc/src/misc.rs
@@ -432,20 +432,13 @@ pub unsafe extern "C" fn longjmp(_env: *mut u8, _val: i32) -> ! {
 }
 
 // dlopen/dlsym/dlclose
-//
-// A C handle is the kernel's module index plus one: index 0 is a module, and
-// NULL is dlopen's failure.
-
-fn module_index(handle: *mut u8) -> Option<u64> {
-    (handle as u64).checked_sub(1)
-}
 
 #[no_mangle]
 pub unsafe extern "C" fn dlopen(path: *const u8, _flags: i32) -> *mut u8 {
     if path.is_null() { return ptr::null_mut(); }
     let path_bytes = super::posix_io::c_str_to_bytes(path);
     match syscall::dl_open(path_bytes) {
-        Ok(index) => (index + 1) as *mut u8,
+        Ok(handle) => handle as *mut u8,
         Err(_) => ptr::null_mut(),
     }
 }
@@ -453,10 +446,9 @@ pub unsafe extern "C" fn dlopen(path: *const u8, _flags: i32) -> *mut u8 {
 #[no_mangle]
 pub unsafe extern "C" fn dlsym(handle: *mut u8, symbol: *const u8) -> *mut u8 {
     if symbol.is_null() { return ptr::null_mut(); }
-    let index = module_index(handle).expect("dlsym: RTLD_DEFAULT not implemented");
     let name = super::posix_io::c_str_to_bytes(symbol);
     // SAFETY: handle is from a prior dlopen, name is a valid C string
-    match unsafe { syscall::dl_sym(index, name) } {
+    match unsafe { syscall::dl_sym(handle as u64, name) } {
         Ok(addr) => addr as *mut u8,
         Err(_) => ptr::null_mut(),
     }
@@ -464,10 +456,7 @@ pub unsafe extern "C" fn dlsym(handle: *mut u8, symbol: *const u8) -> *mut u8 {
 
 #[no_mangle]
 pub unsafe extern "C" fn dlclose(handle: *mut u8) -> i32 {
-    match module_index(handle) {
-        Some(index) => syscall::dl_close(index) as i32,
-        None => -1,
-    }
+    syscall::dl_close(handle as u64) as i32
 }
 
 #[no_mangle]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #599 at f7fae8c, round 4 (high-risk: ABI, kernel ELF loader). Evidence: CI host completed/success at f7fae8c. Orchestrator's guest runs at f7fae8c: 204, 205, abuse_elf_loader, abuse_elf_segments, query_modules_size, dlopen_dedup, std_unwind and Fast all exit 0. Mutations: m1 abuse_elf_loader 1, m1b abuse_elf_loader 1, m2 205 1 (204 0), a/b/c 204 1. c1: 204/205/abuse_elf_loader 101. c2: query_modules_size 0, abuse_elf_loader 0.

Net: +662 −43. Production +253 −14 (kernel +28 −2, toyos-abi +30 −5, toyos-elf +56 −4, libc +139 −3). Tests +337 −8. Issues +72 −21.

Earlier findings

  • BLOCKER, refusals unreached: CLOSED. abuse_elf_loader exits 0 at f7fae8c and 1 under m1 and under m1b, so case 21 reaches both refusal sites.
  • BLOCKER, image_start unchecked: CLOSED. 205 exits 0 at f7fae8c and 1 under m2.
  • NOTE phentsize: CLOSED, the field is dropped.
  • NOTE backtrace sibling: CLOSED, the issue names both readers.
  • NOTE global-scope exit: CLOSED, it now has one.
  • NOTE RTLD_DEFAULT: CLOSED, filed.
  • NOTE sweep: CLOSED, the PR body states it.
  • REMOVE syscall.rs sentence, stale tempdir issue, elf.h:4, PR-body lines: CLOSED. New stale body lines are listed below.

BLOCKER

  • tests/toyos.rs:2689-2742 — c1 as run is not a negative control for case 21. Details:
    • compile_c_tests builds every C case before any test runs, and panics on any unbuilt case that is not in NOT_RUN. So under c1 every test exits 101 whether case 21 exists or not, and abuse_elf_loader never ran. c1 stays valid for 204 and 205: link.h is missing on the base, so they cannot build there, and that is red there.
    • For abuse_elf_loader, run c3: control-c2 with its tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs section removed. c3 keeps case 21 and drops 204/205. Its kernel/abi/elf/libc hunks are byte-identical to git diff HEAD 4de5ecdb over those directories, and 4de5ecd is the merge base. Expected: abuse_elf_loader exits 1 on phdrs_unmapped: spawn returned pid.
    • Also run c3b: c3 plus deleting only abuse_elf_loader.rs:560 spawn_refused("phdrs_unmapped", &unmapped);. refused panics on the spawn arm, so c3 never reaches the dlopen arm on the base. Expected: exit 1 on phdrs_unmapped.so: dlopen loaded an image the loader must refuse.
    • If c3b is green, the dlopen arm cannot fail when load_shared_lib's refusal is removed.
  • kernel/src/syscall/vm.rs:513-514 — no guest check ties a library's phdr or phnum to the library's own header. Every library check in 204 still holds under either of these patches:
    • phnum: lib.phdrs.count().into(), → phnum: 3,. llvm-readobj -h -l on the built tests/toyos-rust-tests/tls-lib/target/x86_64-unknown-toyos/toyos/libtls_lib.so gives e_phnum 10. Entries 0..3 are PT_PHDR, PT_LOAD R (holding the table at 0x40) and PT_LOAD R+X, and PT_GNU_EH_FRAME is index 8, so libunwind would never find it.
    • phdr: (lib.user_base + lib.phdrs.image().start().get() + 56).raw(),. This skips PT_PHDR at index 0, and the R+X PT_LOAD is still read.
    • Fix: 204 checks every library's dlpi_phnum == e_phnum and dlpi_phdr == dlpi_addr + e_phoff, read from the ELF header at the library's dlpi_addr (vaddr 0, first PT_LOAD at file offset 0), as 205 does for the executable. Both patches must turn 204 red.

NOTE

  • toyos-elf/src/layout.rs:602,608 — two ?s cannot fail. u64::from(u16) * 56 cannot overflow. The start add is bounded, as the comment at 606-607 says. If the invariant broke, each would turn into a quiet "no table" refusal instead of failing, so use plain arithmetic.
  • userland/libc/src/link.rs:28,55-61 — every walk allocates two Vecs and makes at least two syscalls. libunwind calls dl_iterate_phdr while unwinding, so throwing bad_alloc under OOM dies in libc's allocator. No claim or test covers this.

REMOVE

  • PR body — "Since then, round 3 added 205 and case 21 … nobody has run them in the guest yet. The orchestrator runs those." — false at f7fae8c.
  • PR body — the Unsure bullet "Only the orchestrator's guest runs can show that 205 loads …" — 205 has been measured, exit 0.
  • PR body — the c1 row's "abuse_elf_loader red (phdrs_unmapped: spawn returned pid)" and "case 21's ELF is loaded instead of refused" — c1 measured 101 at harness build, so neither was shown.
  • PR body — "The orchestrator measured guest results at 11b89c8: …" — superseded by the round-3 head.
  • PR body — "The one-off llvm-readobj sweep of 127 ELFs last round …" — chronology that nothing reproduces.
  • PR body — "The corpus licence's counts go from 316 to 320 files." — narrates the diff.

SEND BACK

🤖 Generated with Claude Code

https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs

Japabu and others added 3 commits September 29, 2026 13:52
204 now reads the ELF header at each reported module's dlpi_addr and
checks dlpi_phnum against e_phnum and dlpi_phdr against dlpi_addr +
e_phoff, as 205 does for the executable through __ehdr_start. Before
this, a kernel that reported a library's phnum as 3, or its table 56
bytes late, passed every check 204 made: the first three headers of
libtls_lib.so are PT_PHDR and two PT_LOADs, and the shifted table still
starts with a PT_LOAD. Both modules 204 walks are linked at vaddr 0
with their first PT_LOAD at file offset 0 and e_phoff 0x40
(llvm-readobj -h -l on the host-linked 204 and on libtls_lib.so), so
the header is at dlpi_addr.

toyos-elf's program_header_table computes the table's length and its
image start with plain arithmetic: a u16 count of 56-byte entries
cannot overflow, and the start is inside the segment's own range. A
broken invariant now panics under the kernel's and bootloader's
overflow checks instead of reading as a table no segment holds.

Filed issues/build/libc-dl-iterate-phdr-allocates-on-every-walk.md:
every walk allocates twice, so libunwind looking up a frame with the
heap exhausted dies in libc's allocator. The answer grows with every
library and path, so a fixed buffer does not hold it without an ABI
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Round-4 patches at a7305ee. Each was checked the same way: git apply --check, apply, cargo run -- --build-only, git apply -R, then git status --porcelain, which came back empty. The patches that change a test were also compiled by the harness with cargo test --test toyos-build -- --list, which builds the C corpus and tests/toyos-rust-tests. The files are under the orchestrator's orch/599/patches/.

patch sha256 check / apply / build-only / reverse --list
control-c3-revert-and-drop-204-205 7ee97ebc…7281 0 / 0 / 0 / 0 0
control-c3b-c3-without-the-spawn-arm f24ea920…077a 0 / 0 / 0 / 0 0
mphnum3-lib-phnum-three 4adaad1f…1df1 0 / 0 / 0 / 0 not run: kernel only
mphdr56-lib-phdr-one-entry-late 5d3ea862…1a3a 0 / 0 / 0 / 0 not run: kernel only
control-c2r4-revert-and-drop-cases de024ad9…c828 0 / 0 / 0 / 0 0
control-c1r4-revert-kernel-abi-elf-libc 0614556a…8829 0 / 0 / 0 / 0 101: 204_dl_iterate_phdr.c:7:10: fatal error: 'link.h' file not found, and the same for 205

The old c1 and c2 no longer apply at a7305ee, because 204 and layout.rs changed. c1r4 and c2r4 define the same controls against this head.

c1r4 is git diff --abbrev=8 a7305ee5 4de5ecdb -- kernel toyos-abi toyos-elf userland/libc byte for byte (sha256 0614556adfe5a0b04af4eb49e7e7f1c2ac78915b28667bd6b6a25135ba768829). 4de5ecd is the base the review names. The current merge base, 576e558, does not differ from it in any of those paths.

c3 is git diff --abbrev=8 a7305ee5 4de5ecdb -- kernel tests/testcases toyos-abi toyos-elf userland/libc byte for byte (sha256 7ee97ebcd9e3d2fa4d0c375678f00ea19e4c949bb7f1658ca44bb53afaad7281). It is c1r4 plus the removal of 204 and 205 and the licence count. It keeps abuse_elf_loader's case 21. Expected: abuse_elf_loader exits 1 on phdrs_unmapped: spawn returned pid.

c3b is c3 plus this hunk (sha256 f24ea920131fbc60cd39229efe94872f345fd492a42a27ce4095c2516046077a). Expected: abuse_elf_loader exits 1 on phdrs_unmapped.so: dlopen loaded an image the loader must refuse.

diff --git a/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs b/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
index e471c1e7..a7aea2af 100644
--- a/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
+++ b/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
@@ -557,7 +557,6 @@ fn main() {
     //     `rebase_base` and `load_shared_lib`'s vaddr-0 check pass it and only
     //     the table refuses it, in `spawn` and in `load_shared_lib` alike.
     let unmapped = Elf::new(0x2000).ph(Phdr::load(0x1000, 0, 0x1000, 0x1000, PF_R | PF_X)).entry(0).build();
-    spawn_refused("phdrs_unmapped", &unmapped);
     dlopen_refused("phdrs_unmapped.so", &unmapped);
 
     // The kernel heap is intact: allocate and touch enough to walk it, then

c2r4 is c3 plus this hunk (sha256 de024ad9d296607459b0705cd333a5a312b3595a17cfc06a270d86ab12ddc828). Expected: query_modules_size and abuse_elf_loader exit 0.

diff --git a/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs b/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
index e471c1e7..36b42ab8 100644
--- a/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
+++ b/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
@@ -552,14 +552,6 @@ fn main() {
     // 20. The apply-time TLS refusals, each named by its reason in the log.
     tls_apply_time_refusals_are_reached();
 
-    // 21. A program header table no `PT_LOAD` maps: the one segment's file
-    //     bytes start at 0x1000, past the table at 0x40. Its vaddr is 0, so
-    //     `rebase_base` and `load_shared_lib`'s vaddr-0 check pass it and only
-    //     the table refuses it, in `spawn` and in `load_shared_lib` alike.
-    let unmapped = Elf::new(0x2000).ph(Phdr::load(0x1000, 0, 0x1000, 0x1000, PF_R | PF_X)).entry(0).build();
-    spawn_refused("phdrs_unmapped", &unmapped);
-    dlopen_refused("phdrs_unmapped.so", &unmapped);
-
     // The kernel heap is intact: allocate and touch enough to walk it, then
     // prove the real loader still works.
     let mut blocks: Vec<Vec<u8>> = Vec::new();

mphnum3: the kernel reports every library's phnum as 3. Expected: 204_dl_iterate_phdr exits 1, printing FAIL dlpi_phnum is not e_phnum: "/system/lib/libtls_lib.so".

diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs
index fb2b798f..726643ae 100644
--- a/kernel/src/syscall/vm.rs
+++ b/kernel/src/syscall/vm.rs
@@ -511,7 +511,7 @@ pub(super) fn sys_query_modules(out: &mut UserBytesMut) -> u64 {
                 eh_frame_hdr: lib.eh_frame_hdr.map_or(0, |r| (lib.user_base + r.start().get()).raw()),
                 eh_frame_hdr_size: lib.eh_frame_hdr.map_or(0, |r| r.len()),
                 phdr: (lib.user_base + lib.phdrs.image().start().get()).raw(),
-                phnum: lib.phdrs.count().into(),
+                phnum: 3,
                 path_offset,
                 path_len: lib_path_bytes.len() as u32,
             };

mphdr56: the kernel reports every library's table one entry late. Expected: 204_dl_iterate_phdr exits 1, printing FAIL dlpi_phdr is not dlpi_addr + e_phoff: "/system/lib/libtls_lib.so".

diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs
index fb2b798f..6ef86e1c 100644
--- a/kernel/src/syscall/vm.rs
+++ b/kernel/src/syscall/vm.rs
@@ -510,7 +510,7 @@ pub(super) fn sys_query_modules(out: &mut UserBytesMut) -> u64 {
                 text_end: lib.user_end(),
                 eh_frame_hdr: lib.eh_frame_hdr.map_or(0, |r| (lib.user_base + r.start().get()).raw()),
                 eh_frame_hdr_size: lib.eh_frame_hdr.map_or(0, |r| r.len()),
-                phdr: (lib.user_base + lib.phdrs.image().start().get()).raw(),
+                phdr: (lib.user_base + lib.phdrs.image().start().get() + 56).raw(),
                 phnum: lib.phdrs.count().into(),
                 path_offset,
                 path_len: lib_path_bytes.len() as u32,

c3 in full:

diff --git a/kernel/src/elf/cache.rs b/kernel/src/elf/cache.rs
index 96322295..7eb03a5d 100644
--- a/kernel/src/elf/cache.rs
+++ b/kernel/src/elf/cache.rs
@@ -22,7 +22,7 @@ use crate::vfs::BackingId;
 use crate::UserAddr;
 use toyos_elf::dynamic::InitArray;
 use toyos_elf::rela::Rules;
-use toyos_elf::{ImageRange, Op, ProgramHeaderTable, RelaCounts, RelocKind, SymIndex, TlsRef};
+use toyos_elf::{ImageRange, Op, RelaCounts, RelocKind, SymIndex, TlsRef};
 
 /// A module's non-`RELATIVE` relocations, parsed and extracted once at cache
 /// time, each as `(r_offset, what it names)`.
@@ -82,7 +82,6 @@ struct Snapshot {
     gnu_hash: Option<KernelSlice>,
     rules: Rules,
     eh_frame_hdr: Option<ImageRange>,
-    phdrs: ProgramHeaderTable,
     init_array: Option<InitArray>,
     span: u64,
     rw_lo: u64,
@@ -101,7 +100,6 @@ impl Snapshot {
             gnu_hash: lib.gnu_hash,
             rules: lib.rules,
             eh_frame_hdr: lib.eh_frame_hdr,
-            phdrs: lib.phdrs,
             init_array: lib.init_array,
             span: lib.span,
             rw_lo: lib.rw_lo,
@@ -129,7 +127,6 @@ impl Snapshot {
             cached_relocs,
             rules: self.rules,
             eh_frame_hdr: self.eh_frame_hdr,
-            phdrs: self.phdrs,
             init_array: self.init_array,
             span: self.span,
             rw_lo: self.rw_lo,
diff --git a/kernel/src/elf/mod.rs b/kernel/src/elf/mod.rs
index 41618d3d..9df1d73a 100644
--- a/kernel/src/elf/mod.rs
+++ b/kernel/src/elf/mod.rs
@@ -27,10 +27,7 @@ use crate::UserAddr;
 use toyos_elf::dynamic::{Dynamic, InitArray};
 use toyos_elf::section::{SectionTable, SHT_DYNSYM};
 use toyos_elf::sym::{Sym, SymTab};
-use toyos_elf::{
-    rela, Extent, GnuHash, ImageRange, Layout, ProgramHeaderTable, Rela, RelaTable, Reloc, RelocError, SymIndex,
-    TlsSegment,
-};
+use toyos_elf::{rela, Extent, GnuHash, ImageRange, Layout, Rela, RelaTable, Reloc, RelocError, SymIndex, TlsSegment};
 
 /// `toyos_elf::MAX_TLS_ALIGN` must equal the kernel's largest page.
 const _: () = assert!(toyos_elf::MAX_TLS_ALIGN == PAGE_2M);
@@ -104,8 +101,6 @@ pub struct LoadedLib {
     rules: rela::Rules,
     /// `PT_GNU_EH_FRAME`, for DWARF unwinding.
     pub eh_frame_hdr: Option<ImageRange>,
-    /// The program header table, which `load_shared_lib` refuses a module without.
-    pub phdrs: ProgramHeaderTable,
     /// `DT_INIT_ARRAY`.
     pub init_array: Option<InitArray>,
     /// Bytes between the image's lowest and highest virtual address.
@@ -360,8 +355,6 @@ pub fn load_shared_lib(
     if extent.min() != 0 {
         return Err("ELF: a shared object must begin at vaddr 0");
     }
-    // `SYS_QUERY_MODULES` answers with the mapped table.
-    let phdrs = layout.program_headers().ok_or("ELF: no PT_LOAD maps the program header table")?;
     // No writable segment yields an empty window; no relocation can target it.
     let (rw_lo, rw_hi) = layout.writable_window().unwrap_or((layout.span(), layout.span()));
 
@@ -520,7 +513,6 @@ pub fn load_shared_lib(
             cached_relocs: None,
             rules,
             eh_frame_hdr: layout.eh_frame_hdr(),
-            phdrs,
             init_array,
             span: layout.span(),
             rw_lo,
diff --git a/kernel/src/loader/mod.rs b/kernel/src/loader/mod.rs
index fffdbc7f..ddd821de 100644
--- a/kernel/src/loader/mod.rs
+++ b/kernel/src/loader/mod.rs
@@ -396,12 +396,6 @@ pub fn spawn(
     // Where the image's first byte lands: every `ImageOffset` the file's
     // numbers were parsed into is added to it, and its span fits above it.
     let image_start = UserAddr::new(USER_VM_BASE);
-    // `SYS_QUERY_MODULES` answers with the mapped table, so an image without
-    // one has no true answer to give.
-    let Some(phdrs) = layout.program_headers() else {
-        log!("spawn: {}: no PT_LOAD maps the program header table", path);
-        return Err(SyscallError::InvalidArgument.into());
-    };
 
     let exe = read_exe_tables(backing.as_ref(), &layout, path)?;
     let t1 = crate::clock::nanos_since_boot();
@@ -603,7 +597,6 @@ pub fn spawn(
                 .eh_frame_hdr()
                 .map_or((0, 0), |r| ((image_start + r.start().get()).raw(), r.len())),
             exe_vaddr_max: image_end,
-            exe_phdrs: ((image_start + phdrs.image().start().get()).raw(), phdrs.count()),
             lib_paths,
         },
         mmap_regions: Vec::new(),
diff --git a/kernel/src/process.rs b/kernel/src/process.rs
index b747865c..f8680f7b 100644
--- a/kernel/src/process.rs
+++ b/kernel/src/process.rs
@@ -492,8 +492,6 @@ pub struct ElfInfo {
     pub exe_eh_frame_hdr: (u64, u64),
     /// One past the executable's last byte.
     pub exe_vaddr_max: u64,
-    /// The executable's program header table as (address, count), `(0, 0)` with no executable.
-    pub exe_phdrs: (u64, u16),
     /// Paths of dlopen'd libraries (parallel to loaded_libs).
     pub lib_paths: Vec<String>,
 }
@@ -512,7 +510,6 @@ impl ElfInfo {
             elf_base: UserAddr::new(0),
             exe_eh_frame_hdr: (0, 0),
             exe_vaddr_max: 0,
-            exe_phdrs: (0, 0),
             lib_paths: Vec::new(),
         }
     }
diff --git a/kernel/src/syscall/vm.rs b/kernel/src/syscall/vm.rs
index fb2b798f..fb52c534 100644
--- a/kernel/src/syscall/vm.rs
+++ b/kernel/src/syscall/vm.rs
@@ -484,14 +484,11 @@ pub(super) fn sys_query_modules(out: &mut UserBytesMut) -> u64 {
         let mut path_offset = (module_count * info_size) as u32;
 
         let (eh_addr, eh_size) = data.elf.exe_eh_frame_hdr;
-        let (phdr, phnum) = data.elf.exe_phdrs;
         let exe_info = ModuleInfo {
             base: data.elf.elf_base.raw(),
             text_end: data.elf.exe_vaddr_max,
             eh_frame_hdr: eh_addr,
             eh_frame_hdr_size: eh_size,
-            phdr,
-            phnum: phnum.into(),
             path_offset,
             path_len: exe_path_bytes.len() as u32,
         };
@@ -510,8 +507,6 @@ pub(super) fn sys_query_modules(out: &mut UserBytesMut) -> u64 {
                 text_end: lib.user_end(),
                 eh_frame_hdr: lib.eh_frame_hdr.map_or(0, |r| (lib.user_base + r.start().get()).raw()),
                 eh_frame_hdr_size: lib.eh_frame_hdr.map_or(0, |r| r.len()),
-                phdr: (lib.user_base + lib.phdrs.image().start().get()).raw(),
-                phnum: lib.phdrs.count().into(),
                 path_offset,
                 path_len: lib_path_bytes.len() as u32,
             };
diff --git a/tests/testcases/LICENSE b/tests/testcases/LICENSE
index 2e02f9cc..8eb68294 100644
--- a/tests/testcases/LICENSE
+++ b/tests/testcases/LICENSE
@@ -22,9 +22,9 @@ against tinycc upstream at 64552b3faa39ee7948a9ea21bfcc11045b90c70d
 (repo.or.cz/tinycc.git, 2026-08-05), allowing for the `-` → `_` renames this
 project made to some filenames.
 
-  tinycc/    320 files: 239 byte-identical to tinycc's `tests/tests2`,
+  tinycc/    316 files: 239 byte-identical to tinycc's `tests/tests2`,
                         17 tinycc files this project modified,
-                        64 not upstream at all — 63 cases written here, plus
+                        60 not upstream at all — 59 cases written here, plus
                         `fred.txt`, which is output `40_stdio.c` writes when it
                         runs and which was committed by accident.
 
diff --git a/tests/testcases/tinycc/204_dl_iterate_phdr.c b/tests/testcases/tinycc/204_dl_iterate_phdr.c
deleted file mode 100644
index da198908..00000000
--- a/tests/testcases/tinycc/204_dl_iterate_phdr.c
+++ /dev/null
@@ -1,160 +0,0 @@
-/* dl_iterate_phdr visits the executable and every loaded library with the
-   program headers the loader mapped, and stops where its callback says.
-   Each module's addresses are checked against where its own header, code and
-   _DYNAMIC really are, not against the kernel's other answers. */
-
-#include <dlfcn.h>
-#include <link.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <string.h>
-
-/* The image's test library; naming it is also what stages it beside this
-   case. */
-#define LIB "/system/lib/libtls_lib.so"
-
-extern const char _DYNAMIC[] __attribute__((visibility("hidden")));
-int main(void);
-
-static int failed;
-
-static void fail(const char *what, const char *name) {
-    printf("FAIL %s: \"%s\"\n", what, name);
-    failed = 1;
-}
-
-/* The PT_LOAD holding [addr, addr + len) at dlpi_addr + p_vaddr, or NULL. */
-static const ElfW(Phdr) *load_holding(const struct dl_phdr_info *info, uintptr_t addr, size_t len) {
-    for (int i = 0; i < info->dlpi_phnum; i++) {
-        const ElfW(Phdr) *ph = &info->dlpi_phdr[i];
-        uintptr_t lo = info->dlpi_addr + ph->p_vaddr;
-        if (ph->p_type == PT_LOAD && addr >= lo && addr + len <= lo + ph->p_memsz)
-            return ph;
-    }
-    return NULL;
-}
-
-/* The table and its count are the ones the module's own ELF header names:
-   every module here is linked at vaddr 0 with its first PT_LOAD at file
-   offset 0, so that header is at dlpi_addr. */
-static void header_names_the_table(const struct dl_phdr_info *info, const char *name) {
-    const unsigned char *ehdr = (const unsigned char *)info->dlpi_addr;
-    if (memcmp(ehdr, "\177ELF", 4) != 0) {
-        fail("no ELF header at dlpi_addr", name);
-        return;
-    }
-    /* e_phoff and e_phnum, at their System V gABI offsets in Elf64_Ehdr. */
-    uint64_t phoff;
-    uint16_t phnum;
-    memcpy(&phoff, ehdr + 32, sizeof phoff);
-    memcpy(&phnum, ehdr + 56, sizeof phnum);
-    if (info->dlpi_phnum != phnum)
-        fail("dlpi_phnum is not e_phnum", name);
-    if ((uintptr_t)info->dlpi_phdr != info->dlpi_addr + phoff)
-        fail("dlpi_phdr is not dlpi_addr + e_phoff", name);
-}
-
-/* Whether `code` lies in an executable PT_LOAD of this module. */
-static int runs(const struct dl_phdr_info *info, const void *code) {
-    const ElfW(Phdr) *ph = load_holding(info, (uintptr_t)code, 1);
-    return ph && (ph->p_flags & PF_X);
-}
-
-struct walk {
-    int visited;
-    int stop_at;
-    int stop_with;
-    void *lib_code;
-};
-
-static int visit(struct dl_phdr_info *info, size_t size, void *data) {
-    struct walk *walk = data;
-    walk->visited++;
-    if (walk->stop_at)
-        return walk->visited == walk->stop_at ? walk->stop_with : 0;
-
-    const char *name = info->dlpi_name;
-    if (size != sizeof(struct dl_phdr_info))
-        fail("the size passed is not the record's", name);
-    if (info->dlpi_phnum == 0 || info->dlpi_phdr == NULL) {
-        fail("no program headers", name);
-        return 0;
-    }
-    header_names_the_table(info, name);
-    uintptr_t table = (uintptr_t)info->dlpi_phdr;
-    if (!load_holding(info, table, info->dlpi_phnum * sizeof(ElfW(Phdr))))
-        fail("the program headers lie in no PT_LOAD", name);
-    for (int i = 0; i < info->dlpi_phnum; i++) {
-        const ElfW(Phdr) *ph = &info->dlpi_phdr[i];
-        if (ph->p_type == PT_PHDR && info->dlpi_addr + ph->p_vaddr != table)
-            fail("PT_PHDR names another address", name);
-    }
-
-    if (walk->visited == 1) {
-        if (strcmp(name, "") != 0)
-            fail("the executable is not first, or is not named by the empty string", name);
-        if (!runs(info, (const void *)main))
-            fail("main lies in no executable PT_LOAD", name);
-        int dynamic = 0;
-        for (int i = 0; i < info->dlpi_phnum; i++) {
-            const ElfW(Phdr) *ph = &info->dlpi_phdr[i];
-            if (ph->p_type != PT_DYNAMIC)
-                continue;
-            dynamic = 1;
-            if (info->dlpi_addr + ph->p_vaddr != (uintptr_t)_DYNAMIC)
-                fail("PT_DYNAMIC is not at _DYNAMIC", name);
-            if (!load_holding(info, info->dlpi_addr + ph->p_vaddr, ph->p_memsz))
-                fail("PT_DYNAMIC lies in no PT_LOAD", name);
-        }
-        if (!dynamic)
-            fail("no PT_DYNAMIC", name);
-        printf("executable: \"%s\"\n", name);
-    } else {
-        if (strcmp(name, LIB) != 0)
-            fail("a library other than the one loaded", name);
-        if (!runs(info, walk->lib_code))
-            fail("tls_get_label lies in no executable PT_LOAD", name);
-        printf("library: %s\n", name);
-    }
-    return 0;
-}
-
-/* Walk every module, checking each; the count visited. */
-static int walk_all(void *lib_code) {
-    struct walk walk = { 0, 0, 0, lib_code };
-    int ret = dl_iterate_phdr(visit, &walk);
-    if (ret != 0) {
-        printf("FAIL a walk no callback stopped returned %d\n", ret);
-        failed = 1;
-    }
-    return walk.visited;
-}
-
-/* A walk the callback stops at module `at` with `with`. */
-static void stop(int at, int with, int of) {
-    struct walk walk = { 0, at, with, NULL };
-    int ret = dl_iterate_phdr(visit, &walk);
-    printf("stopped at %d of %d: visited %d, returned %d\n", at, of, walk.visited, ret);
-    if (walk.visited != at || ret != with)
-        failed = 1;
-}
-
-int main(void) {
-    printf("modules: %d\n", walk_all(NULL));
-    stop(1, 1, 1);
-
-    void *lib = dlopen(LIB, RTLD_NOW);
-    if (!lib) {
-        printf("FAIL dlopen %s\n", LIB);
-        return 1;
-    }
-    void *code = dlsym(lib, "tls_get_label");
-    if (!code) {
-        printf("FAIL dlsym tls_get_label\n");
-        return 1;
-    }
-    printf("modules: %d\n", walk_all(code));
-    stop(1, 7, 2);
-    stop(2, -1, 2);
-    return failed;
-}
diff --git a/tests/testcases/tinycc/204_dl_iterate_phdr.expect b/tests/testcases/tinycc/204_dl_iterate_phdr.expect
deleted file mode 100644
index a07ee158..00000000
--- a/tests/testcases/tinycc/204_dl_iterate_phdr.expect
+++ /dev/null
@@ -1,8 +0,0 @@
-executable: ""
-modules: 1
-stopped at 1 of 1: visited 1, returned 1
-executable: ""
-library: /system/lib/libtls_lib.so
-modules: 2
-stopped at 1 of 2: visited 1, returned 7
-stopped at 2 of 2: visited 2, returned -1
diff --git a/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.c b/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.c
deleted file mode 100644
index 2e5ade2e..00000000
--- a/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.c
+++ /dev/null
@@ -1,53 +0,0 @@
-/* dl_iterate_phdr reports the executable's program headers where they are when
-   its lowest address is not 0: tests/common/compile.rs links this case at
-   --image-base=0x200000, so the load bias and the image's first byte differ.
-   Every fact the answer is checked against is the linker's, reached through
-   __ehdr_start and never through the kernel's answer. */
-
-#include <link.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <string.h>
-
-extern const unsigned char __ehdr_start[] __attribute__((visibility("hidden")));
-
-/* The executable is visited first; keep its record and stop. */
-static int first(struct dl_phdr_info *info, size_t size, void *data) {
-    (void)size;
-    *(struct dl_phdr_info *)data = *info;
-    return 1;
-}
-
-static int failed;
-
-static void check(int holds, const char *what) {
-    printf("%s: %s\n", what, holds ? "yes" : "no");
-    if (!holds)
-        failed = 1;
-}
-
-int main(void) {
-    struct dl_phdr_info exe;
-    if (dl_iterate_phdr(first, &exe) != 1) {
-        printf("FAIL the walk did not stop at the executable\n");
-        return 1;
-    }
-
-    /* e_phoff and e_phnum, at their System V gABI offsets in Elf64_Ehdr. */
-    uint64_t phoff;
-    uint16_t phnum;
-    memcpy(&phoff, __ehdr_start + 32, sizeof phoff);
-    memcpy(&phnum, __ehdr_start + 56, sizeof phnum);
-    const ElfW(Phdr) *table = (const ElfW(Phdr) *)(__ehdr_start + phoff);
-
-    uintptr_t lowest = UINTPTR_MAX;
-    for (int i = 0; i < phnum; i++)
-        if (table[i].p_type == PT_LOAD && table[i].p_vaddr < lowest)
-            lowest = table[i].p_vaddr;
-    printf("lowest PT_LOAD: 0x%lx\n", (unsigned long)lowest);
-
-    check(exe.dlpi_phnum == phnum, "dlpi_phnum is e_phnum");
-    check(exe.dlpi_phdr == table, "dlpi_phdr is __ehdr_start + e_phoff");
-    check((uintptr_t)__ehdr_start == exe.dlpi_addr + lowest, "__ehdr_start is dlpi_addr + the lowest p_vaddr");
-    return failed;
-}
diff --git a/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.expect b/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.expect
deleted file mode 100644
index 17a85849..00000000
--- a/tests/testcases/tinycc/205_dl_iterate_phdr_image_base.expect
+++ /dev/null
@@ -1,4 +0,0 @@
-lowest PT_LOAD: 0x200000
-dlpi_phnum is e_phnum: yes
-dlpi_phdr is __ehdr_start + e_phoff: yes
-__ehdr_start is dlpi_addr + the lowest p_vaddr: yes
diff --git a/toyos-abi/src/syscall.rs b/toyos-abi/src/syscall.rs
index 4a84a27c..62e20a02 100644
--- a/toyos-abi/src/syscall.rs
+++ b/toyos-abi/src/syscall.rs
@@ -2185,11 +2185,6 @@ pub struct ModuleInfo {
     pub eh_frame_hdr: u64,
     /// Size of `.eh_frame_hdr` in bytes.
     pub eh_frame_hdr_size: u64,
-    /// Absolute virtual address of the module's program header table: the
-    /// kernel loads no module whose table a `PT_LOAD` does not map.
-    pub phdr: u64,
-    /// Entries in that table, `e_phnum`: a `u64` so the record has no padding.
-    pub phnum: u64,
     /// Byte offset of the module's path string within the buffer.
     pub path_offset: u32,
     /// Length of the path string in bytes.
@@ -2199,9 +2194,11 @@ pub struct ModuleInfo {
 /// Every byte belongs to a field: this crosses the boundary through
 /// [`ModuleInfo::as_bytes`], so a gap would publish whatever the kernel stack
 /// held. **This is the type where that matters most**, because the buffer it
-/// is written into is a user address. A field of any other width added here
-/// reds here rather than publishing kernel stack bytes to userland.
-const _: () = assert!(core::mem::size_of::<ModuleInfo>() == 8 + 8 + 8 + 8 + 8 + 8 + 4 + 4);
+/// is written into is a user address: the two `u32`s sit at the end of four
+/// `u64`s, which is 8 bytes together at an 8-aligned offset, so there is no
+/// tail padding today — and this is what says so. A field of any other width
+/// added here reds here rather than publishing kernel stack bytes to userland.
+const _: () = assert!(core::mem::size_of::<ModuleInfo>() == 8 + 8 + 8 + 8 + 4 + 4);
 
 impl ModuleInfo {
     /// The record's own bytes, which is what `SYS_QUERY_MODULES` writes.
@@ -2236,28 +2233,6 @@ pub fn query_modules(buf: &mut [u8]) -> Result<usize, SyscallError> {
     check(syscall(SYS_QUERY_MODULES, buf.as_mut_ptr() as u64, buf.len() as u64, 0, 0)).map(|n| n as usize)
 }
 
-/// Every record of one [`query_modules`] answer with its path, executable
-/// first: `answer` is the `n` bytes the call reported.
-///
-/// A record or path outside `answer` is a kernel that broke the layout above,
-/// and panics.
-pub fn modules(answer: &[u8]) -> impl Iterator<Item = (ModuleInfo, &[u8])> {
-    let size = core::mem::size_of::<ModuleInfo>();
-    let record = move |i: usize| {
-        let bytes = &answer[i * size..(i + 1) * size];
-        // SAFETY: `bytes` holds `size_of::<ModuleInfo>()` bytes, read without
-        // an alignment requirement, and every bit pattern of its integer
-        // fields is a `ModuleInfo`.
-        unsafe { (bytes.as_ptr() as *const ModuleInfo).read_unaligned() }
-    };
-    let count = if answer.is_empty() { 0 } else { record(0).path_offset as usize / size };
-    (0..count).map(move |i| {
-        let info = record(i);
-        let path = info.path_offset as usize;
-        (info, &answer[path..path + info.path_len as usize])
-    })
-}
-
 /// Scheduler info for the calling process.
 #[repr(C)]
 #[derive(Clone, Copy, Debug)]
@@ -2342,70 +2317,17 @@ mod tests {
             text_end: 0x2233_4455_6677_8899,
             eh_frame_hdr: 0x3344_5566_7788_99aa,
             eh_frame_hdr_size: 0x44,
-            phdr: 0x5566_7788_99aa_bbcc,
-            phnum: 0x77,
             path_offset: 0x55,
             path_len: 0x66,
         };
         let b = info.as_bytes();
-        assert_eq!(b.len(), 56);
+        assert_eq!(b.len(), 40);
         assert_eq!(u64::from_ne_bytes(b[0..8].try_into().unwrap()), info.base);
         assert_eq!(u64::from_ne_bytes(b[8..16].try_into().unwrap()), info.text_end);
         assert_eq!(u64::from_ne_bytes(b[16..24].try_into().unwrap()), info.eh_frame_hdr);
         assert_eq!(u64::from_ne_bytes(b[24..32].try_into().unwrap()), info.eh_frame_hdr_size);
-        assert_eq!(u64::from_ne_bytes(b[32..40].try_into().unwrap()), info.phdr);
-        assert_eq!(u64::from_ne_bytes(b[40..48].try_into().unwrap()), info.phnum);
-        assert_eq!(u32::from_ne_bytes(b[48..52].try_into().unwrap()), info.path_offset);
-        assert_eq!(u32::from_ne_bytes(b[52..56].try_into().unwrap()), info.path_len);
-    }
-
-    fn record(base: u64, path_offset: u32, path_len: u32) -> ModuleInfo {
-        ModuleInfo {
-            base,
-            text_end: base + 0x1000,
-            eh_frame_hdr: 0,
-            eh_frame_hdr_size: 0,
-            phdr: base + 0x40,
-            phnum: 3,
-            path_offset,
-            path_len,
-        }
-    }
-
-    /// An answer laid out as the kernel writes one — records, then the paths
-    /// packed in module order — decodes to those records and paths, in order,
-    /// at an offset whatever alignment the buffer has.
-    #[test]
-    fn modules_decodes_every_record_and_its_path() {
-        let size = core::mem::size_of::<ModuleInfo>() as u32;
-        let exe = record(0x100_0000_0000, 2 * size, 9);
-        let lib = record(0x200_0000_0000, 2 * size + 9, 13);
-        let mut answer = [0u8; 1 + 2 * 56 + 9 + 13];
-        let wire = &mut answer[1..];
-        wire[..56].copy_from_slice(exe.as_bytes());
-        wire[56..112].copy_from_slice(lib.as_bytes());
-        wire[112..121].copy_from_slice(b"/bin/prog");
-        wire[121..].copy_from_slice(b"/lib/libx.so\0");
-        let got: [(u64, u64, &[u8]); 2] = {
-            let mut it = modules(&answer[1..]).map(|(m, path)| (m.base, m.phdr, path));
-            let pair = [it.next().unwrap(), it.next().unwrap()];
-            assert!(it.next().is_none(), "two records, and no third read out of the paths");
-            pair
-        };
-        assert_eq!(got[0], (exe.base, exe.phdr, &b"/bin/prog"[..]));
-        assert_eq!(got[1], (lib.base, lib.phdr, &b"/lib/libx.so\0"[..]));
-        assert_eq!(modules(&[]).count(), 0);
-    }
-
-    /// A path the kernel says runs past its own answer is a broken layout,
-    /// not a shorter name.
-    #[test]
-    #[should_panic]
-    fn modules_refuses_a_path_past_the_answer() {
-        let size = core::mem::size_of::<ModuleInfo>() as u32;
-        let mut answer = [0u8; 56 + 4];
-        answer[..56].copy_from_slice(record(0, size, 5).as_bytes());
-        modules(&answer).for_each(drop);
+        assert_eq!(u32::from_ne_bytes(b[32..36].try_into().unwrap()), info.path_offset);
+        assert_eq!(u32::from_ne_bytes(b[36..40].try_into().unwrap()), info.path_len);
     }
 
     /// Four lowercase hex digits each and nothing else, because two spellings
diff --git a/toyos-elf/src/layout.rs b/toyos-elf/src/layout.rs
index 18068cb0..c51bab16 100644
--- a/toyos-elf/src/layout.rs
+++ b/toyos-elf/src/layout.rs
@@ -10,8 +10,8 @@
 //! address it placed the image at. A raw `p_vaddr` never leaves this module.
 
 use crate::header::{
-    FileHeader, Machine, ProgramHeader, PROGRAM_HEADER_SIZE, PT_DYNAMIC, PT_GNU_EH_FRAME, PT_LOAD,
-    PT_TLS, SECTION_HEADER_SIZE,
+    FileHeader, Machine, ProgramHeader, PT_DYNAMIC, PT_GNU_EH_FRAME, PT_LOAD, PT_TLS,
+    SECTION_HEADER_SIZE,
 };
 use crate::{Error, MAX_LOAD_SEGMENTS, MAX_TLS_ALIGN};
 
@@ -237,29 +237,6 @@ impl DynamicSegment {
     }
 }
 
-/// Where the loaded image holds its own program header table.
-///
-/// Derived from where a `PT_LOAD` copies `e_phoff` out of the file, not from
-/// `PT_PHDR`: that header is the file's claim about this, and this is what the
-/// loader actually puts there.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub struct ProgramHeaderTable {
-    image: ImageRange,
-    count: u16,
-}
-
-impl ProgramHeaderTable {
-    /// The table's bytes, inside the file-backed part of one `PT_LOAD`.
-    pub const fn image(&self) -> ImageRange {
-        self.image
-    }
-
-    /// `e_phnum`, at least one.
-    pub const fn count(&self) -> u16 {
-        self.count
-    }
-}
-
 /// Where the section header table is, when the file has a usable one.
 ///
 /// Section headers are optional metadata — symbol names for backtraces, and
@@ -294,8 +271,7 @@ impl SectionTableRef {
 /// - the extent runs from the smallest `p_vaddr` to the largest
 ///   `p_vaddr + p_memsz` over those segments, so it covers every one of them;
 /// - the entry point, the file-backed extent of `PT_TLS`, and all of
-///   `PT_DYNAMIC` and `PT_GNU_EH_FRAME`, lie inside the extent, and the
-///   program header table, when one is held, inside one `PT_LOAD`'s file bytes;
+///   `PT_DYNAMIC` and `PT_GNU_EH_FRAME`, lie inside the extent;
 /// - the TLS alignment is zero or a power of two no larger than
 ///   [`MAX_TLS_ALIGN`], so that `!(align - 1)` is a mask and the TLS block's
 ///   size cannot be dominated by a number the file chose.
@@ -313,7 +289,6 @@ pub struct Layout {
     dynamic: Option<DynamicSegment>,
     section_headers: Option<SectionTableRef>,
     eh_frame_hdr: Option<ImageRange>,
-    program_headers: Option<ProgramHeaderTable>,
 }
 
 impl Layout {
@@ -435,7 +410,6 @@ impl Layout {
             None => None,
             Some(e) => Some(extent.range(e.vaddr, e.memsz).ok_or(Error::EhFrameOutsideImage)?),
         };
-        let program_headers = program_header_table(&ehdr, segments.get(..placed).unwrap_or(&[]));
 
         Ok(Layout {
             extent,
@@ -446,7 +420,6 @@ impl Layout {
             dynamic,
             section_headers: section_table(&ehdr),
             eh_frame_hdr,
-            program_headers,
         })
     }
 
@@ -487,13 +460,6 @@ impl Layout {
         self.eh_frame_hdr
     }
 
-    /// Where the loaded image holds the program header table, or `None` when
-    /// no `PT_LOAD`'s file bytes hold all of it and the table exists only in
-    /// the file.
-    pub const fn program_headers(&self) -> Option<ProgramHeaderTable> {
-        self.program_headers
-    }
-
     /// The writable window `[lo, hi)` in image offsets, or `None` when no
     /// segment is writable.
     ///
@@ -594,25 +560,6 @@ impl Layout {
     }
 }
 
-/// The program header table as the first `PT_LOAD` whose file bytes hold all
-/// of it places it in the image.
-///
-/// Only `p_filesz` counts: past it a segment is zeroes, not the file.
-fn program_header_table(ehdr: &FileHeader, segments: &[Segment]) -> Option<ProgramHeaderTable> {
-    // A `u16` count of 56-byte entries: the product cannot overflow a `u64`.
-    let len = u64::from(ehdr.phnum) * PROGRAM_HEADER_SIZE as u64;
-    segments.iter().find_map(|seg| {
-        let within = ehdr.phoff.checked_sub(seg.file_offset)?;
-        if within.checked_add(len)? > seg.filesz {
-            return None;
-        }
-        // Inside the segment's own range, which the extent holds: `within +
-        // len <= filesz <= memsz`.
-        let start = seg.image.start + within;
-        Some(ProgramHeaderTable { image: ImageRange { start, len }, count: ehdr.phnum })
-    })
-}
-
 /// A section header table the loader can index, or `None`.
 ///
 /// `e_shentsize` must be exactly 64: consumers divide a byte count by it and
diff --git a/toyos-elf/src/lib.rs b/toyos-elf/src/lib.rs
index 1d9685d7..46ee094f 100644
--- a/toyos-elf/src/lib.rs
+++ b/toyos-elf/src/lib.rs
@@ -47,7 +47,7 @@ pub use gnu_hash::GnuHash;
 pub use header::{FileHeader, Machine};
 pub use layout::{
     DynamicSegment, Extent, ImageOffset, ImageRange, Layout, Segment, SegmentFlags,
-    ProgramHeaderTable, SectionTableRef, StackedImage, TlsSegment,
+    SectionTableRef, StackedImage, TlsSegment,
 };
 pub use rela::{Op, Rela, RelaCounts, RelaTable, Reloc, RelocError, RelocKind, Rules, TlsRef};
 pub use section::{SectionHeader, SectionTable};
diff --git a/toyos-elf/tests/crafted.rs b/toyos-elf/tests/crafted.rs
index 4d44324f..baff9336 100644
--- a/toyos-elf/tests/crafted.rs
+++ b/toyos-elf/tests/crafted.rs
@@ -347,56 +347,6 @@ fn the_file_bytes_behind_a_vaddr_are_the_containing_segments() {
     assert_eq!(layout.file_bytes_from(0x2400), Some(0x400));
 }
 
-/// `(image offset, bytes, count)` of the table a layout places, for comparing.
-fn table_of(layout: &Layout) -> Option<(u64, u64, u16)> {
-    layout.program_headers().map(|t| (t.image().start().get(), t.image().len(), t.count()))
-}
-
-/// The table is where the segment holding its file bytes puts them, measured
-/// from the image's lowest address, whichever segment that is.
-#[test]
-fn the_program_header_table_is_where_its_segment_places_it() {
-    let two = 2 * PROGRAM_HEADER_SIZE as u64;
-    let at_zero = accepted(Elf::new(0x1000).ph(Phdr::load(0, 0, 0x1000, 0x1000, PF_R)).ph(Phdr::tls(0x800, 0, 8, 8)).build());
-    assert_eq!(table_of(&at_zero), Some((PH_OFF as u64, two, 2)));
-
-    // The first segment does not hold the table and the second does, at a
-    // vaddr above the first's: the offset is from the extent's minimum.
-    let second = accepted(
-        Elf::new(0x2000)
-            .entry(0x1000)
-            .ph(Phdr::load(0x1000, 0x1000, 0x1000, 0x1000, PF_R | PF_X))
-            .ph(Phdr::load(0, 0x10_0000, 0x1000, 0x1000, PF_R))
-            .build(),
-    );
-    assert_eq!(table_of(&second), Some((0x10_0000 + PH_OFF as u64 - 0x1000, two, 2)));
-}
-
-/// Held only where the file's bytes are: a segment whose file image stops
-/// inside the table, or one that holds it only as zero-filled memory, holds
-/// no table.
-#[test]
-fn a_table_no_segment_holds_whole_is_absent() {
-    let one = PROGRAM_HEADER_SIZE as u64;
-    let short = PH_OFF as u64 + one;
-    // Two headers; the file image ends one header in.
-    let cut = Elf::new(0x1000).ph(Phdr::load(0, 0, short, 0x1000, PF_R)).ph(Phdr::tls(0x800, 0, 8, 8));
-    assert_eq!(table_of(&accepted(cut.build())), None);
-    // Exactly the table's end and it is held.
-    let whole = Elf::new(0x1000).ph(Phdr::load(0, 0, short + one, 0x1000, PF_R)).ph(Phdr::tls(0x800, 0, 8, 8));
-    assert_eq!(table_of(&accepted(whole.build())), Some((PH_OFF as u64, 2 * one, 2)));
-    // Memory but no file bytes.
-    let zeroes = Elf::new(0x1000).ph(Phdr::load(0, 0, 0, 0x1000, PF_R));
-    assert_eq!(table_of(&accepted(zeroes.build())), None);
-    // A segment that starts past the table's first byte.
-    let after = Elf::new(0x2000).entry(0x1000).ph(Phdr::load(PH_OFF as u64 + 8, 0x1000, 0x1000, 0x1000, PF_R));
-    assert_eq!(table_of(&accepted(after.build())), None);
-    // And one at vaddr 0, so a loader's vaddr-0 checks pass and only the table
-    // refuses it: `abuse_elf_loader`'s `phdrs_unmapped`.
-    let unmapped = accepted(Elf::new(0x2000).ph(Phdr::load(0x1000, 0, 0x1000, 0x1000, PF_R | PF_X)).build());
-    assert_eq!((unmapped.extent().min(), table_of(&unmapped)), (0, None));
-}
-
 #[test]
 fn the_writable_window_spans_every_writable_segment() {
     let layout = accepted(
diff --git a/toyos-elf/tests/fuzz.rs b/toyos-elf/tests/fuzz.rs
index 2314fb05..871ba397 100644
--- a/toyos-elf/tests/fuzz.rs
+++ b/toyos-elf/tests/fuzz.rs
@@ -309,9 +309,6 @@ fn load(case: &Case, placement: Placement, reached: &mut Reached) -> Result<(),
         assert!(offset <= span, "offset {offset:#x} past the span {span:#x}");
         image_start.checked_add(offset).expect("an offset inside the span leaves the placement")
     };
-    if let Some(table) = layout.program_headers() {
-        place(table.image().end().get());
-    }
 
     let dyn_bytes = at(&case.bytes, layout.dynamic().ok_or(())?.image());
     let dynamic = Dynamic::parse(dyn_bytes);
diff --git a/toyos-elf/tests/real.rs b/toyos-elf/tests/real.rs
index 3fe96367..6da7bdaa 100644
--- a/toyos-elf/tests/real.rs
+++ b/toyos-elf/tests/real.rs
@@ -37,11 +37,6 @@ fn a_toyos_ld_binary_parses_to_what_readelf_says() {
     assert_eq!(layout.tls().unwrap().memsz(), 0x90);
     assert_eq!(layout.tls().unwrap().align(), 0x40);
 
-    // `e_phoff` 0x40, six headers of 56 bytes, inside the text segment's file
-    // bytes at offset 0.
-    let table = layout.program_headers().map(|t| (t.image().start().get(), t.image().len(), t.count()));
-    assert_eq!(table, Some((0x40, 6 * 56, 6)));
-
     let sections = layout.section_headers().expect("a section header table");
     assert_eq!((sections.count, sections.entry_size), (9, 64));
 
diff --git a/userland/libc/include/elf.h b/userland/libc/include/elf.h
deleted file mode 100644
index e6ff35bb..00000000
--- a/userland/libc/include/elf.h
+++ /dev/null
@@ -1,41 +0,0 @@
-#ifndef _ELF_H
-#define _ELF_H
-
-#include <stdint.h>
-
-typedef uint64_t Elf64_Addr;
-typedef uint64_t Elf64_Off;
-typedef uint16_t Elf64_Half;
-typedef uint32_t Elf64_Word;
-typedef int32_t Elf64_Sword;
-typedef uint64_t Elf64_Xword;
-typedef int64_t Elf64_Sxword;
-
-typedef struct {
-    Elf64_Word p_type;
-    Elf64_Word p_flags;
-    Elf64_Off p_offset;
-    Elf64_Addr p_vaddr;
-    Elf64_Addr p_paddr;
-    Elf64_Xword p_filesz;
-    Elf64_Xword p_memsz;
-    Elf64_Xword p_align;
-} Elf64_Phdr;
-
-#define PT_NULL         0
-#define PT_LOAD         1
-#define PT_DYNAMIC      2
-#define PT_INTERP       3
-#define PT_NOTE         4
-#define PT_SHLIB        5
-#define PT_PHDR         6
-#define PT_TLS          7
-#define PT_GNU_EH_FRAME 0x6474e550
-#define PT_GNU_STACK    0x6474e551
-#define PT_GNU_RELRO    0x6474e552
-
-#define PF_X 0x1
-#define PF_W 0x2
-#define PF_R 0x4
-
-#endif
diff --git a/userland/libc/include/link.h b/userland/libc/include/link.h
deleted file mode 100644
index 2eef0a32..00000000
--- a/userland/libc/include/link.h
+++ /dev/null
@@ -1,22 +0,0 @@
-#ifndef _LINK_H
-#define _LINK_H
-
-#include <elf.h>
-#include <stddef.h>
-
-#define ElfW(type) Elf64_##type
-
-/* The first four fields of glibc's layout. There is no dlpi_adds or
-   dlpi_subs, so the size a callback is passed ends at dlpi_phnum.
-   dlpi_name is "" for the executable, and lives only until the callback
-   returns. */
-struct dl_phdr_info {
-    ElfW(Addr) dlpi_addr;
-    const char *dlpi_name;
-    const ElfW(Phdr) *dlpi_phdr;
-    ElfW(Half) dlpi_phnum;
-};
-
-int dl_iterate_phdr(int (*callback)(struct dl_phdr_info *info, size_t size, void *data), void *data);
-
-#endif
diff --git a/userland/libc/src/lib.rs b/userland/libc/src/lib.rs
index 1052461c..9acf206a 100644
--- a/userland/libc/src/lib.rs
+++ b/userland/libc/src/lib.rs
@@ -6,7 +6,6 @@ extern crate alloc;
 mod arch;
 mod ctype;
 mod errno;
-mod link;
 mod math;
 mod memory;
 mod misc;
diff --git a/userland/libc/src/link.rs b/userland/libc/src/link.rs
deleted file mode 100644
index 7faffb24..00000000
--- a/userland/libc/src/link.rs
+++ /dev/null
@@ -1,61 +0,0 @@
-//! `dl_iterate_phdr`, from the kernel's `SYS_QUERY_MODULES` answer.
-//!
-//! The contract is glibc's `dl_iterate_phdr(3)`, which the LSB adopts: the
-//! executable first, named by the empty string, then every library in load
-//! order; the walk stops at the first callback that returns non-zero and
-//! returns that value, else 0. The walk is over one answer, so a module a
-//! callback or another thread loads meanwhile is not visited.
-
-use alloc::vec::Vec;
-
-use toyos_abi::syscall;
-
-/// `struct dl_phdr_info`, as `link.h` declares it.
-#[repr(C)]
-pub struct DlPhdrInfo {
-    dlpi_addr: u64,
-    dlpi_name: *const u8,
-    dlpi_phdr: *const u8,
-    dlpi_phnum: u16,
-}
-
-type Callback = unsafe extern "C" fn(*mut DlPhdrInfo, usize, *mut u8) -> i32;
-
-#[no_mangle]
-pub unsafe extern "C" fn dl_iterate_phdr(callback: Callback, data: *mut u8) -> i32 {
-    let answer = answer();
-    let mut name = Vec::new();
-    for (i, (module, path)) in syscall::modules(&answer).enumerate() {
-        name.clear();
-        if i > 0 {
-            name.extend_from_slice(path);
-        }
-        name.push(0);
-        let mut info = DlPhdrInfo {
-            dlpi_addr: module.base,
-            dlpi_name: name.as_ptr(),
-            dlpi_phdr: module.phdr as *const u8,
-            dlpi_phnum: u16::try_from(module.phnum).expect("SYS_QUERY_MODULES: a phnum past e_phnum's u16"),
-        };
-        // SAFETY: the caller's function, handed a record valid for the call.
-        let stop = unsafe { callback(&mut info, core::mem::size_of::<DlPhdrInfo>(), data) };
-        if stop != 0 {
-            return stop;
-        }
-    }
-    0
-}
-
-/// One whole `SYS_QUERY_MODULES` answer: asked again while a `dlopen`
-/// elsewhere grows it between the size and the read.
-fn answer() -> Vec<u8> {
-    let mut buf = Vec::new();
-    loop {
-        let need = syscall::query_modules(&mut buf).expect("SYS_QUERY_MODULES refused a buffer this process owns");
-        if need <= buf.len() {
-            buf.truncate(need);
-            return buf;
-        }
-        buf.resize(need, 0);
-    }
-}
diff --git a/userland/libc/src/misc.rs b/userland/libc/src/misc.rs
index 4443d6cd..fee86cf4 100644
--- a/userland/libc/src/misc.rs
+++ b/userland/libc/src/misc.rs
@@ -432,20 +432,13 @@ pub unsafe extern "C" fn longjmp(_env: *mut u8, _val: i32) -> ! {
 }
 
 // dlopen/dlsym/dlclose
-//
-// A C handle is the kernel's module index plus one: index 0 is a module, and
-// NULL is dlopen's failure.
-
-fn module_index(handle: *mut u8) -> Option<u64> {
-    (handle as u64).checked_sub(1)
-}
 
 #[no_mangle]
 pub unsafe extern "C" fn dlopen(path: *const u8, _flags: i32) -> *mut u8 {
     if path.is_null() { return ptr::null_mut(); }
     let path_bytes = super::posix_io::c_str_to_bytes(path);
     match syscall::dl_open(path_bytes) {
-        Ok(index) => (index + 1) as *mut u8,
+        Ok(handle) => handle as *mut u8,
         Err(_) => ptr::null_mut(),
     }
 }
@@ -453,10 +446,9 @@ pub unsafe extern "C" fn dlopen(path: *const u8, _flags: i32) -> *mut u8 {
 #[no_mangle]
 pub unsafe extern "C" fn dlsym(handle: *mut u8, symbol: *const u8) -> *mut u8 {
     if symbol.is_null() { return ptr::null_mut(); }
-    let index = module_index(handle).expect("dlsym: RTLD_DEFAULT not implemented");
     let name = super::posix_io::c_str_to_bytes(symbol);
     // SAFETY: handle is from a prior dlopen, name is a valid C string
-    match unsafe { syscall::dl_sym(index, name) } {
+    match unsafe { syscall::dl_sym(handle as u64, name) } {
         Ok(addr) => addr as *mut u8,
         Err(_) => ptr::null_mut(),
     }
@@ -464,10 +456,7 @@ pub unsafe extern "C" fn dlsym(handle: *mut u8, symbol: *const u8) -> *mut u8 {
 
 #[no_mangle]
 pub unsafe extern "C" fn dlclose(handle: *mut u8) -> i32 {
-    match module_index(handle) {
-        Some(index) => syscall::dl_close(index) as i32,
-        None => -1,
-    }
+    syscall::dl_close(handle as u64) as i32
 }
 
 #[no_mangle]

🤖 Generated with Claude Code

https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs

@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: guest runs at a7305ee — 204, 205, abuse_elf_loader, abuse_elf_segments, query_modules_size, dlopen_dedup, std_unwind, Fast: exit 0. mphnum3/204: exit 1 FAIL dlpi_phnum is not e_phnum; mphdr56/204: exit 1 FAIL dlpi_phdr is not dlpi_addr + e_phoff; c3/abuse_elf_loader: exit 1 phdrs_unmapped: spawn returned pid … for an ELF that must be refused; c3b/abuse_elf_loader: exit 1 phdrs_unmapped.so: dlopen loaded an image the loader must refuse; c2r4: query_modules_size 0, abuse_elf_loader 0. Round-3 blockers closed by these runs; landing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs

tests/testcases/LICENSE: the tinycc count is main's 318 files plus this
branch's 204 and 205 (four files): 322 files, 66 not upstream, 65 cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: after merging main (4cc2542) — 204, 205, abuse_elf_loader, abuse_elf_segments, query_modules_size, dlopen_dedup, std_unwind, 202_stat_mtime, Fast: exit 0; mphnum3, mphdr56, c3, c3b: exit 1. Landing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs

@Japabu
Japabu added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit a321cf4 Sep 29, 2026
1 check passed
@Japabu
Japabu deleted the wt/toyos-phdr branch September 29, 2026 14:49
Japabu added a commit that referenced this pull request Sep 29, 2026
Brings #589, #599, #619, #620, #621, #622, #623, #624, #626, #627 and
#628. Five conflicts, each hunk accounted for:

- issues/isolation/untrusted-sites-not-yet-adopted.md (modify/delete):
  #624 deleted the sentence naming sourcegate's ban. This branch deletes
  the issue, whose one site is the kernel's NVMe completion queue, which
  goes with the kernel's NVMe driver here. The deletion stands.
- kernel/src/durability.rs (modify/delete): #624 gave Settlement's field
  an expect(dead_code) under durability-settle-blind. This branch deletes
  the durability ledger, its kernel-loom model and that feature; nothing
  in src/, kernel-loom/ or the manifests names it. The deletion stands.
- kernel/src/iod.rs (modify/delete): #589 moved sysret_ss_probe to
  crate::arch::hw. This branch deletes iod and runs the probe from the
  first syscall (syscall/dispatch.rs's task_probes), which now calls
  crate::arch::hw::sysret_ss_probe.
- kernel/src/actuator.rs: #589 added irq-storm and timer-floor beside
  ftruncate-flush-stall, which this branch deletes with the flush it
  stalled. Both are kept; ftruncate-flush-stall stays deleted.
- kernel/src/main.rs: #589 replaced `pub(crate) use arch::hw` with the
  crate's own `mod hw`; this branch dropped nvme from the drivers
  import. Both are taken.

main's rust pin is still 90697f1401a, which the fork's c4c65e3e87a
already merges, so the gitlink does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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