use gossan_core::Target;
use hickory_resolver::{proto::rr::RecordType, TokioAsyncResolver};
use secfinding::{Evidence, Finding, Severity, FindingKind};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub async fn check(
resolver: &TokioAsyncResolver,
domain: &str,
target: &Target,
timeout: std::time::Duration,
proxy: Option<&str>,
) -> Vec<Finding> {
let mut findings = Vec::new();
let nameservers = match resolve_nameservers(resolver, domain).await {
Some(ns) => ns,
None => return findings,
};
for ns in &nameservers {
if let Some(axfr_result) = attempt(ns, domain, timeout, proxy).await {
gossan_core::try_push_finding(Finding::builder("dns", target.domain().unwrap_or("?"), Severity::Critical)
.title(format!("DNS zone transfer (AXFR) succeeds on {ns}"))
.detail(format!(
"Nameserver {ns} allows unauthenticated AXFR for {domain}. \
{record_count} DNS records exposed — complete subdomain inventory, \
internal hostnames, and mail topology disclosed.",
record_count = axfr_result.record_count
))
.kind(FindingKind::Vulnerability)
.evidence(Evidence::DnsRecord {
record_type: "AXFR".into(),
value: axfr_result.excerpt.into(),
})
.tag("zone-transfer")
.tag("critical")
.tag("dns"), &mut findings);
break; }
}
findings
}
struct AxfrResult {
record_count: u16,
excerpt: String,
}
async fn resolve_nameservers(
resolver: &TokioAsyncResolver,
domain: &str,
) -> Option<Vec<String>> {
let ns_records = resolver.lookup(domain, RecordType::NS).await.ok()?;
let nameservers: Vec<String> = ns_records
.iter()
.filter_map(|r| {
if let hickory_resolver::proto::rr::RData::NS(ns) = r {
Some(ns.to_string().trim_end_matches('.').to_string())
} else {
None
}
})
.collect();
if nameservers.is_empty() {
None
} else {
Some(nameservers)
}
}
async fn attempt(
nameserver: &str,
zone: &str,
timeout: std::time::Duration,
proxy: Option<&str>,
) -> Option<AxfrResult> {
let addr = tokio::net::lookup_host(format!("{nameserver}:53"))
.await
.ok()?
.next()?;
let mut stream = tokio::time::timeout(
timeout,
gossan_core::net::connect_tcp(&addr.ip().to_string(), addr.port(), proxy),
)
.await
.ok()?
.ok()?;
let query = build_query(zone);
let mut msg = (query.len() as u16).to_be_bytes().to_vec();
msg.extend_from_slice(&query);
tokio::time::timeout(timeout, stream.write_all(&msg))
.await
.ok()?
.ok()?;
let mut buf = Vec::with_capacity(65536);
let _ = tokio::time::timeout(timeout * 2, async {
let mut tmp = [0u8; 4096];
loop {
match stream.read(&mut tmp).await {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.extend_from_slice(&tmp[..n]);
if buf.len() > 512 * 1024 {
break;
}
}
}
}
})
.await
.ok();
parse_response(&buf, nameserver, zone)
}
fn parse_response(buf: &[u8], nameserver: &str, zone: &str) -> Option<AxfrResult> {
if buf.len() < 6 {
return None;
}
let first_msg = buf.get(2..)?;
if first_msg.len() < 8 {
return None;
}
let rcode = first_msg[3] & 0x0f;
if rcode != 0 {
return None; }
let ancount = u16::from_be_bytes([first_msg[6], first_msg[7]]);
if ancount == 0 {
return None;
}
tracing::warn!(
ns = nameserver,
zone = zone,
bytes = buf.len(),
records = ancount,
"AXFR zone transfer succeeded"
);
Some(AxfrResult {
record_count: ancount,
excerpt: format!(
"; AXFR response from {nameserver} for zone {zone}\n\
; {ancount} answer records in first message\n\
; {bytes} bytes received",
bytes = buf.len()
),
})
}
fn build_query(zone: &str) -> Vec<u8> {
let mut msg = Vec::with_capacity(64);
msg.extend_from_slice(&[0x13, 0x37]); msg.extend_from_slice(&[0x00, 0x00]); msg.extend_from_slice(&[0x00, 0x01]); msg.extend_from_slice(&[0x00, 0x00]); msg.extend_from_slice(&[0x00, 0x00]); msg.extend_from_slice(&[0x00, 0x00]);
for label in zone.trim_end_matches('.').split('.') {
msg.push(label.len() as u8);
msg.extend_from_slice(label.as_bytes());
}
msg.push(0x00);
msg.extend_from_slice(&[0x00, 0xfc]); msg.extend_from_slice(&[0x00, 0x01]);
msg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_query_encodes_header_and_question() {
let msg = build_query("example.com");
assert_eq!(&msg[..2], &[0x13, 0x37], "transaction ID");
assert_eq!(&msg[4..6], &[0x00, 0x01], "QDCOUNT = 1");
assert!(msg.ends_with(&[0x00, 0xfc, 0x00, 0x01]), "QTYPE=AXFR, QCLASS=IN");
}
#[test]
fn build_query_encodes_multi_label_zone() {
let msg = build_query("api.example.com.");
assert!(msg.windows(3).any(|w| w == [3, b'a', b'p']), "label 'api'");
assert!(
msg.windows(8).any(|w| w == [7, b'e', b'x', b'a', b'm', b'p', b'l', b'e']),
"label 'example'"
);
}
#[test]
fn parse_response_rejects_short_buffer() {
assert!(parse_response(&[0, 0, 0], "ns", "z").is_none());
}
#[test]
fn parse_response_rejects_refused() {
let buf = [0, 20, 0x13, 0x37, 0x80, 0x05, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
assert!(parse_response(&buf, "ns", "z").is_none());
}
#[test]
fn parse_response_accepts_valid_transfer() {
let buf = [0, 20, 0x13, 0x37, 0x80, 0x00, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
let result = parse_response(&buf, "ns1.example.com", "example.com");
assert!(result.is_some());
assert_eq!(result.as_ref().unwrap().record_count, 5);
assert!(result.unwrap().excerpt.contains("5 answer records"));
}
}