From 3583af75906ca522ce0a385df9826db4ac5aab9b Mon Sep 17 00:00:00 2001 From: RayVentura Date: Fri, 18 Sep 2026 19:26:05 -0400 Subject: [PATCH 1/2] =?UTF-8?q?jmap=5Fdumper:=20validate=20the=20resolved?= =?UTF-8?q?=20GUObjectArray=20against=20the=20struct=20invariants=20and=20?= =?UTF-8?q?walk=20to=20the=20real=20base=20when=20a=20neighbouring=20membe?= =?UTF-8?q?r=20was=20matched=20(UE=205.8=20shipping=20builds=20resolve=20t?= =?UTF-8?q?o=20ObjAvailableListEstimateCount,=20+0x68=20=E2=80=94=20truman?= =?UTF-8?q?k/jmap#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jmap_dumper/src/lib.rs | 94 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/jmap_dumper/src/lib.rs b/jmap_dumper/src/lib.rs index bc71d05..117ec4e 100644 --- a/jmap_dumper/src/lib.rs +++ b/jmap_dumper/src/lib.rs @@ -517,6 +517,95 @@ async fn read_u32(mem: &M, addr: u64) -> Result { Ok(u32::from_le_bytes(buf)) } +async fn read_u64(mem: &M, addr: u64) -> Result { + let mut buf = [0u8; 8]; + mem.read_buf(addr, &mut buf).await?; + Ok(u64::from_le_bytes(buf)) +} + +/// Does `base` look like a live `FUObjectArray` for this engine version? +/// +/// The pattern resolvers return an address INSIDE the array's code references +/// (a `lea` of some member); a compiler/layout change can make that member a +/// different one than the pattern assumed — UE 5.8 shipping builds resolved to +/// `ObjAvailableListEstimateCount` (+0x68), and every dump came out empty +/// (trumank/jmap#30). The struct itself carries enough invariants to check: +/// a readable chunk table, 0 < NumElements <= MaxElements, and NumChunks equal +/// to ceil(NumElements / 65536) with NumChunks <= MaxChunks. +async fn guobject_array_plausible( + mem: &M, + base: u64, + version: (u16, u16), +) -> bool { + // (objects_ptr, num_elements, max_elements, num_chunks, max_chunks) offsets + let (o_objects, o_num, o_max, o_nchunks, o_maxchunks) = if version >= (5, 8) { + // FChunkedFixedUObjectArray ObjObjects is the FIRST member (5.8): + // Objects**, NumElements, MaxElements, NumChunks, MaxChunks, PreAllocated* + (0u64, 8u64, 12u64, 16u64, 20u64) + } else if version >= (4, 20) { + // ObjFirstGCIndex, ObjLastNonGCIndex, MaxObjectsNotConsideredByGC, bool + // then ObjObjects: Objects**, PreAllocated*, MaxElements, NumElements, + // MaxChunks, NumChunks + (0x10, 0x24, 0x20, 0x2c, 0x28) + } else { + return true; // older layouts: no chunked array to validate against + }; + let Ok(objects) = read_u64(mem, base + o_objects).await else { return false }; + let Ok(num) = read_u32(mem, base + o_num).await else { return false }; + let Ok(max) = read_u32(mem, base + o_max).await else { return false }; + let Ok(nchunks) = read_u32(mem, base + o_nchunks).await else { return false }; + let Ok(maxchunks) = read_u32(mem, base + o_maxchunks).await else { return false }; + if objects < 0x10000 || num == 0 || num > 50_000_000 || num > max { + return false; + } + if nchunks == 0 || nchunks > maxchunks || maxchunks > 65536 { + return false; + } + if nchunks != num.div_ceil(65536) { + return false; + } + // the first chunk pointer must itself be readable + match read_u64(mem, objects).await { + Ok(chunk0) if chunk0 >= 0x10000 => read_u64(mem, chunk0).await.is_ok(), + _ => false, + } +} + +/// Validate the resolved GUObjectArray against the struct's own invariants and, +/// when the pattern landed on a neighbouring member, walk +-0x200 to the real +/// base. Never touches a user-supplied override. +async fn validate_guobject_array( + mem: &M, + resolved: u64, + version: (u16, u16), +) -> u64 { + if guobject_array_plausible(mem, resolved, version).await { + return resolved; + } + for delta in (8..=0x200u64).step_by(8) { + for cand in [resolved.wrapping_sub(delta), resolved + delta] { + if guobject_array_plausible(mem, cand, version).await { + crate::warn!( + "GUObjectArray pattern resolved to {resolved:#X} which is not a valid \ + FUObjectArray for UE {}.{}; using {cand:#X} ({:+#x}) — a neighbouring \ + member was matched (layout drift, see trumank/jmap#30)", + version.0, + version.1, + cand as i64 - resolved as i64 + ); + return cand; + } + } + } + crate::warn!( + "GUObjectArray at {resolved:#X} does not look like a live FUObjectArray for UE {}.{} \ + and no valid base was found within +-0x200; dumping anyway", + version.0, + version.1 + ); + resolved +} + pub async fn resolve_config( mem: &impl mem::Mem, image: &Image<'_>, @@ -561,7 +650,10 @@ pub async fn resolve_config( ); } let engine_version = engine_version.unwrap(); - let guobject_array = guobject_array.unwrap(); + let guobject_array = match overrides.guobject_array { + Some(explicit) => explicit, + None => validate_guobject_array(mem, guobject_array.unwrap(), engine_version).await, + }; let fname_pool = fname_pool.unwrap(); let mut build_config = overrides.build_config; From 030ea6e6e2cf4fd7df128347cca4f53147d2e601 Mon Sep 17 00:00:00 2001 From: RayVentura Date: Fri, 18 Sep 2026 19:31:55 -0400 Subject: [PATCH 2/2] jmap_dumper: print the base adjustment with a sign, not two's complement --- jmap_dumper/src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/jmap_dumper/src/lib.rs b/jmap_dumper/src/lib.rs index 117ec4e..7d08dde 100644 --- a/jmap_dumper/src/lib.rs +++ b/jmap_dumper/src/lib.rs @@ -585,13 +585,15 @@ async fn validate_guobject_array( for delta in (8..=0x200u64).step_by(8) { for cand in [resolved.wrapping_sub(delta), resolved + delta] { if guobject_array_plausible(mem, cand, version).await { + let delta = cand as i64 - resolved as i64; crate::warn!( "GUObjectArray pattern resolved to {resolved:#X} which is not a valid \ - FUObjectArray for UE {}.{}; using {cand:#X} ({:+#x}) — a neighbouring \ + FUObjectArray for UE {}.{}; using {cand:#X} ({}{:#x}) — a neighbouring \ member was matched (layout drift, see trumank/jmap#30)", version.0, version.1, - cand as i64 - resolved as i64 + if delta < 0 { "-" } else { "+" }, + delta.unsigned_abs() ); return cand; }