Skip to content

issues: a program run from /tmp pages in rewritable code, and a process's mapping count is bounded only by its window - #697

Merged
Japabu merged 1 commit into
mainfrom
wt/toyos-elfbound
Oct 3, 2026
Merged

Japabu merged 1 commit into
mainfrom
wt/toyos-elfbound

Conversation

@Japabu

@Japabu Japabu commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Files two defects a read-only roast found, and records why its third finding, a kernel panic from an unbounded section header read in the loader, is not a defect: no test could make it red.

What changed

  • issues/isolation/a-program-run-from-tmp-pages-in-code-any-process-can-rewrite.md. A spawn by path of a /tmp file fills each 2 MiB window from the live file cache at the fault (TmpfsBacking::read_page, handle_page_fault). Any process that writes the file after the spawn changes code the program has not run yet. Filed by reading. Owner: the orchestrator. Exit: a rewrite test that reds while the child runs the rewritten bytes.
  • issues/kernel/a-processs-mapping-count-is-bounded-only-by-its-address-window.md. sys_mmap bounds a process's mappings only by the placement window, and a PROT_NONE mapping pins no page. Reading goes one step past the roast's "exhausts the kernel heap": ProcessData::mmap_regions holds 40-byte records, so the push past 32,768 of them asks for 65,536 × 40 = 2,621,440 bytes. That is past MAX_HEAP_ALLOC (2,093,056), where KernelAllocator::alloc asserts. Filed by reading. Owner: the orchestrator. Exit: a PROT_NONE-until-refused test that reds on the panic.

The section header read could not be made red

The claim: an executable with PT_DYNAMIC, e_shnum ≥ 32,705 and a file of about 3 MiB gets past the earlier checks and reaches read_file_range at kernel/src/loader/symbols.rs:97.

What the code does: parse_layout (kernel/src/elf/mod.rs:40-49) refuses a file whose section_headers().byte_len() > MAX_HEAP_ALLOC with ELF: section header table larger than one kernel allocation. spawn calls it at kernel/src/loader/mod.rs:385 and load_shared_lib at kernel/src/elf/mod.rs:354. Both calls run before any section header read: read_symtab, read_backtrace_table, rela_dyn_from_sections and dynsym_count_from_sections. The kernel calls Layout::parse nowhere else. 32,705 is exactly the smallest e_shnum this check refuses. PT_DYNAMIC changes nothing, because parse_layout runs first. Case 8 of abuse_elf_loader (shnum_past_heap) already spawns a 3 MiB file with e_shnum 40,000.

What was run:

  1. Host probe (source in a comment below). It feeds the probe's exact header through toyos_elf::Layout::parse and compares the table length with MAX_HEAP_ALLOC, using the same comparison as parse_layout. Exit 0:
    e_shnum 32704: PT_DYNAMIC true byte_len 2093056 MAX_HEAP_ALLOC 2093056 -> parse_layout accepts
    e_shnum 32705: PT_DYNAMIC true byte_len 2093120 MAX_HEAP_ALLOC 2093056 -> parse_layout refuses
    e_shnum 40000: PT_DYNAMIC true byte_len 2560000 MAX_HEAP_ALLOC 2093056 -> parse_layout refuses
    e_shnum 65535: PT_DYNAMIC true byte_len 4194240 MAX_HEAP_ALLOC 2093056 -> parse_layout refuses
    
    The largest table this accepts is exactly MAX_HEAP_ALLOC. alloc.rs:529 accepts that size, because its check is <=.
  2. Guest probe (patch in a comment below). It adds six cases to abuse_elf_loader: spawn by path at e_shnum 32,705 and 32,704, each with and without a DT_NEEDED library (which reaches read_symtab), plus dlopen at both counts. With the patch applied, cargo test --test toyos-build -- --nocapture abuse_elf_loader exited 1 with No test matches filter Some("abuse_elf_loader"). It never booted a guest. Since 520c0d1, every Rust test binary in the shared boot runs only on the T14 (shared_metal), so the only tier that spawns a crafted file is metal. Metal is the orchestrator's, and no T14 run was requested. The patch was reverted in the same script and the tree is clean. An earlier attempt named a test target that does not exist (--test toyos) and exited 101 before building anything.

