diff --git a/crates/wtfnet-dnsleak/src/classify.rs b/crates/wtfnet-dnsleak/src/classify.rs index baa5667..d5d8045 100644 --- a/crates/wtfnet-dnsleak/src/classify.rs +++ b/crates/wtfnet-dnsleak/src/classify.rs @@ -1,5 +1,6 @@ use crate::report::LeakTransport; use hickory_proto::op::{Message, MessageType}; +use hickory_proto::rr::RData; use serde::{Deserialize, Serialize}; use std::net::IpAddr; use wtfnet_platform::FlowProtocol; @@ -17,16 +18,43 @@ pub struct ClassifiedEvent { pub qname: Option, pub qtype: Option, pub rcode: Option, + pub is_response: bool, + pub answer_ips: Vec, } -pub fn classify_dns_query(payload: &[u8]) -> Option<(String, String, String)> { +pub struct ParsedDns { + pub qname: String, + pub qtype: String, + pub rcode: String, + pub is_response: bool, + pub answer_ips: Vec, +} + +pub fn parse_dns_message(payload: &[u8]) -> Option { let message = Message::from_vec(payload).ok()?; - if message.message_type() != MessageType::Query { - return None; - } + let is_response = message.message_type() == MessageType::Response; let query = message.queries().first()?; let qname = query.name().to_utf8(); let qtype = query.query_type().to_string(); let rcode = message.response_code().to_string(); - Some((qname, qtype, rcode)) + let mut answer_ips = Vec::new(); + if is_response { + for record in message.answers() { + if let Some(data) = record.data() { + match data { + RData::A(addr) => answer_ips.push(IpAddr::V4(addr.0)), + RData::AAAA(addr) => answer_ips.push(IpAddr::V6(addr.0)), + _ => {} + } + } + } + } + + Some(ParsedDns { + qname, + qtype, + rcode, + is_response, + answer_ips, + }) } diff --git a/crates/wtfnet-dnsleak/src/lib.rs b/crates/wtfnet-dnsleak/src/lib.rs index 7c4d0dc..c17f2c1 100644 --- a/crates/wtfnet-dnsleak/src/lib.rs +++ b/crates/wtfnet-dnsleak/src/lib.rs @@ -7,7 +7,7 @@ mod rules; mod sensor; use crate::classify::ClassifiedEvent; -use crate::sensor::capture_events; +use crate::sensor::{capture_events, SensorEvent, TcpEvent}; use std::time::Instant; use thiserror::Error; use tracing::debug; @@ -50,13 +50,30 @@ pub async fn watch( let start = Instant::now(); let events = capture_events(&options).await?; let mut leak_events = Vec::new(); + let mut dns_cache: std::collections::HashMap = + std::collections::HashMap::new(); for event in events { - let enriched = enrich_event(event, flow_owner).await; - if let Some(decision) = rules::evaluate(&enriched, &options.policy) { - let mut leak_event = report::LeakEvent::from_decision(enriched, decision); - privacy::apply_privacy(&mut leak_event, options.privacy); - leak_events.push(leak_event); + match event { + SensorEvent::Dns(event) => { + let enriched = enrich_event(event, flow_owner).await; + if enriched.is_response { + update_dns_cache(&mut dns_cache, &enriched); + continue; + } + if let Some(decision) = rules::evaluate(&enriched, &options.policy) { + let mut leak_event = report::LeakEvent::from_decision(enriched, decision); + privacy::apply_privacy(&mut leak_event, options.privacy); + leak_events.push(leak_event); + } + } + SensorEvent::Tcp(event) => { + if let Some(leak_event) = + evaluate_mismatch(event, flow_owner, &mut dns_cache, options.privacy).await + { + leak_events.push(leak_event); + } + } } } @@ -100,3 +117,106 @@ async fn enrich_event( } enriched } + +struct DnsCacheEntry { + qname: String, + route_class: RouteClass, + timestamp_ms: u128, +} + +const DNS_CACHE_TTL_MS: u128 = 60_000; + +fn update_dns_cache(cache: &mut std::collections::HashMap, event: &report::EnrichedEvent) { + let Some(qname) = event.qname.as_ref() else { return }; + let now = event.timestamp_ms; + prune_dns_cache(cache, now); + for ip in event.answer_ips.iter() { + debug!( + "dns leak cache insert ip={} qname={} route={:?}", + ip, qname, event.route_class + ); + cache.insert( + *ip, + DnsCacheEntry { + qname: qname.clone(), + route_class: event.route_class, + timestamp_ms: now, + }, + ); + } +} + +fn prune_dns_cache( + cache: &mut std::collections::HashMap, + now_ms: u128, +) { + cache.retain(|_, entry| now_ms.saturating_sub(entry.timestamp_ms) <= DNS_CACHE_TTL_MS); +} + +async fn evaluate_mismatch( + event: TcpEvent, + flow_owner: Option<&dyn FlowOwnerProvider>, + cache: &mut std::collections::HashMap, + privacy: PrivacyMode, +) -> Option { + prune_dns_cache(cache, event.timestamp_ms); + debug!( + "dns leak tcp syn dst_ip={} dst_port={} cache_size={}", + event.dst_ip, + event.dst_port, + cache.len() + ); + let entry = cache.get(&event.dst_ip)?; + let tcp_route = route::route_class_for(event.src_ip, event.dst_ip, event.iface_name.as_deref()); + if tcp_route == entry.route_class { + debug!( + "dns leak mismatch skip dst_ip={} tcp_route={:?} dns_route={:?}", + event.dst_ip, tcp_route, entry.route_class + ); + return None; + } + + let mut enriched = report::EnrichedEvent { + timestamp_ms: event.timestamp_ms, + proto: wtfnet_platform::FlowProtocol::Tcp, + src_ip: event.src_ip, + src_port: event.src_port, + dst_ip: event.dst_ip, + dst_port: event.dst_port, + iface_name: event.iface_name.clone(), + transport: LeakTransport::Unknown, + qname: Some(entry.qname.clone()), + qtype: None, + rcode: None, + is_response: false, + answer_ips: Vec::new(), + route_class: tcp_route, + owner: None, + owner_confidence: wtfnet_platform::FlowOwnerConfidence::None, + owner_failure: None, + }; + + if let Some(provider) = flow_owner { + let flow = FlowTuple { + proto: wtfnet_platform::FlowProtocol::Tcp, + src_ip: event.src_ip, + src_port: event.src_port, + dst_ip: event.dst_ip, + dst_port: event.dst_port, + }; + if let Ok(result) = provider.owner_of(flow).await { + enriched.owner = result.owner; + enriched.owner_confidence = result.confidence; + enriched.owner_failure = result.failure_reason; + } + } + + let decision = rules::LeakDecision { + leak_type: report::LeakType::D, + severity: Severity::P2, + policy_rule_id: "LEAK_D_MISMATCH".to_string(), + }; + let mut leak_event = report::LeakEvent::from_decision(enriched, decision); + privacy::apply_privacy(&mut leak_event, privacy); + Some(leak_event) +} diff --git a/crates/wtfnet-dnsleak/src/report.rs b/crates/wtfnet-dnsleak/src/report.rs index af46e7f..ed7a0bb 100644 --- a/crates/wtfnet-dnsleak/src/report.rs +++ b/crates/wtfnet-dnsleak/src/report.rs @@ -23,7 +23,7 @@ pub enum LeakType { D, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum RouteClass { Loopback, @@ -54,6 +54,8 @@ pub struct EnrichedEvent { pub qname: Option, pub qtype: Option, pub rcode: Option, + pub is_response: bool, + pub answer_ips: Vec, pub route_class: RouteClass, pub owner: Option, pub owner_confidence: FlowOwnerConfidence, diff --git a/crates/wtfnet-dnsleak/src/route.rs b/crates/wtfnet-dnsleak/src/route.rs index 53fa905..bbe8355 100644 --- a/crates/wtfnet-dnsleak/src/route.rs +++ b/crates/wtfnet-dnsleak/src/route.rs @@ -3,20 +3,7 @@ use crate::report::{EnrichedEvent, RouteClass}; use wtfnet_platform::FlowOwnerConfidence; pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent { - let route_class = if event.src_ip.is_loopback() || event.dst_ip.is_loopback() { - RouteClass::Loopback - } else if event - .iface_name - .as_ref() - .map(|name| is_tunnel_iface(name)) - .unwrap_or(false) - { - RouteClass::Tunnel - } else if event.iface_name.is_some() { - RouteClass::Physical - } else { - RouteClass::Unknown - }; + let route_class = route_class_for(event.src_ip, event.dst_ip, event.iface_name.as_deref()); EnrichedEvent { timestamp_ms: event.timestamp_ms, @@ -30,6 +17,8 @@ pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent { qname: event.qname, qtype: event.qtype, rcode: event.rcode, + is_response: event.is_response, + answer_ips: event.answer_ips, route_class, owner: None, owner_confidence: FlowOwnerConfidence::None, @@ -37,6 +26,22 @@ pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent { } } +pub fn route_class_for( + src_ip: std::net::IpAddr, + dst_ip: std::net::IpAddr, + iface_name: Option<&str>, +) -> RouteClass { + if src_ip.is_loopback() || dst_ip.is_loopback() { + RouteClass::Loopback + } else if iface_name.map(is_tunnel_iface).unwrap_or(false) { + RouteClass::Tunnel + } else if iface_name.is_some() { + RouteClass::Physical + } else { + RouteClass::Unknown + } +} + fn is_tunnel_iface(name: &str) -> bool { let name = name.to_ascii_lowercase(); name.contains("tun") diff --git a/crates/wtfnet-dnsleak/src/sensor.rs b/crates/wtfnet-dnsleak/src/sensor.rs index 510571e..e7e8d08 100644 --- a/crates/wtfnet-dnsleak/src/sensor.rs +++ b/crates/wtfnet-dnsleak/src/sensor.rs @@ -1,4 +1,4 @@ -use crate::classify::{classify_dns_query, ClassifiedEvent}; +use crate::classify::{parse_dns_message, ClassifiedEvent}; use crate::report::LeakTransport; use crate::DnsLeakError; use std::collections::HashSet; @@ -20,14 +20,14 @@ const OPEN_IFACE_TIMEOUT_MS: u64 = 700; const FRAME_RECV_TIMEOUT_MS: u64 = 200; #[cfg(not(feature = "pcap"))] -pub async fn capture_events(_options: &LeakWatchOptions) -> Result, DnsLeakError> { +pub async fn capture_events(_options: &LeakWatchOptions) -> Result, DnsLeakError> { Err(DnsLeakError::NotSupported( "dns leak watch requires pcap feature".to_string(), )) } #[cfg(feature = "pcap")] -pub async fn capture_events(options: &LeakWatchOptions) -> Result, DnsLeakError> { +pub async fn capture_events(options: &LeakWatchOptions) -> Result, DnsLeakError> { let options = options.clone(); let iface_list = datalink::interfaces(); let candidates = format_iface_list(&iface_list); @@ -49,6 +49,22 @@ pub async fn capture_events(options: &LeakWatchOptions) -> Result, +} + +#[derive(Debug, Clone)] +pub enum SensorEvent { + Dns(ClassifiedEvent), + Tcp(TcpEvent), +} + #[derive(Debug, Clone)] pub struct IfaceDiag { pub name: String, @@ -88,7 +104,7 @@ pub fn iface_diagnostics() -> Result, DnsLeakError> { } #[cfg(feature = "pcap")] -fn capture_events_blocking(options: LeakWatchOptions) -> Result, DnsLeakError> { +fn capture_events_blocking(options: LeakWatchOptions) -> Result, DnsLeakError> { use pnet::packet::ethernet::{EtherTypes, EthernetPacket}; use pnet::packet::Packet; @@ -137,19 +153,38 @@ fn capture_events_blocking(options: LeakWatchOptions) -> Result None, }; if let Some(event) = event { - let key = format!( - "{:?}|{}|{}|{}|{}", - event.transport, event.src_ip, event.src_port, event.dst_ip, event.dst_port - ); + let key = match &event { + SensorEvent::Dns(value) => format!( + "dns:{:?}|{}|{}|{}|{}", + value.transport, value.src_ip, value.src_port, value.dst_ip, value.dst_port + ), + SensorEvent::Tcp(value) => format!( + "tcp:{}|{}|{}|{}", + value.src_ip, value.src_port, value.dst_ip, value.dst_port + ), + }; if seen.insert(key) { - debug!( - transport = ?event.transport, - src_ip = %event.src_ip, - src_port = event.src_port, - dst_ip = %event.dst_ip, - dst_port = event.dst_port, - "dns leak event" - ); + match &event { + SensorEvent::Dns(value) => { + debug!( + transport = ?value.transport, + src_ip = %value.src_ip, + src_port = value.src_port, + dst_ip = %value.dst_ip, + dst_port = value.dst_port, + "dns leak event" + ); + } + SensorEvent::Tcp(value) => { + debug!( + src_ip = %value.src_ip, + src_port = value.src_port, + dst_ip = %value.dst_ip, + dst_port = value.dst_port, + "dns leak tcp event" + ); + } + } events.push(event); } } @@ -163,28 +198,19 @@ fn parse_ipv4( payload: &[u8], local_ips: &[IpAddr], iface_name: &str, -) -> Option { +) -> Option { use pnet::packet::ip::IpNextHeaderProtocols; use pnet::packet::ipv4::Ipv4Packet; use pnet::packet::Packet; let ipv4 = Ipv4Packet::new(payload)?; let src = IpAddr::V4(ipv4.get_source()); - if !local_ips.contains(&src) { + let dst = IpAddr::V4(ipv4.get_destination()); + if !local_ips.contains(&src) && !local_ips.contains(&dst) { return None; } match ipv4.get_next_level_protocol() { - IpNextHeaderProtocols::Udp => parse_udp( - src, - IpAddr::V4(ipv4.get_destination()), - ipv4.payload(), - iface_name, - ), - IpNextHeaderProtocols::Tcp => parse_tcp( - src, - IpAddr::V4(ipv4.get_destination()), - ipv4.payload(), - iface_name, - ), + IpNextHeaderProtocols::Udp => parse_udp(src, dst, ipv4.payload(), iface_name), + IpNextHeaderProtocols::Tcp => parse_tcp(src, dst, ipv4.payload(), iface_name), _ => None, } } @@ -194,28 +220,19 @@ fn parse_ipv6( payload: &[u8], local_ips: &[IpAddr], iface_name: &str, -) -> Option { +) -> Option { use pnet::packet::ip::IpNextHeaderProtocols; use pnet::packet::ipv6::Ipv6Packet; use pnet::packet::Packet; let ipv6 = Ipv6Packet::new(payload)?; let src = IpAddr::V6(ipv6.get_source()); - if !local_ips.contains(&src) { + let dst = IpAddr::V6(ipv6.get_destination()); + if !local_ips.contains(&src) && !local_ips.contains(&dst) { return None; } match ipv6.get_next_header() { - IpNextHeaderProtocols::Udp => parse_udp( - src, - IpAddr::V6(ipv6.get_destination()), - ipv6.payload(), - iface_name, - ), - IpNextHeaderProtocols::Tcp => parse_tcp( - src, - IpAddr::V6(ipv6.get_destination()), - ipv6.payload(), - iface_name, - ), + IpNextHeaderProtocols::Udp => parse_udp(src, dst, ipv6.payload(), iface_name), + IpNextHeaderProtocols::Tcp => parse_tcp(src, dst, ipv6.payload(), iface_name), _ => None, } } @@ -226,28 +243,31 @@ fn parse_udp( dst_ip: IpAddr, payload: &[u8], iface_name: &str, -) -> Option { +) -> Option { use pnet::packet::udp::UdpPacket; use pnet::packet::Packet; let udp = UdpPacket::new(payload)?; + let src_port = udp.get_source(); let dst_port = udp.get_destination(); - if dst_port != 53 { + if src_port != 53 && dst_port != 53 { return None; } - let (qname, qtype, rcode) = classify_dns_query(udp.payload())?; - Some(ClassifiedEvent { + let parsed = parse_dns_message(udp.payload())?; + Some(SensorEvent::Dns(ClassifiedEvent { timestamp_ms: now_ms(), proto: FlowProtocol::Udp, src_ip, - src_port: udp.get_source(), + src_port, dst_ip, dst_port, iface_name: Some(iface_name.to_string()), transport: LeakTransport::Udp53, - qname: Some(qname), - qtype: Some(qtype), - rcode: Some(rcode), - }) + qname: Some(parsed.qname), + qtype: Some(parsed.qtype), + rcode: Some(parsed.rcode), + is_response: parsed.is_response, + answer_ips: parsed.answer_ips, + })) } #[cfg(feature = "pcap")] @@ -256,20 +276,36 @@ fn parse_tcp( dst_ip: IpAddr, payload: &[u8], iface_name: &str, -) -> Option { +) -> Option { use pnet::packet::tcp::TcpPacket; let tcp = TcpPacket::new(payload)?; let dst_port = tcp.get_destination(); + let src_port = tcp.get_source(); let transport = match dst_port { 53 => LeakTransport::Tcp53, 853 => LeakTransport::Dot, - _ => return None, + _ => { + let flags = tcp.get_flags(); + let syn = flags & 0x02 != 0; + let ack = flags & 0x10 != 0; + if syn && !ack { + return Some(SensorEvent::Tcp(TcpEvent { + timestamp_ms: now_ms(), + src_ip, + src_port, + dst_ip, + dst_port, + iface_name: Some(iface_name.to_string()), + })); + } + return None; + } }; - Some(ClassifiedEvent { + Some(SensorEvent::Dns(ClassifiedEvent { timestamp_ms: now_ms(), proto: FlowProtocol::Tcp, src_ip, - src_port: tcp.get_source(), + src_port, dst_ip, dst_port, iface_name: Some(iface_name.to_string()), @@ -277,7 +313,9 @@ fn parse_tcp( qname: None, qtype: None, rcode: None, - }) + is_response: false, + answer_ips: Vec::new(), + })) } #[cfg(feature = "pcap")] diff --git a/docs/WORK_ITEMS_v0.4.0.md b/docs/WORK_ITEMS_v0.4.0.md index 98aefbc..c51c6b1 100644 --- a/docs/WORK_ITEMS_v0.4.0.md +++ b/docs/WORK_ITEMS_v0.4.0.md @@ -30,4 +30,4 @@ This is a practical checklist to execute v0.4.0. ## 5) follow-ups - [ ] add DoH heuristic classification (optional) -- [ ] add Leak-D mismatch correlation (optional) +- [x] add Leak-D mismatch correlation (optional) diff --git a/docs/dns_leak_implementation_status.md b/docs/dns_leak_implementation_status.md index 1064898..5600bad 100644 --- a/docs/dns_leak_implementation_status.md +++ b/docs/dns_leak_implementation_status.md @@ -13,6 +13,7 @@ This document tracks the current DNS leak detector implementation against the de - Leak-A (plaintext DNS outside safe path). - Leak-B (split-policy intent leak based on proxy-required/allowlist domains). - Leak-C (encrypted DNS bypass for DoT). + - Leak-D (basic mismatch: DNS response IP -> outbound TCP SYN on different route). - Policy profiles: `full-tunnel`, `proxy-stub`, `split`. - Privacy modes: full/redacted/minimal (redacts qname). - Process attribution: @@ -37,10 +38,16 @@ This document tracks the current DNS leak detector implementation against the de ## Not implemented (v0.4 backlog) - DoH heuristic detection (SNI/endpoint list/traffic shape). -- Leak-D mismatch correlation (DNS -> TCP/TLS flows). - GeoIP enrichment of leak events. - Process tree reporting (PPID chain). ## Known limitations - On Windows, pcap capture may require selecting a specific NPF interface; use `dns leak watch --iface-diag` to list interfaces that can be opened. +- Leak-D test attempts on Windows did not fire; see test notes below. + +## Test notes +- `dns leak watch --duration 8s --summary-only --iface ` captured UDP/53 and produced Leak-A. +- `dns leak watch --duration 15s --iface ` with scripted DNS query + TCP connect: + - UDP/53 query/response captured (Leak-A). + - TCP SYNs observed, but did not match cached DNS response IPs, so Leak-D did not trigger.