use gossan_core::Target;
use hickory_resolver::{proto::rr::RecordType, TokioResolver};
use secfinding::{Evidence, Finding, FindingKind, Severity};
static FINGERPRINTS: &str = include_str!("takeovers.txt");
pub async fn check(resolver: &TokioResolver, domain: &str, target: &Target) -> Vec<Finding> {
let mut findings = Vec::new();
findings.extend(check_cname(resolver, domain, target).await);
findings.extend(check_ns(resolver, domain, target).await);
findings.extend(check_mx(resolver, domain, target).await);
findings
}
pub fn fingerprints() -> Vec<(&'static str, &'static str)> {
FINGERPRINTS
.lines()
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
.filter_map(|l| {
let (suffix, name) = l.split_once(':')?;
Some((suffix.trim(), name.trim()))
})
.collect()
}
async fn lookup_name_absent(resolver: &TokioResolver, name: &str) -> bool {
match resolver.lookup(name, RecordType::A).await {
Ok(lookup) => lookup.iter().next().is_none(),
Err(e) => {
if e.is_nx_domain() || e.is_no_records_found() {
true
} else {
tracing::warn!(
name,
error = %e,
"dns takeover: A lookup failed (not treating as dangling)"
);
false
}
}
}
}
async fn check_cname(resolver: &TokioResolver, domain: &str, target: &Target) -> Vec<Finding> {
let mut findings = Vec::new();
let cname = match resolver.lookup(domain, RecordType::CNAME).await {
Ok(response) => {
let records: Vec<String> = response
.iter()
.filter_map(|r| {
if let hickory_resolver::proto::rr::RData::CNAME(c) = r {
Some(c.to_string().trim_end_matches('.').to_string())
} else {
None
}
})
.collect();
if records.is_empty() {
return findings;
}
records[0].clone()
}
Err(_) => return findings,
};
let fps = fingerprints();
let matched_service = fps.iter().find(|(suffix, _)| cname.ends_with(suffix));
let (suffix, service) = match matched_service {
Some(s) => *s,
None => return findings,
};
let dns_dangling = lookup_name_absent(resolver, domain).await;
if dns_dangling {
gossan_core::try_push_finding(Finding::builder("dns", target.domain().unwrap_or("?"), Severity::High)
.title(format!("Dangling CNAME → {service}"))
.detail(format!(
"{domain} has CNAME → {cname} (matches {suffix} → {service}) but it does not resolve. \
An attacker might be able to register this hostname on {service}."
))
.kind(FindingKind::Exposure)
.evidence(Evidence::DnsRecord {
record_type: "CNAME".into(),
value: cname.clone().into(),
})
.tag("takeover")
.tag("cname")
.tag("dangling"), &mut findings);
}
findings
}
async fn check_ns(resolver: &TokioResolver, domain: &str, target: &Target) -> Vec<Finding> {
let mut findings = Vec::new();
let ns_records = match resolver.lookup(domain, RecordType::NS).await {
Ok(r) => r,
Err(_) => return findings,
};
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();
for ns in &nameservers {
if lookup_name_absent(resolver, ns.as_str()).await {
gossan_core::try_push_finding(
Finding::builder("dns", target.domain().unwrap_or("?"), Severity::Critical)
.title(format!("NS takeover: dangling nameserver {ns}"))
.detail(format!(
"{domain} delegates DNS to {ns} which does not resolve. \
If an attacker registers {ns}, they control ALL DNS records \
for {domain} (full domain hijack)."
))
.kind(FindingKind::Vulnerability)
.evidence(Evidence::DnsRecord {
record_type: "NS".into(),
value: ns.clone().into(),
})
.tag("takeover")
.tag("ns")
.tag("critical"),
&mut findings,
);
}
}
if nameservers.len() < 2 {
gossan_core::try_push_finding(
Finding::builder("dns", target.domain().unwrap_or("?"), Severity::Low)
.title(format!(
"Only {} nameserver(s), no redundancy",
nameservers.len()
))
.detail(format!(
"{domain} has {count} NS record(s). RFC 2182 recommends ≥2. \
Single nameserver failure = total domain outage.",
count = nameservers.len()
))
.kind(FindingKind::Vulnerability)
.tag("dns")
.tag("resilience"),
&mut findings,
);
}
findings
}
async fn check_mx(resolver: &TokioResolver, domain: &str, target: &Target) -> Vec<Finding> {
let mut findings = Vec::new();
let mx_records = match resolver.mx_lookup(domain).await {
Ok(r) => r,
Err(_) => return findings,
};
let exchanges: Vec<String> = mx_records
.iter()
.map(|mx| mx.exchange().to_string().trim_end_matches('.').to_string())
.collect();
for mx in &exchanges {
if mx == "." {
continue; }
if lookup_name_absent(resolver, mx.as_str()).await {
gossan_core::try_push_finding(
Finding::builder("dns", target.domain().unwrap_or("?"), Severity::High)
.title(format!("MX takeover risk: dangling mail server {mx}"))
.detail(format!(
"{domain} MX record points to {mx} which does not resolve. \
If an attacker claims this hostname, they receive all email \
for {domain} (password resets, MFA codes, confidential data)."
))
.kind(FindingKind::Vulnerability)
.evidence(Evidence::DnsRecord {
record_type: "MX".into(),
value: mx.clone().into(),
})
.tag("takeover")
.tag("mx"),
&mut findings,
);
}
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fingerprint_database_loads() {
let fps = fingerprints();
assert!(
fps.len() >= 50,
"should have 50+ service fingerprints, got {}",
fps.len()
);
}
#[test]
fn fingerprints_have_valid_format() {
for (suffix, name) in fingerprints() {
assert!(!suffix.is_empty(), "empty suffix found");
assert!(!name.is_empty(), "empty name for suffix: {suffix}");
assert!(suffix.contains('.'), "suffix should be a domain: {suffix}");
}
}
#[test]
fn known_services_present() {
let fps = fingerprints();
let suffixes: Vec<&str> = fps.iter().map(|(s, _)| *s).collect();
for expected in [
"github.io",
"herokuapp.com",
"amazonaws.com",
"azurewebsites.net",
"netlify.app",
"vercel.app",
] {
assert!(
suffixes.contains(&expected),
"missing fingerprint: {expected}"
);
}
}
#[test]
fn no_duplicate_suffixes() {
let fps = fingerprints();
let mut seen = std::collections::HashSet::new();
for (suffix, name) in &fps {
assert!(seen.insert(*suffix), "duplicate suffix: {suffix} ({name})");
}
}
}