use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
#[derive(Subcommand)]
pub(crate) enum LsaCmd {
#[command(hide = true)]
LsassParse(LsassParseArgs),
}
#[derive(Parser)]
pub(crate) struct LsassParseArgs {
#[arg(value_name = "PATH")]
pub file: String,
#[arg(long)]
pub no_hint: bool,
}
const MDMP_SIGNATURE: u32 = 0x504D_444D;
pub(crate) async fn lsass_parse(a: LsassParseArgs) -> Result<()> {
eprintln!(
"[SCAFFOLDING] lsa lsass-parse only reads the outer MDMP header + \
stream directory. It does NOT walk lsasrv.dll symbols and does NOT \
extract WDigest/MSV1_0/TSPKG/Kerberos/SSP secrets. Use the hint \
below for actual credential extraction."
);
let bytes = std::fs::read(&a.file).with_context(|| format!("read {}", a.file))?;
if bytes.len() < 32 {
bail!("file too short for a minidump ({} bytes)", bytes.len());
}
let sig = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
if sig != MDMP_SIGNATURE {
bail!("not a minidump — bad signature {sig:#x} (expected MDMP)");
}
let version_low = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
let stream_count = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
let stream_dir_rva = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
let flags = u64::from_le_bytes(bytes[24..32].try_into().unwrap());
println!("[+] minidump OK — version={version_low:#x} streams={stream_count} flags={flags:#x}");
let dir_size = stream_count.checked_mul(12).context("stream dir size")?;
if stream_dir_rva
.checked_add(dir_size)
.is_none_or(|end| end > bytes.len())
{
bail!(
"stream directory truncated: rva {stream_dir_rva} + {dir_size}B > file {}B",
bytes.len()
);
}
let mut has_module_list = false;
let mut has_thread_list = false;
let mut has_system_info = false;
let mut has_memory64 = false;
println!(" streams:");
for i in 0..stream_count {
let off = stream_dir_rva + i * 12;
let ty = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
let sz = u32::from_le_bytes(bytes[off + 4..off + 8].try_into().unwrap());
let rva = u32::from_le_bytes(bytes[off + 8..off + 12].try_into().unwrap());
let name = stream_type_name(ty);
println!(" · [{ty:>4}] {name:<28} size={sz:>10} rva={rva:>10}");
match ty {
4 => has_module_list = true,
3 => has_thread_list = true,
7 => has_system_info = true,
9 | 0x11 => has_memory64 = true,
_ => {}
}
}
if has_system_info {
if let Some(sys) = find_stream(&bytes, stream_dir_rva, stream_count, 7) {
summarize_system_info(&bytes, sys);
}
}
println!("[i] triage:");
println!(
" · full LSASS walk viable? {}",
yesno(has_module_list && has_thread_list && has_memory64)
);
println!(
" · module list present — {}",
yesno(has_module_list)
);
println!(
" · thread list present — {}",
yesno(has_thread_list)
);
println!(" · memory64 (mem regions) — {}", yesno(has_memory64));
if !a.no_hint {
let mut p = crate::gap_hint::HintParams::new();
p.file = Some(a.file.clone());
crate::gap_hint::hint_external(crate::gap_hint::Gap::LsassMinidump, &p);
}
Ok(())
}
fn find_stream(
bytes: &[u8],
dir_rva: usize,
count: usize,
want_type: u32,
) -> Option<(usize, usize)> {
for i in 0..count {
let off = dir_rva + i * 12;
if off + 12 > bytes.len() {
return None;
}
let ty = u32::from_le_bytes(bytes[off..off + 4].try_into().unwrap());
if ty != want_type {
continue;
}
let sz = u32::from_le_bytes(bytes[off + 4..off + 8].try_into().unwrap()) as usize;
let rva = u32::from_le_bytes(bytes[off + 8..off + 12].try_into().unwrap()) as usize;
return Some((rva, sz));
}
None
}
fn summarize_system_info(bytes: &[u8], (rva, sz): (usize, usize)) {
if sz < 24 || rva + 24 > bytes.len() {
return;
}
let arch = u16::from_le_bytes(bytes[rva..rva + 2].try_into().unwrap());
let n_proc = bytes[rva + 6];
let major = u32::from_le_bytes(bytes[rva + 8..rva + 12].try_into().unwrap());
let minor = u32::from_le_bytes(bytes[rva + 12..rva + 16].try_into().unwrap());
let build = u32::from_le_bytes(bytes[rva + 16..rva + 20].try_into().unwrap());
let arch_s = match arch {
0 => "x86",
6 => "IA64",
9 => "AMD64",
12 => "ARM64",
_ => "?",
};
println!(" system info: arch={arch_s} os={major}.{minor}.{build} n_proc={n_proc}");
}
fn stream_type_name(ty: u32) -> &'static str {
match ty {
0 => "UnusedStream",
3 => "ThreadListStream",
4 => "ModuleListStream",
5 => "MemoryListStream",
6 => "ExceptionStream",
7 => "SystemInfoStream",
8 => "ThreadExListStream",
9 => "Memory64ListStream",
10 => "CommentStreamA",
11 => "CommentStreamW",
12 => "HandleDataStream",
13 => "FunctionTableStream",
14 => "UnloadedModuleListStream",
15 => "MiscInfoStream",
16 => "MemoryInfoListStream",
17 => "ThreadInfoListStream",
18 => "HandleOperationListStream",
19 => "TokenStream",
_ => "(other)",
}
}
fn yesno(b: bool) -> &'static str {
if b {
"yes"
} else {
"no"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_short_file() {
let dir = std::env::temp_dir();
let p = dir.join("adhammer-lsa-short.dmp");
std::fs::write(&p, [0u8; 4]).unwrap();
let path_s = p.to_string_lossy().to_string();
let rt = tokio::runtime::Runtime::new().unwrap();
let err = rt
.block_on(lsass_parse(LsassParseArgs {
file: path_s,
no_hint: true,
}))
.unwrap_err();
assert!(format!("{err:#}").contains("too short"));
let _ = std::fs::remove_file(&p);
}
#[test]
fn rejects_bad_signature() {
let dir = std::env::temp_dir();
let p = dir.join("adhammer-lsa-badsig.dmp");
std::fs::write(&p, [0u8; 128]).unwrap();
let path_s = p.to_string_lossy().to_string();
let rt = tokio::runtime::Runtime::new().unwrap();
let err = rt
.block_on(lsass_parse(LsassParseArgs {
file: path_s,
no_hint: true,
}))
.unwrap_err();
assert!(format!("{err:#}").contains("bad signature"));
let _ = std::fs::remove_file(&p);
}
#[test]
fn stream_type_name_covers_the_common_ones() {
assert_eq!(stream_type_name(3), "ThreadListStream");
assert_eq!(stream_type_name(4), "ModuleListStream");
assert_eq!(stream_type_name(7), "SystemInfoStream");
assert_eq!(stream_type_name(9), "Memory64ListStream");
assert_eq!(stream_type_name(99999), "(other)");
}
#[test]
fn parses_minimal_valid_header() {
let mut buf = Vec::new();
buf.extend_from_slice(&MDMP_SIGNATURE.to_le_bytes()); buf.extend_from_slice(&0xA793u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u64.to_le_bytes()); let dir = std::env::temp_dir();
let p = dir.join("adhammer-lsa-min.dmp");
std::fs::write(&p, &buf).unwrap();
let path_s = p.to_string_lossy().to_string();
let rt = tokio::runtime::Runtime::new().unwrap();
let r = rt.block_on(lsass_parse(LsassParseArgs {
file: path_s,
no_hint: true,
}));
assert!(
r.is_ok(),
"expected OK for a minimal valid header, got {r:?}"
);
let _ = std::fs::remove_file(&p);
}
}