use gossan_core::Target;
use hickory_resolver::{proto::rr::RecordType, TokioResolver};
use secfinding::{Evidence, Finding, FindingKind, Severity};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const MAX_AXFR_RESPONSE_BYTES: usize = 512 * 1024;
const AXFR_READ_CAPACITY: usize = 65_536;
const DNS_MAX_LABEL_LEN: usize = 63;
const AXFR_MIN_BUF_LEN: usize = 6;
const AXFR_MIN_MSG_LEN: usize = 8;
pub async fn check(
resolver: &TokioResolver,
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(resolver, 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
}
pub struct AxfrResult {
pub record_count: u16,
pub excerpt: String,
}
async fn resolve_nameservers(resolver: &TokioResolver, domain: &str) -> Option<Vec<String>> {
let ns_records = match resolver.lookup(domain, RecordType::NS).await {
Ok(r) => r,
Err(e) if e.is_nx_domain() || e.is_no_records_found() => return None,
Err(e) => {
tracing::warn!(
domain,
error = %e,
"AXFR NS lookup failed; skipping zone transfer attempts"
);
return None;
}
};
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(
resolver: &TokioResolver,
nameserver: &str,
zone: &str,
timeout: std::time::Duration,
proxy: Option<&str>,
) -> Option<AxfrResult> {
let port: u16 = std::env::var("GOSSAN_AXFR_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(53);
let lookup = match resolver.lookup_ip(nameserver).await {
Ok(r) => r,
Err(e) if e.is_nx_domain() || e.is_no_records_found() => return None,
Err(e) => {
tracing::warn!(
nameserver,
error = %e,
"AXFR nameserver A/AAAA lookup failed; skipping this NS"
);
return None;
}
};
let ip = lookup.iter().next()?;
let addr = std::net::SocketAddr::new(ip, port);
let mut stream = match tokio::time::timeout(
timeout,
gossan_core::net::connect_tcp(&addr.ip().to_string(), addr.port(), proxy),
)
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
tracing::warn!("AXFR TCP connect failed: nameserver={} zone={} error={}", nameserver, zone, e);
return None;
}
Err(_) => {
tracing::warn!("AXFR TCP connect timed out: nameserver={} zone={}", nameserver, zone);
return None;
}
};
let query = build_query(zone);
if query.is_empty() {
return None;
}
let mut msg = (query.len() as u16).to_be_bytes().to_vec();
msg.extend_from_slice(&query);
match tokio::time::timeout(timeout, stream.write_all(&msg)).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::warn!("AXFR query write failed: nameserver={} zone={} error={}", nameserver, zone, e);
return None;
}
Err(_) => {
tracing::warn!("AXFR query write timed out: nameserver={} zone={}", nameserver, zone);
return None;
}
}
let mut buf = Vec::with_capacity(AXFR_READ_CAPACITY);
let read_result = tokio::time::timeout(timeout.saturating_mul(2), async {
let mut tmp = [0u8; 4096];
loop {
match stream.read(&mut tmp).await {
Ok(0) => break Ok(()), Ok(n) => {
buf.extend_from_slice(&tmp[..n]);
if buf.len() > MAX_AXFR_RESPONSE_BYTES {
break Ok(());
}
}
Err(e) => break Err(e),
}
}
})
.await;
match read_result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::warn!(
nameserver,
zone,
error = %e,
"AXFR read failed; aborting parse"
);
return None;
}
Err(_) => {
tracing::warn!(
nameserver,
zone,
"AXFR read timed out; aborting parse"
);
return None;
}
}
parse_response(&buf, nameserver, zone)
}
pub fn parse_response(buf: &[u8], nameserver: &str, zone: &str) -> Option<AxfrResult> {
if buf.len() < AXFR_MIN_BUF_LEN {
return None;
}
let first_msg = buf.get(2..)?;
if first_msg.len() < AXFR_MIN_MSG_LEN {
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()
),
})
}
pub fn build_query(zone: &str) -> Vec<u8> {
build_query_with_txid(zone, random_txid())
}
fn random_txid() -> u16 {
fastrand::u16(..)
}
pub fn build_query_with_txid(zone: &str, txid: u16) -> Vec<u8> {
let mut msg = Vec::with_capacity(64);
msg.extend_from_slice(&txid.to_be_bytes()); 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('.') {
if label.len() > DNS_MAX_LABEL_LEN {
continue;
}
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]);
if msg.len() > u16::MAX as usize {
return Vec::new();
}
msg
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn build_query_encodes_header_and_question() {
let msg = build_query_with_txid("example.com", 0x1337);
assert_eq!(&msg[..2], &[0x13, 0x37], "transaction ID encoded big-endian");
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_txid_is_big_endian() {
let msg = build_query_with_txid("example.com", 0xABCD);
assert_eq!(msg[0], 0xAB);
assert_eq!(msg[1], 0xCD);
}
#[test]
fn build_query_encodes_multi_label_zone() {
let msg = build_query_with_txid("api.example.com.", 0x0001);
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 build_query_production_txid_is_not_fixed_sentinel() {
let all_sentinel = (0..16)
.map(|_| {
let q = build_query("example.com");
u16::from_be_bytes([q[0], q[1]])
})
.all(|id| id == 0x1337);
assert!(
!all_sentinel,
"build_query always returned the fixed 0x1337 sentinel. RNG is broken"
);
}
#[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"));
}
#[test]
fn build_query_rejects_pathological_zone() {
let zone = (0..35_000).map(|_| "a").collect::<Vec<_>>().join(".");
let msg = build_query_with_txid(&zone, 0x1337);
assert!(
msg.is_empty(),
"pathological zone must produce empty query, got {} bytes",
msg.len()
);
}
#[test]
fn read_timeout_does_not_overflow() {
let timeout = std::time::Duration::from_secs(u64::MAX);
let extended = timeout.saturating_mul(2);
assert_eq!(extended, std::time::Duration::MAX);
}
proptest! {
#[test]
fn build_query_never_panics(zone in ".*") {
let _ = build_query(&zone);
}
#[test]
fn build_query_length_bounded(zone in ".{0,10000}") {
let msg = build_query(&zone);
prop_assert!(msg.len() <= u16::MAX as usize);
}
#[test]
fn parse_response_never_panics(buf in prop::collection::vec(any::<u8>(), 0..1024)) {
let _ = parse_response(&buf, "ns", "z");
}
#[test]
fn parse_response_rejects_short_buffers(buf in prop::collection::vec(any::<u8>(), 0..6)) {
prop_assert!(parse_response(&buf, "ns", "z").is_none());
}
#[test]
fn parse_response_min_buf_len_constant_is_six(
buf in prop::collection::vec(any::<u8>(), 0..AXFR_MIN_BUF_LEN),
) {
prop_assert!(parse_response(&buf, "ns", "z").is_none());
}
#[test]
fn parse_response_rejects_nonzero_rcode(
suffix in prop::collection::vec(any::<u8>(), 4..20),
rcode in 1u8..16,
) {
let mut buf = vec![0u8, 0u8]; buf.push(0x13); buf.push(0x37); buf.push(0x80); buf.push(rcode | 0x80); buf.extend(suffix);
prop_assert!(parse_response(&buf, "ns", "z").is_none());
}
}
}