From d598ab06c5036ccf68bbd3c304787059cf0b9da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 22 Jun 2026 21:50:57 +0200 Subject: [PATCH] fix(runtime): fetch string_from_header must reject handle-band ids (doctor/mcp SIGSEGV) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctor / mcp-list startup segfault (EXC_BAD_ACCESS at the fetch-handle address, e.g. 0x40005/0x40006) root-causes to perry_stdlib::fetch:: string_from_header dereferencing a native registry id as a *StringHeader. lldb on a debug bundle (doctor): frame #0 perry_stdlib::fetch::string_from_header ldr w9,[x1,#0x4] x1=0x40002 frame #1 js_fetch_with_options frame #2 global_this_fetch_thunk fetch() was called with a non-string first argument — a Request/Headers object (a Web Fetch handle, registry id in [0x40000, 0xE0000)). The codegen passes the bare handle id into the url_ptr *StringHeader slot of js_fetch_with_options(url_ptr, method_ptr, body_ptr, headers_json_ptr), and string_from_header reads (*ptr).byte_len at id+4 -> deref of unmapped low address -> SIGSEGV. Its only guard was `ptr < 0x1000` (the TAG_UNDEFINED 0x1 remnant); a 0x40002 handle clears that floor. Non-deterministic for mcp list because whether the id+4 page is mapped at the call depends on heap/page layout: when mapped, byte_len reads garbage and fetch proceeds to the intended error (clean rc=1); when unmapped, SIGSEGV. Fix: reject the whole handle band (is_handle_band, < 0x100000) in string_from_header so a native handle is treated as 'not a string' (None) instead of dereferenced — same robustness pattern as the inline .length / IC-miss / class-field-guard band gates. Adds a regression test. This is the doctor/mcp wall and is INDEPENDENT of the alias-new fix (a9e41e163) and of the inline-.length handle-band fix (175fda498) — both touch different code paths; a9e41e163 merely advanced doctor far enough to reach this fetch() call. --- crates/perry-stdlib/src/fetch/mod.rs | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index 61082b4ab3..b4ec5ad233 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -120,6 +120,32 @@ mod tests { assert_ne!(native_id as usize, id); crate::common::drop_handle(native_id); } + + /// `string_from_header` must treat a handle-band value (a Fetch / native + /// registry id, not a `StringHeader` pointer) as "not a string" and return + /// `None` WITHOUT dereferencing it. Regression for the doctor / mcp-list + /// startup SIGSEGV: `fetch()` called with a non-string first argument (a + /// `Request`/`Headers` object) passed the bare handle id into the + /// `url_ptr` `*StringHeader` slot, and reading `(*ptr).byte_len` at `id+4` + /// dereferenced an unmapped low address. + #[test] + fn string_from_header_rejects_handle_band_ids() { + use perry_runtime::value::addr_class; + for &id in &[ + 1usize, // common native handle + addr_class::FETCH_HANDLE_BAND_START, // 0x40000 + addr_class::FETCH_HANDLE_BAND_START + 2, // a fetch handle id + addr_class::HANDLE_BAND_MAX - 1, // 0xFFFFF + ] { + assert!(addr_class::is_handle_band(id)); + // Must return None without dereferencing the bogus pointer. + let r = unsafe { string_from_header(id as *const StringHeader) }; + assert!( + r.is_none(), + "handle-band id {id:#x} must be rejected, got {r:?}" + ); + } + } } struct StreamState { @@ -198,6 +224,20 @@ pub(crate) unsafe fn string_from_header(ptr: *const StringHeader) -> Option()); let bytes = std::slice::from_raw_parts(data_ptr, len);