From 0e3069cd9c11884f8b74f9da6f5b2f2238088c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Sun, 9 Aug 2026 08:06:26 -0400 Subject: [PATCH 1/4] Fix dump driver evidence grading --- crates/windbg-tool/src/cli/platform.rs | 53 ++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 72f7589..8af2b78 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -5643,12 +5643,15 @@ fn dump_evidence_grades( Some("not_set") => "not_established", _ => "not_established", }; - let external_driver_grade = - if driver_evidence["status"].as_str() == Some("direct_evidence_present") { - "observed" - } else { - "not_established" - }; + let stack_participant_grade = driver_evidence["observations"] + .as_array() + .is_some_and(|observations| { + observations + .iter() + .any(|observation| observation["kind"] == "validated_stack_module") + }) + .then_some("observed") + .unwrap_or("not_established"); json!({ "grading_scale": { "observed": "Captured directly from the dump.", @@ -5667,10 +5670,15 @@ fn dump_evidence_grades( "grade": driver_grade, "detail": "KiBugCheckDriver is meaningful only when the kernel populated a non-null driver string.", }, + { + "topic": "third_party_driver_stack_participation", + "grade": stack_participant_grade, + "detail": "A module in the validated saved stack is a direct participation observation, not an attribution of the initiating write or root cause.", + }, { "topic": "third_party_driver_causation", - "grade": external_driver_grade, - "detail": "Loaded modules are inventory only. Attribution requires a fault, validated stack, or populated bugcheck-driver observation.", + "grade": "not_established", + "detail": "Neither loaded-module inventory nor a module's presence in the saved stack proves third-party-driver causation. A non-null bugcheck-driver record or faulting driver code remains separate evidence.", }, ], }) @@ -7261,6 +7269,35 @@ mod tests { assert_eq!(grades["observations"][0]["grade"], "possible"); assert_eq!(grades["observations"][1]["grade"], "not_established"); assert_eq!(grades["observations"][2]["grade"], "not_established"); + assert_eq!(grades["observations"][3]["grade"], "not_established"); + + let stack_participant_grades = dump_evidence_grades( + &json!({ + "status": "direct_evidence_present", + "observations": [{ + "kind": "validated_stack_module", + "module": "thirdparty" + }] + }), + &json!({"classification": "outside_central_table_tracker_like_unclassified"}), + &json!({"status": "not_set"}), + ); + assert_eq!( + stack_participant_grades["observations"][2]["topic"], + "third_party_driver_stack_participation" + ); + assert_eq!( + stack_participant_grades["observations"][2]["grade"], + "observed" + ); + assert_eq!( + stack_participant_grades["observations"][3]["topic"], + "third_party_driver_causation" + ); + assert_eq!( + stack_participant_grades["observations"][3]["grade"], + "not_established" + ); } #[test] From 96eaed99f8f02b51fb685eefe1f4a80d1566f643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Sun, 9 Aug 2026 08:13:57 -0400 Subject: [PATCH 2/4] Format dump driver evidence fix --- crates/windbg-tool/src/cli/platform.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 8af2b78..2cedb51 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -5643,15 +5643,19 @@ fn dump_evidence_grades( Some("not_set") => "not_established", _ => "not_established", }; - let stack_participant_grade = driver_evidence["observations"] - .as_array() - .is_some_and(|observations| { - observations - .iter() - .any(|observation| observation["kind"] == "validated_stack_module") - }) - .then_some("observed") - .unwrap_or("not_established"); + let stack_participant_grade = + if driver_evidence["observations"] + .as_array() + .is_some_and(|observations| { + observations + .iter() + .any(|observation| observation["kind"] == "validated_stack_module") + }) + { + "observed" + } else { + "not_established" + }; json!({ "grading_scale": { "observed": "Captured directly from the dump.", From 5c79aec0cb03fba2a198fc9831968f2f9bbeed34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Sun, 9 Aug 2026 20:08:24 -0400 Subject: [PATCH 3/4] Expose snapshot events for dump targets --- crates/windbg-ttd/src/backend.rs | 19 +++++++++++++++++-- crates/windbg-ttd/src/targets.rs | 1 - 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/windbg-ttd/src/backend.rs b/crates/windbg-ttd/src/backend.rs index 2685050..cd69fd5 100644 --- a/crates/windbg-ttd/src/backend.rs +++ b/crates/windbg-ttd/src/backend.rs @@ -95,6 +95,7 @@ pub fn capability_contract(kind: &str) -> Value { "stack": "supported", "query_symbols": "supported", "query_source": "supported", + "last_event": "supported_snapshot_event_if_available", "step": "unsupported", "continue": "unsupported", "set_breakpoint": "unsupported", @@ -105,13 +106,13 @@ pub fn capability_contract(kind: &str) -> Value { "timeline": "unsupported" }, "mutability": { - "read_only": ["memory", "modules", "threads", "stack", "symbols", "source", "disassembly"], + "read_only": ["last_event", "memory", "modules", "threads", "stack", "symbols", "source", "disassembly"], "target_execution": [], "destructive": ["close"] }, "required_identifiers": ["target_id"], "limitations": [ - "Dump targets are immutable snapshots and do not have a live event stream." + "Dump targets are immutable snapshots and do not have a live event stream. The bounded last-event query reports DbgEng's captured load-time stop only if it is available." ] }), "dbgeng_remote_plan" => json!({ @@ -201,4 +202,18 @@ mod tests { .iter() .any(|contract| contract["backend"] == "dbgeng_dump")); } + + #[test] + fn dump_contract_distinguishes_last_event_from_live_event_streaming() { + let contract = capability_contract("dbgeng_dump"); + assert_eq!( + contract["operations"]["last_event"], + "supported_snapshot_event_if_available" + ); + assert!(contract["limitations"] + .as_array() + .is_some_and(|limitations| limitations.iter().any(|detail| detail + .as_str() + .is_some_and(|detail| detail.contains("do not have a live event stream"))))); + } } diff --git a/crates/windbg-ttd/src/targets.rs b/crates/windbg-ttd/src/targets.rs index 5422aed..4b5bd22 100644 --- a/crates/windbg-ttd/src/targets.rs +++ b/crates/windbg-ttd/src/targets.rs @@ -698,7 +698,6 @@ impl TargetRegistry { pub fn last_event(&self, request: TargetRequest) -> anyhow::Result { let target = self.target(request.target_id)?; - ensure_live_target(request.target_id, target.kind())?; Ok(TargetEventResponse { target_id: request.target_id, event: target From 080f3644e437e16bf91bb8f4bf56ab0c039e2d0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Sun, 9 Aug 2026 23:08:32 -0400 Subject: [PATCH 4/4] Inspect dump event and minidump metadata --- crates/windbg-dbgeng/src/lib.rs | 602 +++++++++++++++++++++++++ crates/windbg-tool/src/cli/platform.rs | 79 ++++ 2 files changed, 681 insertions(+) diff --git a/crates/windbg-dbgeng/src/lib.rs b/crates/windbg-dbgeng/src/lib.rs index e0d75ee..93d727f 100644 --- a/crates/windbg-dbgeng/src/lib.rs +++ b/crates/windbg-dbgeng/src/lib.rs @@ -9,6 +9,7 @@ use std::sync::{ }; use std::{ env, fs, + io::{Read, Seek, SeekFrom}, path::{Path, PathBuf}, sync::OnceLock, time::Instant, @@ -29,6 +30,8 @@ pub const MAX_VIRTUAL_MEMORY_MAP_REGIONS: u32 = 4096; pub const MAX_THREAD_ACCOUNTING_THREADS: u32 = 128; pub const MAX_MODULE_PARAMETER_QUERIES: usize = 128; pub const MAX_SYMBOL_ENTRY_OFFSET_REGIONS: usize = 16; +pub const MAX_MINIDUMP_DIRECTORY_ENTRIES: u32 = 512; +pub const MAX_CAPTURED_EVENT_CODE_BYTES: u32 = 64; const DEFAULT_DBGENG_SYMBOL_CACHE: &str = ".windbg-symbol-cache"; const DBGENG_DLL_NAME: &str = "dbgeng.dll"; const DBGENG_RUNTIME_COMPONENTS: [&str; 4] = [ @@ -1003,6 +1006,52 @@ pub struct DebuggerEventInfo { pub exit_code: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct CapturedEventCode { + pub source: String, + pub status: String, + pub code_offset: Option, + pub code_bytes: Option, + pub bytes_returned: Option, + pub byte_limit: u32, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MinidumpDirectoryEntry { + pub index: u32, + pub stream_type: u32, + pub stream_name: String, + pub data_rva: u32, + pub data_size: u32, + pub range_status: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MinidumpStreamPresence { + pub stream_type: u32, + pub stream_name: String, + pub occurrences: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MinidumpDirectoryInventory { + pub source: String, + pub status: String, + pub file_bytes: Option, + pub signature: Option, + pub version: Option, + pub stream_count: Option, + pub stream_directory_rva: Option, + pub checksum: Option, + pub time_date_stamp: Option, + pub flags: Option, + pub directory_entry_limit: u32, + pub entries: Vec, + pub documented_stream_presence: Vec, + pub detail: String, +} + #[derive(Debug, Clone, Serialize)] pub struct ThreadContext { pub thread: ThreadInfo, @@ -1038,6 +1087,324 @@ pub fn write_process_dump(options: ProcessDumpOptions) -> anyhow::Result MinidumpDirectoryInventory { + let file_bytes = match fs::metadata(path) { + Ok(metadata) => metadata.len(), + Err(error) => { + return unavailable_minidump_directory( + None, + format!("Could not read dump metadata: {error}"), + ); + } + }; + if file_bytes < MINIDUMP_HEADER_SIZE as u64 { + return unavailable_minidump_directory( + Some(file_bytes), + format!( + "File contains {file_bytes} bytes, fewer than the documented {MINIDUMP_HEADER_SIZE}-byte MINIDUMP_HEADER." + ), + ); + } + + let mut file = match fs::File::open(path) { + Ok(file) => file, + Err(error) => { + return unavailable_minidump_directory( + Some(file_bytes), + format!("Could not open the dump for bounded header inspection: {error}"), + ); + } + }; + let mut header = [0_u8; MINIDUMP_HEADER_SIZE]; + if let Err(error) = file.read_exact(&mut header) { + return unavailable_minidump_directory( + Some(file_bytes), + format!("Could not read the documented MINIDUMP_HEADER: {error}"), + ); + } + + let signature = minidump_u32(&header, 0); + let signature_text = format!("0x{signature:08X}"); + if signature != MINIDUMP_SIGNATURE { + return MinidumpDirectoryInventory { + source: "raw_minidump_directory".to_string(), + status: "not_minidump".to_string(), + file_bytes: Some(file_bytes), + signature: Some(signature_text), + version: None, + stream_count: None, + stream_directory_rva: None, + checksum: None, + time_date_stamp: None, + flags: None, + directory_entry_limit: MAX_MINIDUMP_DIRECTORY_ENTRIES, + entries: Vec::new(), + documented_stream_presence: Vec::new(), + detail: "The file does not have the documented MDMP signature, so no MINIDUMP directory or stream payload was interpreted.".to_string(), + }; + } + + let version = minidump_u32(&header, 4); + let stream_count = minidump_u32(&header, 8); + let stream_directory_rva = minidump_u32(&header, 12); + let checksum = minidump_u32(&header, 16); + let time_date_stamp = minidump_u32(&header, 20); + let flags = minidump_u64(&header, 24); + let mut inventory = MinidumpDirectoryInventory { + source: "raw_minidump_directory".to_string(), + status: "captured".to_string(), + file_bytes: Some(file_bytes), + signature: Some(signature_text), + version: Some(version), + stream_count: Some(stream_count), + stream_directory_rva: Some(stream_directory_rva), + checksum: Some(checksum), + time_date_stamp: Some(time_date_stamp), + flags: Some(flags), + directory_entry_limit: MAX_MINIDUMP_DIRECTORY_ENTRIES, + entries: Vec::new(), + documented_stream_presence: Vec::new(), + detail: "Only the documented MINIDUMP header and directory are inspected. Stream payloads, including unknown or application-defined streams, are not decoded.".to_string(), + }; + if stream_count > MAX_MINIDUMP_DIRECTORY_ENTRIES { + inventory.status = "directory_entry_limit_exceeded".to_string(); + inventory.detail = format!( + "The documented header reports {stream_count} directory entries, exceeding the bounded {MAX_MINIDUMP_DIRECTORY_ENTRIES}-entry limit; no directory entries or stream payloads were read." + ); + return inventory; + } + + let directory_bytes = (stream_count as usize) + .checked_mul(MINIDUMP_DIRECTORY_SIZE) + .expect("the bounded MINIDUMP directory count fits usize"); + let directory_start = u64::from(stream_directory_rva); + let Some(directory_end) = directory_start.checked_add(directory_bytes as u64) else { + inventory.status = "invalid_directory_range".to_string(); + inventory.detail = + "The documented MINIDUMP directory range overflowed while validating its file bounds." + .to_string(); + return inventory; + }; + if directory_start < MINIDUMP_HEADER_SIZE as u64 || directory_end > file_bytes { + inventory.status = "invalid_directory_range".to_string(); + inventory.detail = format!( + "The documented MINIDUMP directory range 0x{directory_start:X}..0x{directory_end:X} is outside the {file_bytes}-byte file." + ); + return inventory; + } + if let Err(error) = file.seek(SeekFrom::Start(directory_start)) { + inventory.status = "unavailable".to_string(); + inventory.detail = format!("Could not seek to the documented MINIDUMP directory: {error}"); + return inventory; + } + let mut directory = vec![0_u8; directory_bytes]; + if let Err(error) = file.read_exact(&mut directory) { + inventory.status = "unavailable".to_string(); + inventory.detail = format!("Could not read the documented MINIDUMP directory: {error}"); + return inventory; + } + + for (index, entry) in directory.chunks_exact(MINIDUMP_DIRECTORY_SIZE).enumerate() { + let stream_type = minidump_u32(entry, 0); + let data_size = minidump_u32(entry, 4); + let data_rva = minidump_u32(entry, 8); + let range_status = minidump_stream_range_status(file_bytes, data_rva, data_size); + inventory.entries.push(MinidumpDirectoryEntry { + index: index as u32, + stream_type, + stream_name: minidump_stream_name(stream_type), + data_rva, + data_size, + range_status, + }); + } + inventory.documented_stream_presence = MINIDUMP_DOCUMENTED_STREAM_TYPES + .iter() + .map(|(stream_type, stream_name)| MinidumpStreamPresence { + stream_type: *stream_type, + stream_name: (*stream_name).to_string(), + occurrences: inventory + .entries + .iter() + .filter(|entry| entry.stream_type == *stream_type) + .count() as u32, + }) + .collect(); + if inventory + .entries + .iter() + .any(|entry| entry.range_status != "empty" && entry.range_status != "within_file") + { + inventory.status = "captured_with_invalid_stream_ranges".to_string(); + } + inventory +} + +fn unavailable_minidump_directory( + file_bytes: Option, + detail: String, +) -> MinidumpDirectoryInventory { + MinidumpDirectoryInventory { + source: "raw_minidump_directory".to_string(), + status: "unavailable".to_string(), + file_bytes, + signature: None, + version: None, + stream_count: None, + stream_directory_rva: None, + checksum: None, + time_date_stamp: None, + flags: None, + directory_entry_limit: MAX_MINIDUMP_DIRECTORY_ENTRIES, + entries: Vec::new(), + documented_stream_presence: Vec::new(), + detail, + } +} + +fn minidump_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes( + bytes[offset..offset + std::mem::size_of::()] + .try_into() + .expect("validated MINIDUMP field bounds"), + ) +} + +fn minidump_u64(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes( + bytes[offset..offset + std::mem::size_of::()] + .try_into() + .expect("validated MINIDUMP field bounds"), + ) +} + +fn minidump_stream_range_status(file_bytes: u64, data_rva: u32, data_size: u32) -> String { + if data_size == 0 { + return "empty".to_string(); + } + let start = u64::from(data_rva); + let Some(end) = start.checked_add(u64::from(data_size)) else { + return "range_overflow".to_string(); + }; + if start < MINIDUMP_HEADER_SIZE as u64 { + "overlaps_header".to_string() + } else if end <= file_bytes { + "within_file".to_string() + } else { + "outside_file".to_string() + } +} + +fn minidump_stream_name(stream_type: u32) -> String { + let name = match stream_type { + 0 => "unused", + 1 => "reserved_0", + 2 => "reserved_1", + 3 => "thread_list", + 4 => "module_list", + 5 => "memory_list", + 6 => "exception", + 7 => "system_info", + 8 => "thread_ex_list", + 9 => "memory64_list", + 10 => "comment_a", + 11 => "comment_w", + 12 => "handle_data", + 13 => "function_table", + 14 => "unloaded_module_list", + 15 => "misc_info", + 16 => "memory_info_list", + 17 => "thread_info_list", + 18 => "handle_operation_list", + 19 => "token", + 20 => "javascript_data", + 21 => "system_memory_info", + 22 => "process_vm_counters", + 23 => "ipt_trace", + 24 => "thread_names", + 25 => "compressed_memory", + 26 => "compressed_memory_sql", + 0x8000 => "ce_stream_null", + 0x8001 => "ce_stream_system_info", + 0x8002 => "ce_stream_exception", + 0x8003 => "ce_stream_module_list", + 0x8004 => "ce_stream_process_list", + 0x8005 => "ce_stream_thread_list", + 0x8006 => "ce_stream_thread_context_list", + 0x8007 => "ce_stream_thread_call_stack_list", + 0x8008 => "ce_stream_memory_virtual_list", + 0x8009 => "ce_stream_memory_physical_list", + 0x800a => "ce_stream_bucket_parameters", + 0x800b => "ce_stream_process_module_map", + 0x800c => "ce_stream_diagnosis_list", + 0x800d..=0xffff => "reserved", + _ => { + return if stream_type > 0xffff { + "application_defined".to_string() + } else { + format!("unknown_0x{stream_type:08X}") + }; + } + }; + name.to_string() +} + +const MINIDUMP_DOCUMENTED_STREAM_TYPES: &[(u32, &str)] = &[ + (3, "thread_list"), + (4, "module_list"), + (5, "memory_list"), + (6, "exception"), + (7, "system_info"), + (8, "thread_ex_list"), + (9, "memory64_list"), + (10, "comment_a"), + (11, "comment_w"), + (12, "handle_data"), + (13, "function_table"), + (14, "unloaded_module_list"), + (15, "misc_info"), + (16, "memory_info_list"), + (17, "thread_info_list"), + (18, "handle_operation_list"), + (19, "token"), + (20, "javascript_data"), + (21, "system_memory_info"), + (22, "process_vm_counters"), + (23, "ipt_trace"), + (24, "thread_names"), + (25, "compressed_memory"), + (26, "compressed_memory_sql"), + (0x8000, "ce_stream_null"), + (0x8001, "ce_stream_system_info"), + (0x8002, "ce_stream_exception"), + (0x8003, "ce_stream_module_list"), + (0x8004, "ce_stream_process_list"), + (0x8005, "ce_stream_thread_list"), + (0x8006, "ce_stream_thread_context_list"), + (0x8007, "ce_stream_thread_call_stack_list"), + (0x8008, "ce_stream_memory_virtual_list"), + (0x8009, "ce_stream_memory_physical_list"), + (0x800a, "ce_stream_bucket_parameters"), + (0x800b, "ce_stream_process_module_map"), + (0x800c, "ce_stream_diagnosis_list"), +]; + +fn unavailable_captured_event_code(detail: String) -> CapturedEventCode { + CapturedEventCode { + source: "dbgeng_iddebugadvanced_request_captured_event_code".to_string(), + status: "unavailable".to_string(), + code_offset: None, + code_bytes: None, + bytes_returned: None, + byte_limit: MAX_CAPTURED_EVENT_CODE_BYTES, + detail, + } +} + #[cfg(windows)] pub struct DebuggerSession { kind: DebuggerSessionKind, @@ -1584,6 +1951,145 @@ impl DebuggerSession { }) } + pub fn captured_event_code(&self) -> CapturedEventCode { + use windows::core::Interface; + use windows::Win32::Foundation::E_NOINTERFACE; + use windows::Win32::System::Diagnostics::Debug::Extensions::{ + IDebugAdvanced2, IDebugAdvanced3, DEBUG_REQUEST_GET_CAPTURED_EVENT_CODE_OFFSET, + DEBUG_REQUEST_READ_CAPTURED_EVENT_CODE_STREAM, + }; + + enum CapturedEventRequestInterface { + Advanced2(IDebugAdvanced2), + Advanced3(IDebugAdvanced3), + } + + let (advanced, interface_name) = match self.client.cast::() { + Ok(advanced) => ( + CapturedEventRequestInterface::Advanced3(advanced), + "IDebugAdvanced3", + ), + Err(advanced3_error) => match self.client.cast::() { + Ok(advanced) => ( + CapturedEventRequestInterface::Advanced2(advanced), + "IDebugAdvanced2", + ), + Err(advanced2_error) => { + return unavailable_captured_event_code(format!( + "DbgEng did not expose IDebugAdvanced3 or its compatible IDebugAdvanced2 Request ABI for captured-event code requests: IDebugAdvanced3={advanced3_error}; IDebugAdvanced2={advanced2_error}" + )); + } + }, + }; + macro_rules! request { + ($request:expr, $outbuffer:expr, $outbuffersize:expr, $outsize:expr) => { + match &advanced { + CapturedEventRequestInterface::Advanced2(advanced) => unsafe { + advanced.Request($request, None, 0, $outbuffer, $outbuffersize, $outsize) + }, + CapturedEventRequestInterface::Advanced3(advanced) => unsafe { + advanced.Request($request, None, 0, $outbuffer, $outbuffersize, $outsize) + }, + } + }; + } + + let mut code_offset = 0u64; + let mut offset_size = 0u32; + if let Err(error) = request!( + DEBUG_REQUEST_GET_CAPTURED_EVENT_CODE_OFFSET, + Some((&mut code_offset as *mut u64).cast()), + std::mem::size_of::() as u32, + Some(&mut offset_size) + ) { + return CapturedEventCode { + source: "dbgeng_iddebugadvanced_request_captured_event_code".to_string(), + status: if error.code() == E_NOINTERFACE { + "captured_event_code_memory_invalid".to_string() + } else { + "unavailable".to_string() + }, + code_offset: None, + code_bytes: None, + bytes_returned: None, + byte_limit: MAX_CAPTURED_EVENT_CODE_BYTES, + detail: if error.code() == E_NOINTERFACE { + format!( + "DbgEng {interface_name} returned E_NOINTERFACE for DEBUG_REQUEST_GET_CAPTURED_EVENT_CODE_OFFSET. Microsoft documents this result as invalid memory at the current stored event's instruction pointer, so no captured code offset or bytes are available." + ) + } else { + format!( + "DbgEng {interface_name} did not provide the captured-event instruction pointer: {error}" + ) + }, + }; + } + if offset_size != std::mem::size_of::() as u32 { + return CapturedEventCode { + source: "dbgeng_iddebugadvanced_request_captured_event_code".to_string(), + status: "invalid_response".to_string(), + code_offset: None, + code_bytes: None, + bytes_returned: Some(offset_size), + byte_limit: MAX_CAPTURED_EVENT_CODE_BYTES, + detail: format!( + "DbgEng returned {offset_size} bytes for DEBUG_REQUEST_GET_CAPTURED_EVENT_CODE_OFFSET; the documented ULONG64 result requires {} bytes.", + std::mem::size_of::() + ), + }; + } + + let mut code = [0u8; MAX_CAPTURED_EVENT_CODE_BYTES as usize]; + let mut code_size = 0u32; + if let Err(error) = request!( + DEBUG_REQUEST_READ_CAPTURED_EVENT_CODE_STREAM, + Some(code.as_mut_ptr().cast()), + MAX_CAPTURED_EVENT_CODE_BYTES, + Some(&mut code_size) + ) { + return CapturedEventCode { + source: "dbgeng_iddebugadvanced_request_captured_event_code".to_string(), + status: "code_bytes_unavailable".to_string(), + code_offset: Some(code_offset), + code_bytes: None, + bytes_returned: None, + byte_limit: MAX_CAPTURED_EVENT_CODE_BYTES, + detail: format!( + "DbgEng {interface_name} provided the captured-event instruction pointer but not its bounded code snapshot: {error}" + ), + }; + } + if code_size > MAX_CAPTURED_EVENT_CODE_BYTES { + return CapturedEventCode { + source: "dbgeng_iddebugadvanced_request_captured_event_code".to_string(), + status: "invalid_response".to_string(), + code_offset: Some(code_offset), + code_bytes: None, + bytes_returned: Some(code_size), + byte_limit: MAX_CAPTURED_EVENT_CODE_BYTES, + detail: format!( + "DbgEng reported {code_size} captured-event bytes, exceeding the documented {MAX_CAPTURED_EVENT_CODE_BYTES}-byte maximum." + ), + }; + } + let code = &code[..code_size as usize]; + CapturedEventCode { + source: "dbgeng_iddebugadvanced_request_captured_event_code".to_string(), + status: if code.is_empty() { + "captured_empty".to_string() + } else { + "captured".to_string() + }, + code_offset: Some(code_offset), + code_bytes: (!code.is_empty()).then(|| encode_hex(code)), + bytes_returned: Some(code_size), + byte_limit: MAX_CAPTURED_EVENT_CODE_BYTES, + detail: format!( + "The offset and bytes are a bounded DbgEng {interface_name} snapshot of the current stored event. They are not promoted to original-exception, fault-address, thread, or causation evidence without an independent match to the bugcheck contract." + ), + } + } + pub fn detach(&self) -> anyhow::Result<()> { unsafe { self.client.DetachProcesses()?; @@ -3165,6 +3671,10 @@ impl DebuggerSession { anyhow::bail!("DbgEng sessions are only supported on Windows") } + pub fn captured_event_code(&self) -> CapturedEventCode { + unavailable_captured_event_code("DbgEng sessions are only supported on Windows".to_string()) + } + pub fn detach(&self) -> anyhow::Result<()> { anyhow::bail!("DbgEng sessions are only supported on Windows") } @@ -3949,6 +4459,98 @@ mod tests { assert_eq!(event_type_name(0xFFFF), "unknown"); } + #[test] + fn inventories_documented_minidump_directory_entries_without_reading_payloads() { + let directory = temporary_runtime_directory("minidump-directory"); + let path = directory.join("fixture.dmp"); + fs::create_dir_all(&directory).unwrap(); + let mut bytes = vec![0_u8; 0x100]; + bytes[..4].copy_from_slice(b"MDMP"); + bytes[4..8].copy_from_slice(&0x0000_a793_u32.to_le_bytes()); + bytes[8..12].copy_from_slice(&3_u32.to_le_bytes()); + bytes[12..16].copy_from_slice(&(MINIDUMP_HEADER_SIZE as u32).to_le_bytes()); + bytes[16..20].copy_from_slice(&0x1234_5678_u32.to_le_bytes()); + bytes[20..24].copy_from_slice(&0x1020_3040_u32.to_le_bytes()); + bytes[24..32].copy_from_slice(&0x9abc_def0_1234_5678_u64.to_le_bytes()); + let entries = [ + (6_u32, 16_u32, 0x80_u32), + (14_u32, 0_u32, 0_u32), + (0x1234_u32, 32_u32, 0xf0_u32), + ]; + for (index, (stream_type, data_size, data_rva)) in entries.into_iter().enumerate() { + let offset = MINIDUMP_HEADER_SIZE + index * MINIDUMP_DIRECTORY_SIZE; + bytes[offset..offset + 4].copy_from_slice(&stream_type.to_le_bytes()); + bytes[offset + 4..offset + 8].copy_from_slice(&data_size.to_le_bytes()); + bytes[offset + 8..offset + 12].copy_from_slice(&data_rva.to_le_bytes()); + } + fs::write(&path, bytes).unwrap(); + + let inventory = inspect_minidump_directory(&path); + + let _ = fs::remove_dir_all(&directory); + assert_eq!(inventory.status, "captured_with_invalid_stream_ranges"); + assert_eq!(inventory.signature.as_deref(), Some("0x504D444D")); + assert_eq!(inventory.stream_count, Some(3)); + assert_eq!(inventory.entries.len(), 3); + assert_eq!(inventory.entries[0].stream_name, "exception"); + assert_eq!(inventory.entries[0].range_status, "within_file"); + assert_eq!(inventory.entries[1].stream_name, "unloaded_module_list"); + assert_eq!(inventory.entries[1].range_status, "empty"); + assert_eq!(inventory.entries[2].stream_name, "unknown_0x00001234"); + assert_eq!(inventory.entries[2].range_status, "outside_file"); + assert_eq!( + inventory + .documented_stream_presence + .iter() + .find(|stream| stream.stream_name == "exception") + .map(|stream| stream.occurrences), + Some(1) + ); + assert_eq!( + inventory + .documented_stream_presence + .iter() + .find(|stream| stream.stream_name == "thread_info_list") + .map(|stream| stream.occurrences), + Some(0) + ); + } + + #[test] + fn refuses_to_decode_non_minidump_files() { + let directory = temporary_runtime_directory("not-minidump"); + let path = directory.join("fixture.dmp"); + fs::create_dir_all(&directory).unwrap(); + let mut bytes = vec![0_u8; MINIDUMP_HEADER_SIZE]; + bytes[..4].copy_from_slice(b"PAGE"); + fs::write(&path, bytes).unwrap(); + + let inventory = inspect_minidump_directory(&path); + + let _ = fs::remove_dir_all(&directory); + assert_eq!(inventory.status, "not_minidump"); + assert_eq!(inventory.signature.as_deref(), Some("0x45474150")); + assert!(inventory.entries.is_empty()); + } + + #[test] + fn rejects_out_of_file_minidump_directories() { + let directory = temporary_runtime_directory("invalid-minidump-directory"); + let path = directory.join("fixture.dmp"); + fs::create_dir_all(&directory).unwrap(); + let mut bytes = vec![0_u8; MINIDUMP_HEADER_SIZE]; + bytes[..4].copy_from_slice(b"MDMP"); + bytes[8..12].copy_from_slice(&1_u32.to_le_bytes()); + bytes[12..16].copy_from_slice(&0x100_u32.to_le_bytes()); + fs::write(&path, bytes).unwrap(); + + let inventory = inspect_minidump_directory(&path); + + let _ = fs::remove_dir_all(&directory); + assert_eq!(inventory.status, "invalid_directory_range"); + assert!(inventory.entries.is_empty()); + } + #[test] fn recognizes_dbgeng_s_false_as_wait_timeout() { assert!(is_dbgeng_wait_timeout_hresult(1)); diff --git a/crates/windbg-tool/src/cli/platform.rs b/crates/windbg-tool/src/cli/platform.rs index 2cedb51..3bb55ca 100644 --- a/crates/windbg-tool/src/cli/platform.rs +++ b/crates/windbg-tool/src/cli/platform.rs @@ -4550,6 +4550,7 @@ pub(super) fn run_dump_inspect( .transpose()?; let dump_metadata = fs::metadata(&args.path) .with_context(|| format!("reading dump metadata for {}", args.path.display()))?; + let minidump_directory = windbg_dbgeng::inspect_minidump_directory(&args.path); let operation_started = Instant::now(); let opened_at = Instant::now(); let session = open_dump_session(DumpOpenOptions { @@ -4602,6 +4603,7 @@ pub(super) fn run_dump_inspect( json!({ "triage_profile": "bounded_pool_corruption", "target": target, + "minidump_directory": minidump_directory, "modules": modules, "threads": threads, "registers": registers, @@ -4621,6 +4623,9 @@ pub(super) fn run_dump_inspect( "total_elapsed_ms": operation_started.elapsed().as_millis() as u64, "bounded_operations": { "stack_frame_limit": args.max_frames, + "minidump_directory_entry_limit": windbg_dbgeng::MAX_MINIDUMP_DIRECTORY_ENTRIES, + "captured_event_code_byte_limit": windbg_dbgeng::MAX_CAPTURED_EVENT_CODE_BYTES, + "minidump_stream_payloads_decoded": false, "whole_dump_scan": false, }, }, @@ -4897,6 +4902,7 @@ fn dump_triage_value(session: &DebuggerSession, input: DumpTriageInput<'_>) -> V let fault = fault_address.map(|address| dump_address_observation(session, address, 8)); let exception_context = dump_exception_context(session, input.target, bugcheck_data, input.max_frames); + let snapshot_event = dump_snapshot_event_observation(session, input.bugcheck); let symbol_modules = dump_symbol_modules(session, fault_address, &exception_context); let symbol_readiness = dump_symbol_readiness(session, &symbol_modules, input.refresh_symbols); let driver_evidence = dump_driver_evidence( @@ -4922,6 +4928,7 @@ fn dump_triage_value(session: &DebuggerSession, input: DumpTriageInput<'_>) -> V "bugcheck": dump_bugcheck_value(input.bugcheck), "fault": fault, "exception_context": exception_context, + "snapshot_event": snapshot_event, "current_stack": input.current_stack, "symbol_readiness": symbol_readiness, "native_symbol_prefetch": input.native_symbols, @@ -5054,6 +5061,78 @@ fn dump_bugcheck_value(bugcheck: &windbg_dbgeng::BugCheckDataResult) -> Value { }) } +fn dump_snapshot_event_observation( + session: &DebuggerSession, + bugcheck: &windbg_dbgeng::BugCheckDataResult, +) -> Value { + let fault_address = bugcheck.data.as_ref().and_then(dump_fault_address); + let captured_code = session.captured_event_code(); + let (fault_code_snapshot, fault_code_bytes) = match fault_address { + Some(address) => { + match session.read_memory(address, windbg_dbgeng::MAX_CAPTURED_EVENT_CODE_BYTES) { + Ok(memory) => { + let bytes = memory.complete.then_some(memory.data.clone()); + (json!(memory), bytes) + } + Err(error) => ( + json!({ + "status": "unavailable", + "address": address, + "detail": format!( + "DbgEng could not read the bounded bugcheck fault-code snapshot: {error}" + ), + }), + None, + ), + } + } + None => (json!({"status": "not_applicable"}), None), + }; + let code_offset_relation = match (captured_code.code_offset, fault_address) { + (Some(captured), Some(fault)) if captured == fault => "exact_match", + (Some(_), Some(_)) => "different_from_bugcheck_fault_address", + (Some(_), None) => "bugcheck_has_no_documented_fault_address", + (None, _) => "captured_event_offset_unavailable", + }; + let code_bytes_relation = match code_offset_relation { + "exact_match" => match ( + captured_code.code_bytes.as_deref(), + fault_code_bytes.as_deref(), + ) { + (Some(captured), Some(fault)) if captured == fault => "exact_match", + (Some(captured), Some(fault)) if fault.starts_with(captured) => { + "captured_snapshot_matches_current_dump_prefix" + } + (Some(_), Some(_)) => "different_from_current_dump_memory", + (Some(_), None) => "bugcheck_fault_code_unavailable", + (None, _) => "captured_event_code_unavailable", + }, + "different_from_bugcheck_fault_address" => "not_compared_different_addresses", + "bugcheck_has_no_documented_fault_address" => "not_applicable", + _ => "captured_event_code_unavailable", + }; + let last_event = match session.last_event() { + Ok(event) => json!({ + "status": "captured", + "event": event, + }), + Err(error) => json!({ + "status": "unavailable", + "detail": format!("DbgEng could not retrieve the dump's stored last event: {error}"), + }), + }; + json!({ + "provenance": "dump_stored_event_snapshot", + "event_role": "metadata_only", + "last_event": last_event, + "captured_code": captured_code, + "bugcheck_fault_code_snapshot": fault_code_snapshot, + "code_offset_relation_to_bugcheck_fault_address": code_offset_relation, + "code_bytes_relation_to_bugcheck_fault_snapshot": code_bytes_relation, + "detail": "The bounded DbgEng captured-event requests report the dump's stored event snapshot. They are compared mechanically with the documented bugcheck fault address and a bounded current-dump memory read, but never promoted to original-exception, context, thread, fault-time register, or causation evidence without an independent provenance match.", + }) +} + fn dump_address_observation( session: &DebuggerSession, address: u64,