use anyhow::Result;
use clap::Parser;
#[derive(Parser)]
pub(crate) struct HostArgs {
#[arg(long)]
pub host: String,
#[arg(long)]
pub anon: bool,
#[arg(long)]
pub json: bool,
}
pub(crate) struct ShareRow {
pub netname: String,
pub stype: String,
pub special: bool,
pub remark: String,
}
#[derive(Default)]
pub(crate) struct HostPosture {
pub reachable: bool,
pub null_session: bool,
pub samr_users: Option<Vec<(u32, String)>>,
pub srvsvc_sessions: Option<usize>,
pub wkssvc_users: Option<usize>,
pub lsarpc_ok: bool,
pub shares: Option<Vec<ShareRow>>,
pub notes: Vec<String>,
}
impl HostPosture {
pub fn anon_exposed(&self) -> bool {
self.samr_users.is_some()
|| self.srvsvc_sessions.is_some()
|| self.wkssvc_users.is_some()
|| self.lsarpc_ok
|| self.shares.is_some()
}
pub fn non_admin_shares(&self) -> usize {
self.shares
.as_ref()
.map(|v| v.iter().filter(|s| !s.special).count())
.unwrap_or(0)
}
}
pub(crate) async fn probe_host(host: &str) -> HostPosture {
use adhammer_core::sanitize_terminal_output as san;
use smb2_client::SmbClient;
let mut p = HostPosture::default();
let mut smb = match SmbClient::connect(host).await {
Ok(s) => s,
Err(e) => {
p.notes.push(format!("connect: {e}"));
return p;
}
};
p.reachable = true;
if let Err(e) = smb.login_null(host).await {
p.notes.push(format!("null session refused: {e}"));
return p;
}
p.null_session = true;
if let Err(e) = smb.tree_connect(&format!("\\\\{host}\\IPC$")).await {
p.notes.push(format!("IPC$ tree-connect: {e}"));
return p;
}
match smb.open_pipe("samr").await {
Ok(pipe) => match dcerpc::samr::SamrClient::bind(&mut smb, pipe).await {
Ok(mut c) => match c.enumerate_all_users(&format!("\\\\{host}")).await {
Ok(users) => {
p.samr_users = Some(users.into_iter().map(|(rid, n)| (rid, san(&n))).collect())
}
Err(e) => p.notes.push(format!("samr enum: {e}")),
},
Err(e) => p.notes.push(format!("samr bind: {e}")),
},
Err(e) => p.notes.push(format!("samr pipe: {e}")),
}
match smb.open_pipe("srvsvc").await {
Ok(pipe) => match dcerpc::srvsvc::SrvsvcClient::bind(&mut smb, pipe).await {
Ok(mut c) => {
match c.enum_sessions().await {
Ok((sessions, _)) => p.srvsvc_sessions = Some(sessions.len()),
Err(e) => p.notes.push(format!("srvsvc NetSessionEnum: {e}")),
}
match c.enum_shares().await {
Ok((shares, ret)) if !(ret != 0 && shares.is_empty()) => {
p.shares = Some(
shares
.into_iter()
.map(|s| ShareRow {
netname: san(&s.netname),
stype: s.stype_label().to_string(),
special: s.is_special(),
remark: san(&s.remark),
})
.collect(),
);
}
Ok((_, ret)) => p.notes.push(format!("srvsvc NetrShareEnum rc={ret:#010x}")),
Err(e) => p.notes.push(format!("srvsvc NetrShareEnum: {e}")),
}
}
Err(e) => p.notes.push(format!("srvsvc bind: {e}")),
},
Err(e) => p.notes.push(format!("srvsvc pipe: {e}")),
}
match smb.open_pipe("wkssvc").await {
Ok(pipe) => match dcerpc::wkssvc::WkstaUserClient::bind(&mut smb, pipe).await {
Ok(mut c) => match c.enum_users().await {
Ok((users, _)) => p.wkssvc_users = Some(users.len()),
Err(e) => p.notes.push(format!("wkssvc NetrWkstaUserEnum: {e}")),
},
Err(e) => p.notes.push(format!("wkssvc bind: {e}")),
},
Err(e) => p.notes.push(format!("wkssvc pipe: {e}")),
}
match smb.open_pipe("lsarpc").await {
Ok(pipe) => match dcerpc::lsat::LsatClient::bind(&mut smb, pipe).await {
Ok(mut c) => match c.open_policy().await {
Ok(_) => p.lsarpc_ok = true,
Err(e) => p.notes.push(format!("lsarpc LsarOpenPolicy: {e}")),
},
Err(e) => p.notes.push(format!("lsarpc bind: {e}")),
},
Err(e) => p.notes.push(format!("lsarpc pipe: {e}")),
}
p
}
pub(crate) async fn host(a: HostArgs) -> Result<()> {
if !a.anon {
anyhow::bail!("pass --anon: only anonymous (no-cred) host probing is wired in 1.5.0");
}
let sp = crate::ui::Spinner::start(format!("anonymous host posture → {}", a.host));
let p = probe_host(&a.host).await;
if !p.reachable {
sp.done_warn(&format!("{}: SMB (445) not reachable", a.host));
} else if !p.null_session {
sp.done(&format!("{}: null session refused (hardened)", a.host));
} else {
sp.done(&format!(
"{}: null session OK · anon surface {}",
a.host,
if p.anon_exposed() {
"partially exposed"
} else {
"all refused (hardened)"
}
));
}
if a.json {
println!("{}", posture_json(&a.host, &p));
} else {
print_posture(&p, " ");
}
Ok(())
}
pub(crate) fn print_posture(p: &HostPosture, ind: &str) {
use adhammer_core::sanitize_terminal_output as san;
if !p.reachable {
println!("{ind}SMB (445) not reachable");
} else if !p.null_session {
println!("{ind}null session refused — hardened against anonymous SMB.");
} else {
println!("{ind}null session established.");
match &p.samr_users {
Some(u) => println!(
"{ind}\\samr users → {} [ANON EXPOSED]",
u.len()
),
None => println!("{ind}\\samr users → refused"),
}
match p.srvsvc_sessions {
Some(n) => println!("{ind}\\srvsvc NetSessionEnum → {n} [ANON EXPOSED]"),
None => println!("{ind}\\srvsvc NetSessionEnum → refused"),
}
match &p.shares {
Some(rows) => {
println!(
"{ind}\\srvsvc NetrShareEnum → {} share(s) [ANON EXPOSED]",
rows.len()
);
for s in rows {
let tag = if s.special { " [admin$]" } else { "" };
let rem = if s.remark.is_empty() {
String::new()
} else {
format!(" — {}", s.remark)
};
println!("{ind} {:<16} {}{tag}{rem}", san(&s.netname), s.stype);
}
}
None => println!("{ind}\\srvsvc NetrShareEnum → refused"),
}
match p.wkssvc_users {
Some(n) => println!("{ind}\\wkssvc WkstaUserEnum → {n} [ANON EXPOSED]"),
None => println!("{ind}\\wkssvc WkstaUserEnum → refused"),
}
println!(
"{ind}\\lsarpc LsarOpenPolicy → {}",
if p.lsarpc_ok {
"OK [ANON EXPOSED]"
} else {
"refused"
}
);
if let Some(u) = &p.samr_users {
if !u.is_empty() {
println!("{ind}users:");
for (rid, name) in u {
println!("{ind} {rid}\t{name}");
}
}
}
let na = p.non_admin_shares();
if na > 0 {
println!(
"{ind}** {na} non-admin share(s) listable anonymously — DC permits null-session \
NetrShareEnum (RestrictNullSessAccess=0)."
);
}
}
if !p.notes.is_empty() {
println!("{ind}detail:");
for n in &p.notes {
println!("{ind} - {}", san(n));
}
}
}
pub(crate) fn posture_json(host: &str, p: &HostPosture) -> String {
let users = match &p.samr_users {
Some(u) => u
.iter()
.map(|(rid, n)| format!("{{\"rid\":{rid},\"name\":\"{}\"}}", jesc(n)))
.collect::<Vec<_>>()
.join(","),
None => String::new(),
};
let shares = match &p.shares {
Some(rows) => rows
.iter()
.map(|s| {
format!(
"{{\"netname\":\"{}\",\"type\":\"{}\",\"special\":{},\"remark\":\"{}\"}}",
jesc(&s.netname),
jesc(&s.stype),
s.special,
jesc(&s.remark)
)
})
.collect::<Vec<_>>()
.join(","),
None => String::new(),
};
let notes = p
.notes
.iter()
.map(|n| format!("\"{}\"", jesc(n)))
.collect::<Vec<_>>()
.join(",");
let num = |o: Option<usize>| o.map(|n| n.to_string()).unwrap_or_else(|| "null".into());
format!(
"{{\"host\":\"{}\",\"reachable\":{},\"null_session\":{},\"anon_exposed\":{},\
\"samr_users\":[{}],\"srvsvc_sessions\":{},\"wkssvc_users\":{},\"lsarpc_policy\":{},\
\"shares\":[{}],\"notes\":[{}]}}",
jesc(host),
p.reachable,
p.null_session,
p.anon_exposed(),
users,
num(p.srvsvc_sessions),
num(p.wkssvc_users),
p.lsarpc_ok,
shares,
notes
)
}
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
}