The loader's other reads whose size comes from the file are each bounded before they allocate:

read bound
the 4 KiB header 4096.min(file_size)
PT_DYNAMIC, DT_RELASZ, DT_PLTRELSZ, DT_STRSZ, .dynsym count × 24 read_elf_table, refused past MAX_HEAP_ALLOC
.gnu.hash MAX_GNU_HASH_BYTES (64 KiB)
rela_dyn_from_sections' table and SHT_RELA read_elf_table
read_symtab's .symtab and .strtab read_elf_table
the backtrace table checked against the file size and MAX_SYMBOL_BYTES, then read into a PageAlloc (pages, not the heap)
the TLS template OwnedAlloc::new, refused past MAX_HEAP_ALLOC
a library's segments a PageAlloc of the rounded span

Gates

  • cargo run -- --ci host at 4e192a670: EXIT=0, Host: 67 step(s), all green (/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/elfbound/ci-host.log, ci-host.exit).
  • --build-only and guest tests were not run: the diff touches only issues/, which no image and no guest reads.

Unsure

  • The 40-byte MmapRegion, the Vec growth to 65,536 and the window counts (260,094 placements with the guard, 520,188 FIXED) come from reading the code plus arithmetic. None of them was measured.
  • e_shnum 32,704 reads exactly MAX_HEAP_ALLOC bytes into one Vec. By reading, dlmalloc serves that size plus its bands from one 2 MiB page, which is what MAX_HEAP_ALLOC is sized for. The metal test that held this (heap_ceiling_bounds) is not in the tree; the guest-suite track lists it as a metal stage. The probe patch covers this count, but only on metal.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8

…ss's mapping count is unbounded

Two defects a read-only roast found, filed by reading and unmeasured, owner
the orchestrator:

- A spawn by path of a /tmp file pages its segments in from the live file
  cache at each fault, so any process that writes the file after the spawn
  changes the running program's not-yet-faulted code.
- sys_mmap bounds a process's mappings only by the placement window. A
  PROT_NONE loop pins no physical page, and by reading the push past 32,768
  records grows ProcessData::mmap_regions to 65,536 x 40 bytes, past
  MAX_HEAP_ALLOC, where the kernel allocator asserts.

The roast's third finding, an unbounded section header read in the loader,
is refuted by reading: kernel/src/elf/mod.rs's parse_layout refuses a section
header table past MAX_HEAP_ALLOC before either loader path reads one, and
e_shnum 32,705 is the smallest count it refuses (host probe in the pull
request).

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

Japabu commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Evidence for the refuted section-header finding. Neither of these is committed.

Guest probe: probe-shnum.patch, applied with git apply --check and then git apply, run, then reverted with git apply -R in one script. cargo test --test toyos-build -- --nocapture abuse_elf_loader exited 1 with No test matches filter Some("abuse_elf_loader"), because the shared boot runs on metal only.

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 0244faec2..7c46692b6 100644
--- a/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
+++ b/tests/toyos-rust-tests/src/bin/abuse_elf_loader.rs
@@ -478,6 +478,39 @@ fn main() {
         )),
     );
 
