use anyhow::Result;
use clap::Parser;
use serde_json::json;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::time::timeout;
const CONNECT_TIMEOUT: Duration = Duration::from_millis(1500);
const IO_TIMEOUT: Duration = Duration::from_millis(2500);
#[derive(Parser)]
pub(crate) struct AnonServicesArgs {
#[arg(long)]
pub host: String,
#[arg(long, value_delimiter = ',')]
pub skip: Vec<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, serde::Serialize, Default)]
struct AnonReport {
host: String,
rsync: Option<ProbeOutcome>,
ftp: Option<ProbeOutcome>,
tftp: Option<ProbeOutcome>,
}
#[derive(Debug, serde::Serialize)]
struct ProbeOutcome {
port: u16,
reachable: bool,
banner: Option<String>,
anon_ok: bool,
detail: Vec<String>,
}
pub(crate) async fn anon_services(a: AnonServicesArgs) -> Result<()> {
let skip: std::collections::HashSet<String> =
a.skip.iter().map(|s| s.to_ascii_lowercase()).collect();
let mut report = AnonReport {
host: a.host.clone(),
..Default::default()
};
if !skip.contains("rsync") {
report.rsync = Some(probe_rsync(&a.host).await);
}
if !skip.contains("ftp") {
report.ftp = Some(probe_ftp(&a.host).await);
}
if !skip.contains("tftp") {
report.tftp = Some(probe_tftp(&a.host).await);
}
if a.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"command": "adhammer enum anon-services",
"success": true,
"evidence": report,
}))?
);
} else {
print_human(&report);
}
Ok(())
}
fn print_human(r: &AnonReport) {
let one = |name: &str, o: Option<&ProbeOutcome>| match o {
None => eprintln!(" {name:<8} SKIPPED"),
Some(o) if !o.reachable => eprintln!(" {name:<8} port {} closed / filtered", o.port),
Some(o) if o.anon_ok => {
eprintln!(
" {name:<8} port {} — ANONYMOUS OK{}",
o.port,
o.banner
.as_ref()
.map(|b| format!(" — {b}"))
.unwrap_or_default()
);
for d in &o.detail {
eprintln!(" · {d}");
}
}
Some(o) => {
eprintln!(
" {name:<8} port {} reachable, anonymous refused{}",
o.port,
o.banner
.as_ref()
.map(|b| format!(" — {b}"))
.unwrap_or_default()
);
}
};
eprintln!();
eprintln!("== enum anon-services ({}) ==", r.host);
one("rsync", r.rsync.as_ref());
one("ftp", r.ftp.as_ref());
one("tftp", r.tftp.as_ref());
}
async fn probe_rsync(host: &str) -> ProbeOutcome {
let port = 873;
let addr = format!("{host}:{port}");
let stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(s)) => s,
_ => return closed(port),
};
let mut stream = stream;
let mut buf = [0u8; 128];
let n = match timeout(IO_TIMEOUT, stream.read(&mut buf)).await {
Ok(Ok(n)) => n,
_ => return reachable_no_anon(port, None),
};
let greeting = String::from_utf8_lossy(&buf[..n]).trim().to_string();
if !greeting.starts_with("@RSYNCD:") {
return reachable_no_anon(port, Some(greeting));
}
let echo = format!("{greeting}\n");
let _ = timeout(IO_TIMEOUT, stream.write_all(echo.as_bytes())).await;
let _ = timeout(IO_TIMEOUT, stream.write_all(b"#list\n")).await;
let mut reader = BufReader::new(stream);
let mut modules = Vec::new();
for _ in 0..64 {
let mut line = String::new();
match timeout(IO_TIMEOUT, reader.read_line(&mut line)).await {
Ok(Ok(0)) => break,
Ok(Ok(_)) => {
let line = line.trim();
if line.is_empty() || line == "@RSYNCD: EXIT" {
break;
}
if line.starts_with("@ERROR") {
return reachable_no_anon(port, Some(line.to_string()));
}
modules.push(line.to_string());
}
_ => break,
}
}
ProbeOutcome {
port,
reachable: true,
banner: Some(greeting),
anon_ok: !modules.is_empty(),
detail: modules,
}
}
async fn probe_ftp(host: &str) -> ProbeOutcome {
let port = 21;
let addr = format!("{host}:{port}");
let stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(s)) => s,
_ => return closed(port),
};
let mut stream = stream;
let mut buf = [0u8; 512];
let n = match timeout(IO_TIMEOUT, stream.read(&mut buf)).await {
Ok(Ok(n)) => n,
_ => return reachable_no_anon(port, None),
};
let banner = String::from_utf8_lossy(&buf[..n]).trim().to_string();
if !banner.starts_with("220") {
return reachable_no_anon(port, Some(banner));
}
let _ = timeout(IO_TIMEOUT, stream.write_all(b"USER anonymous\r\n")).await;
let mut buf = [0u8; 256];
let n = match timeout(IO_TIMEOUT, stream.read(&mut buf)).await {
Ok(Ok(n)) => n,
_ => return reachable_no_anon(port, Some(banner)),
};
let user_resp = String::from_utf8_lossy(&buf[..n]).trim().to_string();
if user_resp.starts_with("230") {
return ProbeOutcome {
port,
reachable: true,
banner: Some(banner),
anon_ok: true,
detail: vec![user_resp],
};
}
if !user_resp.starts_with("331") {
return reachable_no_anon(port, Some(banner));
}
let _ = timeout(
IO_TIMEOUT,
stream.write_all(b"PASS anonymous@example.com\r\n"),
)
.await;
let mut buf = [0u8; 256];
let n = match timeout(IO_TIMEOUT, stream.read(&mut buf)).await {
Ok(Ok(n)) => n,
_ => return reachable_no_anon(port, Some(banner)),
};
let pass_resp = String::from_utf8_lossy(&buf[..n]).trim().to_string();
let anon_ok = pass_resp.starts_with("230");
ProbeOutcome {
port,
reachable: true,
banner: Some(banner),
anon_ok,
detail: if anon_ok { vec![pass_resp] } else { vec![] },
}
}
async fn probe_tftp(host: &str) -> ProbeOutcome {
let _ = host;
ProbeOutcome {
port: 69,
reachable: false,
banner: Some(String::from(
"UDP probe deferred — use `nmap -sU -p69 --script tftp-enum`",
)),
anon_ok: false,
detail: vec![],
}
}
fn closed(port: u16) -> ProbeOutcome {
ProbeOutcome {
port,
reachable: false,
banner: None,
anon_ok: false,
detail: vec![],
}
}
fn reachable_no_anon(port: u16, banner: Option<String>) -> ProbeOutcome {
ProbeOutcome {
port,
reachable: true,
banner,
anon_ok: false,
detail: vec![],
}
}