use anyhow::Result;
use hickory_resolver::config::{NameServerConfig, Protocol, ResolverConfig, ResolverOpts};
use hickory_resolver::TokioAsyncResolver;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use crate::types::{Finding, ModuleResult, Severity, StageTimer};
use crate::ui;
const SRV_QUERIES: &[(&str, &str)] = &[
("_ldap._tcp.dc._msdcs", "Domain Controllers"),
("_kerberos._tcp", "Kerberos KDC"),
("_gc._tcp", "Global Catalog"),
("_kpasswd._tcp", "Kerberos Password Change"),
("_ldap._tcp.pdc._msdcs", "Primary DC"),
("_ldap._tcp.gc._msdcs", "GC (MSDCS)"),
("_kerberos._tcp.dc._msdcs", "KDC (MSDCS)"),
("_ldap._tcp.ForestDnsZones", "Forest DNS Zones"),
("_ldap._tcp.DomainDnsZones", "Domain DNS Zones"),
("_mssql._tcp", "MSSQL Server"),
];
pub async fn run(
target: &str,
domain: Option<&str>,
) -> Result<(ModuleResult, Option<String>)> {
ui::section("DNS ENUMERATION");
let timer = StageTimer::start();
let spin = ui::spinner("DNS");
let mut result = ModuleResult::new("dns");
let mut discovered_domain: Option<String> = None;
spin.set_message("reverse lookup...");
if let Some(hostname) = discover_domain_from_target(target).await {
ui::info(&format!("Reverse DNS: {} → {}", target, hostname));
if let Some(dom) = domain_from_hostname(&hostname) {
discovered_domain = Some(dom.clone());
ui::success(&format!("Discovered domain: {}", dom));
}
}
let domain = domain
.map(|d| d.to_string())
.or_else(|| discovered_domain.clone());
let Some(domain) = &domain else {
ui::finish_spinner_warn(&spin, "no domain available for SRV queries");
result = result.success(timer.elapsed());
return Ok((result, discovered_domain));
};
let resolver = build_resolver(target)?;
spin.set_message("querying SRV records...");
let mut total_records = 0u32;
for (srv, label) in SRV_QUERIES {
let fqdn = format!("{}.{}", srv, domain);
match resolver.srv_lookup(&fqdn).await {
Ok(lookup) => {
let records: Vec<String> = lookup
.iter()
.map(|r| {
format!(
"{}:{} (priority={}, weight={})",
r.target(),
r.port(),
r.priority(),
r.weight()
)
})
.collect();
total_records += records.len() as u32;
if !records.is_empty() {
ui::kv(label, &records.join(", "));
}
}
Err(_) => {}
}
}
spin.set_message("checking open recursion...");
if check_open_recursion(&resolver).await {
let finding = Finding::new("dns", "DNS-001", Severity::Medium, "Open DNS recursion detected")
.with_description("The DNS server resolves external queries, which may allow cache poisoning or information leakage")
.with_recommendation("Disable recursive queries for external clients")
.with_mitre("T1557");
result.findings.push(finding);
ui::warning("Open DNS recursion detected — external queries resolved");
}
spin.set_message("attempting zone transfer...");
if let Ok(axfr_result) = attempt_zone_transfer(target, domain).await {
if axfr_result {
let finding = Finding::new(
"dns",
"DNS-002",
Severity::High,
"DNS zone transfer permitted",
)
.with_description("The DNS server allows zone transfers (AXFR), exposing all DNS records")
.with_recommendation("Restrict zone transfers to authorized secondary DNS servers only")
.with_mitre("T1590.002");
result.findings.push(finding);
ui::warning("Zone transfer (AXFR) appears to be permitted!");
}
}
spin.set_message("checking DNS dynamic updates...");
if let Some(finding) = check_dns_dynamic_update(target, domain).await {
result.findings.push(finding);
}
ui::finish_spinner(&spin, &format!("{} SRV records found", total_records));
ui::stage_done("DNS", &format!("{} records", total_records), &timer.elapsed_pretty());
result = result.success(timer.elapsed());
Ok((result, discovered_domain))
}
pub async fn discover_domain_from_target(target: &str) -> Option<String> {
let ip: IpAddr = target.parse().ok()?;
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
let lookup = tokio::time::timeout(Duration::from_secs(5), resolver.reverse_lookup(ip))
.await
.ok()?
.ok()?;
lookup
.iter()
.next()
.map(|name| name.to_string().trim_end_matches('.').to_string())
}
pub fn domain_from_hostname(hostname: &str) -> Option<String> {
let parts: Vec<&str> = hostname.split('.').collect();
if parts.len() >= 2 {
Some(parts[1..].join("."))
} else {
None
}
}
fn build_resolver(target: &str) -> Result<TokioAsyncResolver> {
let ip: IpAddr = target.parse()?;
let ns = NameServerConfig::new(SocketAddr::new(ip, 53), Protocol::Udp);
let mut config = ResolverConfig::new();
config.add_name_server(ns);
let mut opts = ResolverOpts::default();
opts.timeout = Duration::from_secs(5);
opts.attempts = 2;
Ok(TokioAsyncResolver::tokio(config, opts))
}
async fn check_open_recursion(resolver: &TokioAsyncResolver) -> bool {
let test_domains = ["www.google.com.", "www.cloudflare.com."];
for domain in test_domains {
if let Ok(lookup) = tokio::time::timeout(
Duration::from_secs(3),
resolver.lookup_ip(domain),
)
.await
{
if lookup.is_ok() {
return true;
}
}
}
false
}
async fn attempt_zone_transfer(target: &str, domain: &str) -> Result<bool> {
let output = tokio::time::timeout(
Duration::from_secs(10),
tokio::process::Command::new("dig")
.args(["axfr", domain, &format!("@{}", target)])
.output(),
)
.await??;
let stdout = String::from_utf8_lossy(&output.stdout);
ui::verbose_output("dig", &stdout);
let record_count = stdout
.lines()
.filter(|l| !l.starts_with(';') && !l.is_empty() && l.contains('\t'))
.count();
Ok(record_count > 2) }
async fn check_dns_dynamic_update(target: &str, domain: &str) -> Option<Finding> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
let test_name = format!("_aydee-dnstest-{}.{}", ts, domain);
let script = format!(
"server {}\nzone {}\nupdate add {} 10 TXT \"aydee-dynamic-update-test\"\nsend\nupdate delete {} TXT\nsend\n",
target, domain, test_name, test_name
);
let tmp_path = format!("/tmp/aydee_nsupdate_{}.txt", ts);
if tokio::fs::write(&tmp_path, &script).await.is_err() {
return None;
}
let out = tokio::time::timeout(
Duration::from_secs(10),
tokio::process::Command::new("nsupdate")
.arg(&tmp_path)
.output(),
)
.await;
let _ = tokio::fs::remove_file(&tmp_path).await;
match out {
Ok(Ok(output)) => {
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
ui::verbose_output("nsupdate", &combined);
if output.status.success() {
ui::warning("DNS dynamic updates allowed without authentication!");
Some(
Finding::new(
"dns",
"DNS-003",
Severity::High,
"Unauthenticated DNS dynamic updates permitted",
)
.with_description(
"The DNS server accepts dynamic updates without authentication, allowing attackers to add/modify DNS records for MitM attacks",
)
.with_recommendation(
"Configure DNS zones to require secure dynamic updates only",
)
.with_mitre("T1557.001"),
)
} else {
let lower = combined.to_lowercase();
if lower.contains("refused") {
ui::info("DNS dynamic updates properly restricted");
}
None
}
}
Ok(Err(_)) => {
ui::verbose("nsupdate not found — skipping DNS dynamic update check");
None
}
Err(_) => {
ui::verbose("nsupdate timed out");
None
}
}
}