+    // PROBE: PT_DYNAMIC, a 3 MiB file, e_shnum either side of MAX_HEAP_ALLOC / 64.
+    {
+        let elf = |shnum: u16, needed: bool| {
+            let mut tags = vec![(DT_STRTAB, 0x1800u64), (DT_STRSZ, 0x100)];
+            if needed {
+                tags.push((DT_NEEDED, 1));
+            }
+            Elf::new(BIG)
+                .ph(Phdr::load(0, 0, BIG as u64, BIG as u64, PF_R))
+                .ph(Phdr { kind: PT_DYNAMIC, flags: PF_R, offset: 0x1000, vaddr: 0x1000, filesz: 0x200, memsz: 0x200, align: 8 })
+                .entry(0)
+                .sections(0x10_0000, shnum, 64)
+                .dynamic(0x1000, &tags)
+                .poke(0x1801, b"probe_dep.so\0")
+                .build()
+        };
+        write_file_in(BIG_DIR, "probe_dep.so", &so_with(&[], &[], None));
+        for (name, shnum, needed) in [
+            ("probe_exe_32705", 32_705u16, false),
+            ("probe_exe_32704", 32_704, false),
+            ("probe_needed_32705", 32_705, true),
+            ("probe_needed_32704", 32_704, true),
+        ] {
+            let outcome = spawn_path(&write_file_in(BIG_DIR, name, &elf(shnum, needed))).map(|_| ());
+            println!("PROBE {name}: {outcome:?}");
+        }
+        for (name, shnum) in [("probe_so_32705.so", 32_705u16), ("probe_so_32704.so", 32_704)] {
+            let path = write_file_in(BIG_DIR, name, &elf(shnum, false));
+            let outcome = unsafe { libloading::Library::new(&path) }.map(drop).map_err(|e| e.to_string());
+            println!("PROBE {name}: {outcome:?}");
+        }
+    }
+
     // 10. A .gnu.hash whose chain array never terminates. The symbol-count
     //     walk followed `chain[i] & 1` with no bound but the slice's own
     //     assert, and a zeroed image never sets that bit.

Host probe: a standalone crate depending on toyos-elf by path. cargo run exited 0.

[package]
name = "elfbound-hostprobe"
version = "0.0.0"
edition = "2021"
publish = false

[dependencies]
toyos-elf = { path = "/Users/jan/Dev/jan/toyos-elfbound/toyos-elf" }

[workspace]
//! The roast's file, byte for byte as the probe patch builds it, through the
//! parse the kernel's `elf::parse_layout` wraps; the kernel's own bound is
//! `kernel/src/mm/mod.rs`'s `MAX_HEAP_ALLOC`, copied here by value.
use toyos_elf::{Layout, Machine};

const MAX_HEAP_ALLOC: usize = 2 * 1024 * 1024 - 4096;

