Repository navigation
issues: a program run from /tmp pages in rewritable code, and a process's mapping count is bounded only by its window - #697
Merged
Merged
Conversation
…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
Collaborator
Author
|
Evidence for the refuted section-header finding. Neither of these is committed. Guest probe: 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 [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: |
Collaborator
Author
|
Review, round 1, head Checked, as briefed:
Net lines ( BLOCKER None. NOTE
REMOVE None. LAND AFTER NAMED CHANGES |
Japabu
marked this pull request as ready for review
October 3, 2026 21:12
Japabu
enabled auto-merge
October 3, 2026 21:12
This was referenced Oct 3, 2026
Japabu
added a commit
that referenced
this pull request
Oct 3, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
Japabu
added a commit
that referenced
this pull request
Oct 4, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/tmpfile 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_mmapbounds a process's mappings only by the placement window, and aPROT_NONEmapping pins no page. Reading goes one step past the roast's "exhausts the kernel heap":ProcessData::mmap_regionsholds 40-byte records, so the push past 32,768 of them asks for 65,536 × 40 = 2,621,440 bytes. That is pastMAX_HEAP_ALLOC(2,093,056), whereKernelAllocator::allocasserts. Filed by reading. Owner: the orchestrator. Exit: aPROT_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 reachesread_file_rangeatkernel/src/loader/symbols.rs:97.What the code does:
parse_layout(kernel/src/elf/mod.rs:40-49) refuses a file whosesection_headers().byte_len() > MAX_HEAP_ALLOCwithELF: section header table larger than one kernel allocation.spawncalls it atkernel/src/loader/mod.rs:385andload_shared_libatkernel/src/elf/mod.rs:354. Both calls run before any section header read:read_symtab,read_backtrace_table,rela_dyn_from_sectionsanddynsym_count_from_sections. The kernel callsLayout::parsenowhere else. 32,705 is exactly the smalleste_shnumthis check refuses.PT_DYNAMICchanges nothing, becauseparse_layoutruns first. Case 8 ofabuse_elf_loader(shnum_past_heap) already spawns a 3 MiB file withe_shnum40,000.What was run:
toyos_elf::Layout::parseand compares the table length withMAX_HEAP_ALLOC, using the same comparison asparse_layout. Exit 0:MAX_HEAP_ALLOC.alloc.rs:529accepts that size, because its check is<=.abuse_elf_loader: spawn by path ate_shnum32,705 and 32,704, each with and without aDT_NEEDEDlibrary (which reachesread_symtab), plusdlopenat both counts. With the patch applied,cargo test --test toyos-build -- --nocapture abuse_elf_loaderexited 1 withNo 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:
4096.min(file_size)PT_DYNAMIC,DT_RELASZ,DT_PLTRELSZ,DT_STRSZ,.dynsymcount × 24read_elf_table, refused pastMAX_HEAP_ALLOC.gnu.hashMAX_GNU_HASH_BYTES(64 KiB)rela_dyn_from_sections' table andSHT_RELAread_elf_tableread_symtab's.symtaband.strtabread_elf_tableMAX_SYMBOL_BYTES, then read into aPageAlloc(pages, not the heap)OwnedAlloc::new, refused pastMAX_HEAP_ALLOCPageAllocof the rounded spanGates
cargo run -- --ci hostat4e192a670: EXIT=0,Host: 67 step(s), all green(/Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/elfbound/ci-host.log,ci-host.exit).--build-onlyand guest tests were not run: the diff touches onlyissues/, which no image and no guest reads.Unsure
MmapRegion, the Vec growth to 65,536 and the window counts (260,094 placements with the guard, 520,188FIXED) come from reading the code plus arithmetic. None of them was measured.e_shnum32,704 reads exactlyMAX_HEAP_ALLOCbytes into oneVec. By reading, dlmalloc serves that size plus its bands from one 2 MiB page, which is whatMAX_HEAP_ALLOCis 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