fn header(shnum: u16) -> Vec<u8> {
    let mut b = vec![0u8; 4096];
    b[..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
    b[4] = 2; b[5] = 1; b[6] = 1;
    b[16..18].copy_from_slice(&3u16.to_le_bytes());
    b[18..20].copy_from_slice(&62u16.to_le_bytes());
    b[20..24].copy_from_slice(&1u32.to_le_bytes());
    b[24..32].copy_from_slice(&0u64.to_le_bytes());
    b[32..40].copy_from_slice(&64u64.to_le_bytes());
    b[40..48].copy_from_slice(&0x10_0000u64.to_le_bytes());
    b[52..54].copy_from_slice(&64u16.to_le_bytes());
    b[54..56].copy_from_slice(&56u16.to_le_bytes());
    b[56..58].copy_from_slice(&2u16.to_le_bytes());
    b[58..60].copy_from_slice(&64u16.to_le_bytes());
    b[60..62].copy_from_slice(&shnum.to_le_bytes());
    let big = 3u64 * 1024 * 1024;
    // PT_LOAD over the whole 3 MiB file, PF_R; PT_DYNAMIC at 0x1000.
    for (i, (kind, flags, off, vaddr, sz)) in [(1u32, 4u32, 0u64, 0u64, big), (2, 4, 0x1000, 0x1000, 0x200)].into_iter().enumerate() {
        let at = 64 + i * 56;
        b[at..at + 4].copy_from_slice(&kind.to_le_bytes());
        b[at + 4..at + 8].copy_from_slice(&flags.to_le_bytes());
        b[at + 8..at + 16].copy_from_slice(&off.to_le_bytes());
        b[at + 16..at + 24].copy_from_slice(&vaddr.to_le_bytes());
        b[at + 24..at + 32].copy_from_slice(&vaddr.to_le_bytes());
        b[at + 32..at + 40].copy_from_slice(&sz.to_le_bytes());
        b[at + 40..at + 48].copy_from_slice(&sz.to_le_bytes());
        b[at + 48..at + 56].copy_from_slice(&8u64.to_le_bytes());
    }
    b
}

fn main() {
    for shnum in [32_704u16, 32_705, 40_000, u16::MAX] {
        let layout = Layout::parse(&header(shnum), Machine::X86_64).expect("parses");
        let table = layout.section_headers().expect("a section table");
        let len = table.byte_len();
        println!(
            "e_shnum {shnum}: PT_DYNAMIC {} byte_len {len} MAX_HEAP_ALLOC {MAX_HEAP_ALLOC} -> parse_layout {}",
            layout.dynamic().is_some(),
            if len > MAX_HEAP_ALLOC { "refuses" } else { "accepts" },
        );
    }
}

Output:

e_shnum 32704: PT_DYNAMIC true byte_len 2093056 MAX_HEAP_ALLOC 2093056 -> parse_layout accepts
e_shnum 32705: PT_DYNAMIC true byte_len 2093120 MAX_HEAP_ALLOC 2093056 -> parse_layout refuses
e_shnum 40000: PT_DYNAMIC true byte_len 2560000 MAX_HEAP_ALLOC 2093056 -> parse_layout refuses
e_shnum 65535: PT_DYNAMIC true byte_len 4194240 MAX_HEAP_ALLOC 2093056 -> parse_layout refuses

@Japabu

Japabu commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Review, round 1, head 4e192a670. No earlier round.

Checked, as briefed:

  • Merge with origin/main. The branch has no merge. Its base is 39c5fd559, and main is one first-parent commit ahead (The system programs are the supervisor, netstack, logkeeper, soundserver, diskserver, fileserver and sshserver #695, 932fb7098). git merge-tree --write-tree origin/main 4e192a670 exits 0 (tree a552c6e93). The system programs are the supervisor, netstack, logkeeper, soundserver, diskserver, fileserver and sshserver #695 touches kernel/src/loader/mod.rs and kernel/src/process.rs only in the spawn_init rename. It touches none of the other files the issues cite: git diff --name-only 39c5fd559 origin/main over them prints nothing. Every cited symbol is on main in the cited file, and so is issues/isolation/a-swapped-binary-lives-where-any-process-can-rewrite-it.md. Neither issue names a program, by its old name or its new one: git grep -E '\b(init|netd|logd|soundd|blockd|fsd|sshd)\b' over the two files exits 1. Neither slug is among main's 638 issue paths.
  • The refuted section-header panic. By my own reading, the refutation is right:
    • All four readers size their read by layout.section_headers()?.byte_len(): kernel/src/loader/symbols.rs:64 and :97, kernel/src/loader/mod.rs:327, kernel/src/elf/mod.rs:541.
    • The kernel builds a Layout only in elf::parse_layout, which refuses byte_len() > MAX_HEAP_ALLOC.
    • toyos-elf/src/layout.rs:622 closes both ways around that check. An e_shentsize other than 64 yields no table, so the checked length and the read's stride are the same number. An e_shnum of 0 yields no table, so no extended count read from section 0 exists.
    • read_file_range clamps to the file size, and alloc.rs:529 uses <=.
    • At e_shnum 32,704, dlmalloc 0.2.13 can serve the banded 2,093,120 bytes. pad_request gives 2,093,136. sys_alloc asks for align_up(2,093,136 + 80 + 16, 64 KiB) = 2,097,152, which KernelPageSource grants, leaving a top of 2,097,072. This is by reading, as the body's "Unsure" says: no tier measures it while heap_ceiling_bounds is out of the tree.
  • The two issues' claims.
    • MmapRegion is 8 + 8 + 24 = 40 bytes; Option<Vec> takes the niche.
    • The address space's ledger is a BTreeMap (kernel/src/vma.rs:67), so the Vec is the first to cross the ceiling.
    • KernelAllocator does not override realloc, so the growth reaches the assert in alloc.
    • TmpfsBacking::read_page reads the live file cache at each fault, and no write path refuses a file that is being paged from.

Net lines (git diff --shortstat origin/main...4e192a670): 2 files, +56. Production 0, tests 0, issues +56.

BLOCKER

None.

NOTE

  • PR body, "Gates" — the host line names neither the head nor the log — a claim stands only on its log at that head. ci-host.log in the orchestrator's elfbound/ ends with 20:59:01 [ci] Host: 67 step(s), all green, and ci-host.exit reads EXIT=0, after the commit at 20:52:23 UTC. Name 4e192a670 and that file.

REMOVE

None.

LAND AFTER NAMED CHANGES

@Japabu
Japabu marked this pull request as ready for review October 3, 2026 21:12
@Japabu
Japabu enabled auto-merge October 3, 2026 21:12
@Japabu
Japabu added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 4560019 Oct 3, 2026
6 checks passed
@Japabu
Japabu deleted the wt/toyos-elfbound branch October 3, 2026 21:49
Japabu added a commit that referenced this pull request Oct 3, 2026
Japabu added a commit that referenced this pull request Oct 4, 2026
Japabu added a commit that referenced this pull request Oct 4, 2026
…too, and the region probes reach every placement path

The review found a second ledger with the region ledger's defect: an
exited thread keeps its ThreadEntry until a join collects it, so a
spawn/exit loop that never joins grows the thread table's hashbrown map
to 32,768 buckets at its 14,337th entry, a 2,392,080-byte allocation past
MAX_HEAP_ALLOC. The orchestrator ruled that the sweep bounds what it
finds, so this bounds both it and the dlopen ledger filed last round.

- toyos_abi::syscall declares MAX_REGIONS, MAX_THREADS (4096) and
  MAX_LIBRARIES (1024), each read by the kernel and by its test, as
  MAX_HANDLES is RawHandle::MAX_SLOTS.
- Threads: kernel::proclife::spawn's admission answers Full at
  MAX_THREADS, zombies counted, at both of its moments, so the insert
  that decides refuses a spawn a sibling filled the process under. A
  const assertion in process.rs holds MAX_THREADS to hashbrown 0.16's
  resize rule (a table grows only once its live entries pass 7/16 of its
  buckets): 16,384 buckets at most, 1,196,048 bytes.
- Libraries: sys_dlopen refuses a name it does not hold past
  MAX_LIBRARIES under the guard that registers, beside the recheck a
  sibling's registration needs; the refused mapping goes down with the
  guard. 2 x 1024 x 544 bytes fits one allocation from any starting
  capacity, which a const assertion holds.
- Regions: alloc_mapped is alloc with RegionKind::Mapped, so the bound
  has one placement site; the comment and doc the review named go.
- abuse_mmap_regions reads the SyscallError off the raw syscall and, at
  the bound, refuses a FIXED mapping at a free address and an RW mapping,
  maps a FIXED replacement, and shows each refusal the bound's by serving
  the same request once a region is freed.
- abuse_thread_table and abuse_dlopen_ledger are the other two bounds'
  tests. The three ride one T14 boot of their own (testcases-bounds) on
  the process_bound_* metal rows, off the shared boot.
- #697's mapping-count issue is closed by this branch and goes, with the
  dlopen issue this branch filed. Filed: every mmap walks every region
  under both locks; demand_pages outgrows one allocation at 128 GiB; a
  thread std detaches holds its place for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
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