Add: Leak-D for dns leak detection
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use crate::report::LeakTransport;
|
use crate::report::LeakTransport;
|
||||||
use hickory_proto::op::{Message, MessageType};
|
use hickory_proto::op::{Message, MessageType};
|
||||||
|
use hickory_proto::rr::RData;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use wtfnet_platform::FlowProtocol;
|
use wtfnet_platform::FlowProtocol;
|
||||||
@@ -17,16 +18,43 @@ pub struct ClassifiedEvent {
|
|||||||
pub qname: Option<String>,
|
pub qname: Option<String>,
|
||||||
pub qtype: Option<String>,
|
pub qtype: Option<String>,
|
||||||
pub rcode: Option<String>,
|
pub rcode: Option<String>,
|
||||||
|
pub is_response: bool,
|
||||||
|
pub answer_ips: Vec<IpAddr>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn classify_dns_query(payload: &[u8]) -> Option<(String, String, String)> {
|
pub struct ParsedDns {
|
||||||
let message = Message::from_vec(payload).ok()?;
|
pub qname: String,
|
||||||
if message.message_type() != MessageType::Query {
|
pub qtype: String,
|
||||||
return None;
|
pub rcode: String,
|
||||||
|
pub is_response: bool,
|
||||||
|
pub answer_ips: Vec<IpAddr>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn parse_dns_message(payload: &[u8]) -> Option<ParsedDns> {
|
||||||
|
let message = Message::from_vec(payload).ok()?;
|
||||||
|
let is_response = message.message_type() == MessageType::Response;
|
||||||
let query = message.queries().first()?;
|
let query = message.queries().first()?;
|
||||||
let qname = query.name().to_utf8();
|
let qname = query.name().to_utf8();
|
||||||
let qtype = query.query_type().to_string();
|
let qtype = query.query_type().to_string();
|
||||||
let rcode = message.response_code().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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ mod rules;
|
|||||||
mod sensor;
|
mod sensor;
|
||||||
|
|
||||||
use crate::classify::ClassifiedEvent;
|
use crate::classify::ClassifiedEvent;
|
||||||
use crate::sensor::capture_events;
|
use crate::sensor::{capture_events, SensorEvent, TcpEvent};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
@@ -50,15 +50,32 @@ pub async fn watch(
|
|||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let events = capture_events(&options).await?;
|
let events = capture_events(&options).await?;
|
||||||
let mut leak_events = Vec::new();
|
let mut leak_events = Vec::new();
|
||||||
|
let mut dns_cache: std::collections::HashMap<std::net::IpAddr, DnsCacheEntry> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
|
match event {
|
||||||
|
SensorEvent::Dns(event) => {
|
||||||
let enriched = enrich_event(event, flow_owner).await;
|
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) {
|
if let Some(decision) = rules::evaluate(&enriched, &options.policy) {
|
||||||
let mut leak_event = report::LeakEvent::from_decision(enriched, decision);
|
let mut leak_event = report::LeakEvent::from_decision(enriched, decision);
|
||||||
privacy::apply_privacy(&mut leak_event, options.privacy);
|
privacy::apply_privacy(&mut leak_event, options.privacy);
|
||||||
leak_events.push(leak_event);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let summary = LeakSummary::from_events(&leak_events);
|
let summary = LeakSummary::from_events(&leak_events);
|
||||||
let report = LeakReport {
|
let report = LeakReport {
|
||||||
@@ -100,3 +117,106 @@ async fn enrich_event(
|
|||||||
}
|
}
|
||||||
enriched
|
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<std::net::IpAddr, DnsCacheEntry>, 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<std::net::IpAddr, DnsCacheEntry>,
|
||||||
|
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<std::net::IpAddr, DnsCacheEntry>,
|
||||||
|
privacy: PrivacyMode,
|
||||||
|
) -> Option<LeakEvent> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub enum LeakType {
|
|||||||
D,
|
D,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum RouteClass {
|
pub enum RouteClass {
|
||||||
Loopback,
|
Loopback,
|
||||||
@@ -54,6 +54,8 @@ pub struct EnrichedEvent {
|
|||||||
pub qname: Option<String>,
|
pub qname: Option<String>,
|
||||||
pub qtype: Option<String>,
|
pub qtype: Option<String>,
|
||||||
pub rcode: Option<String>,
|
pub rcode: Option<String>,
|
||||||
|
pub is_response: bool,
|
||||||
|
pub answer_ips: Vec<IpAddr>,
|
||||||
pub route_class: RouteClass,
|
pub route_class: RouteClass,
|
||||||
pub owner: Option<FlowOwner>,
|
pub owner: Option<FlowOwner>,
|
||||||
pub owner_confidence: FlowOwnerConfidence,
|
pub owner_confidence: FlowOwnerConfidence,
|
||||||
|
|||||||
@@ -3,20 +3,7 @@ use crate::report::{EnrichedEvent, RouteClass};
|
|||||||
use wtfnet_platform::FlowOwnerConfidence;
|
use wtfnet_platform::FlowOwnerConfidence;
|
||||||
|
|
||||||
pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent {
|
pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent {
|
||||||
let route_class = if event.src_ip.is_loopback() || event.dst_ip.is_loopback() {
|
let route_class = route_class_for(event.src_ip, event.dst_ip, event.iface_name.as_deref());
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
EnrichedEvent {
|
EnrichedEvent {
|
||||||
timestamp_ms: event.timestamp_ms,
|
timestamp_ms: event.timestamp_ms,
|
||||||
@@ -30,6 +17,8 @@ pub fn enrich_route(event: ClassifiedEvent) -> EnrichedEvent {
|
|||||||
qname: event.qname,
|
qname: event.qname,
|
||||||
qtype: event.qtype,
|
qtype: event.qtype,
|
||||||
rcode: event.rcode,
|
rcode: event.rcode,
|
||||||
|
is_response: event.is_response,
|
||||||
|
answer_ips: event.answer_ips,
|
||||||
route_class,
|
route_class,
|
||||||
owner: None,
|
owner: None,
|
||||||
owner_confidence: FlowOwnerConfidence::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 {
|
fn is_tunnel_iface(name: &str) -> bool {
|
||||||
let name = name.to_ascii_lowercase();
|
let name = name.to_ascii_lowercase();
|
||||||
name.contains("tun")
|
name.contains("tun")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::classify::{classify_dns_query, ClassifiedEvent};
|
use crate::classify::{parse_dns_message, ClassifiedEvent};
|
||||||
use crate::report::LeakTransport;
|
use crate::report::LeakTransport;
|
||||||
use crate::DnsLeakError;
|
use crate::DnsLeakError;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -20,14 +20,14 @@ const OPEN_IFACE_TIMEOUT_MS: u64 = 700;
|
|||||||
const FRAME_RECV_TIMEOUT_MS: u64 = 200;
|
const FRAME_RECV_TIMEOUT_MS: u64 = 200;
|
||||||
|
|
||||||
#[cfg(not(feature = "pcap"))]
|
#[cfg(not(feature = "pcap"))]
|
||||||
pub async fn capture_events(_options: &LeakWatchOptions) -> Result<Vec<ClassifiedEvent>, DnsLeakError> {
|
pub async fn capture_events(_options: &LeakWatchOptions) -> Result<Vec<SensorEvent>, DnsLeakError> {
|
||||||
Err(DnsLeakError::NotSupported(
|
Err(DnsLeakError::NotSupported(
|
||||||
"dns leak watch requires pcap feature".to_string(),
|
"dns leak watch requires pcap feature".to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
pub async fn capture_events(options: &LeakWatchOptions) -> Result<Vec<ClassifiedEvent>, DnsLeakError> {
|
pub async fn capture_events(options: &LeakWatchOptions) -> Result<Vec<SensorEvent>, DnsLeakError> {
|
||||||
let options = options.clone();
|
let options = options.clone();
|
||||||
let iface_list = datalink::interfaces();
|
let iface_list = datalink::interfaces();
|
||||||
let candidates = format_iface_list(&iface_list);
|
let candidates = format_iface_list(&iface_list);
|
||||||
@@ -49,6 +49,22 @@ pub async fn capture_events(options: &LeakWatchOptions) -> Result<Vec<Classified
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TcpEvent {
|
||||||
|
pub timestamp_ms: u128,
|
||||||
|
pub src_ip: IpAddr,
|
||||||
|
pub src_port: u16,
|
||||||
|
pub dst_ip: IpAddr,
|
||||||
|
pub dst_port: u16,
|
||||||
|
pub iface_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SensorEvent {
|
||||||
|
Dns(ClassifiedEvent),
|
||||||
|
Tcp(TcpEvent),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct IfaceDiag {
|
pub struct IfaceDiag {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -88,7 +104,7 @@ pub fn iface_diagnostics() -> Result<Vec<IfaceDiag>, DnsLeakError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
fn capture_events_blocking(options: LeakWatchOptions) -> Result<Vec<ClassifiedEvent>, DnsLeakError> {
|
fn capture_events_blocking(options: LeakWatchOptions) -> Result<Vec<SensorEvent>, DnsLeakError> {
|
||||||
use pnet::packet::ethernet::{EtherTypes, EthernetPacket};
|
use pnet::packet::ethernet::{EtherTypes, EthernetPacket};
|
||||||
use pnet::packet::Packet;
|
use pnet::packet::Packet;
|
||||||
|
|
||||||
@@ -137,19 +153,38 @@ fn capture_events_blocking(options: LeakWatchOptions) -> Result<Vec<ClassifiedEv
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(event) = event {
|
if let Some(event) = event {
|
||||||
let key = format!(
|
let key = match &event {
|
||||||
"{:?}|{}|{}|{}|{}",
|
SensorEvent::Dns(value) => format!(
|
||||||
event.transport, event.src_ip, event.src_port, event.dst_ip, event.dst_port
|
"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) {
|
if seen.insert(key) {
|
||||||
|
match &event {
|
||||||
|
SensorEvent::Dns(value) => {
|
||||||
debug!(
|
debug!(
|
||||||
transport = ?event.transport,
|
transport = ?value.transport,
|
||||||
src_ip = %event.src_ip,
|
src_ip = %value.src_ip,
|
||||||
src_port = event.src_port,
|
src_port = value.src_port,
|
||||||
dst_ip = %event.dst_ip,
|
dst_ip = %value.dst_ip,
|
||||||
dst_port = event.dst_port,
|
dst_port = value.dst_port,
|
||||||
"dns leak event"
|
"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);
|
events.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,28 +198,19 @@ fn parse_ipv4(
|
|||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
local_ips: &[IpAddr],
|
local_ips: &[IpAddr],
|
||||||
iface_name: &str,
|
iface_name: &str,
|
||||||
) -> Option<ClassifiedEvent> {
|
) -> Option<SensorEvent> {
|
||||||
use pnet::packet::ip::IpNextHeaderProtocols;
|
use pnet::packet::ip::IpNextHeaderProtocols;
|
||||||
use pnet::packet::ipv4::Ipv4Packet;
|
use pnet::packet::ipv4::Ipv4Packet;
|
||||||
use pnet::packet::Packet;
|
use pnet::packet::Packet;
|
||||||
let ipv4 = Ipv4Packet::new(payload)?;
|
let ipv4 = Ipv4Packet::new(payload)?;
|
||||||
let src = IpAddr::V4(ipv4.get_source());
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
match ipv4.get_next_level_protocol() {
|
match ipv4.get_next_level_protocol() {
|
||||||
IpNextHeaderProtocols::Udp => parse_udp(
|
IpNextHeaderProtocols::Udp => parse_udp(src, dst, ipv4.payload(), iface_name),
|
||||||
src,
|
IpNextHeaderProtocols::Tcp => parse_tcp(src, dst, ipv4.payload(), iface_name),
|
||||||
IpAddr::V4(ipv4.get_destination()),
|
|
||||||
ipv4.payload(),
|
|
||||||
iface_name,
|
|
||||||
),
|
|
||||||
IpNextHeaderProtocols::Tcp => parse_tcp(
|
|
||||||
src,
|
|
||||||
IpAddr::V4(ipv4.get_destination()),
|
|
||||||
ipv4.payload(),
|
|
||||||
iface_name,
|
|
||||||
),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,28 +220,19 @@ fn parse_ipv6(
|
|||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
local_ips: &[IpAddr],
|
local_ips: &[IpAddr],
|
||||||
iface_name: &str,
|
iface_name: &str,
|
||||||
) -> Option<ClassifiedEvent> {
|
) -> Option<SensorEvent> {
|
||||||
use pnet::packet::ip::IpNextHeaderProtocols;
|
use pnet::packet::ip::IpNextHeaderProtocols;
|
||||||
use pnet::packet::ipv6::Ipv6Packet;
|
use pnet::packet::ipv6::Ipv6Packet;
|
||||||
use pnet::packet::Packet;
|
use pnet::packet::Packet;
|
||||||
let ipv6 = Ipv6Packet::new(payload)?;
|
let ipv6 = Ipv6Packet::new(payload)?;
|
||||||
let src = IpAddr::V6(ipv6.get_source());
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
match ipv6.get_next_header() {
|
match ipv6.get_next_header() {
|
||||||
IpNextHeaderProtocols::Udp => parse_udp(
|
IpNextHeaderProtocols::Udp => parse_udp(src, dst, ipv6.payload(), iface_name),
|
||||||
src,
|
IpNextHeaderProtocols::Tcp => parse_tcp(src, dst, ipv6.payload(), iface_name),
|
||||||
IpAddr::V6(ipv6.get_destination()),
|
|
||||||
ipv6.payload(),
|
|
||||||
iface_name,
|
|
||||||
),
|
|
||||||
IpNextHeaderProtocols::Tcp => parse_tcp(
|
|
||||||
src,
|
|
||||||
IpAddr::V6(ipv6.get_destination()),
|
|
||||||
ipv6.payload(),
|
|
||||||
iface_name,
|
|
||||||
),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,28 +243,31 @@ fn parse_udp(
|
|||||||
dst_ip: IpAddr,
|
dst_ip: IpAddr,
|
||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
iface_name: &str,
|
iface_name: &str,
|
||||||
) -> Option<ClassifiedEvent> {
|
) -> Option<SensorEvent> {
|
||||||
use pnet::packet::udp::UdpPacket;
|
use pnet::packet::udp::UdpPacket;
|
||||||
use pnet::packet::Packet;
|
use pnet::packet::Packet;
|
||||||
let udp = UdpPacket::new(payload)?;
|
let udp = UdpPacket::new(payload)?;
|
||||||
|
let src_port = udp.get_source();
|
||||||
let dst_port = udp.get_destination();
|
let dst_port = udp.get_destination();
|
||||||
if dst_port != 53 {
|
if src_port != 53 && dst_port != 53 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (qname, qtype, rcode) = classify_dns_query(udp.payload())?;
|
let parsed = parse_dns_message(udp.payload())?;
|
||||||
Some(ClassifiedEvent {
|
Some(SensorEvent::Dns(ClassifiedEvent {
|
||||||
timestamp_ms: now_ms(),
|
timestamp_ms: now_ms(),
|
||||||
proto: FlowProtocol::Udp,
|
proto: FlowProtocol::Udp,
|
||||||
src_ip,
|
src_ip,
|
||||||
src_port: udp.get_source(),
|
src_port,
|
||||||
dst_ip,
|
dst_ip,
|
||||||
dst_port,
|
dst_port,
|
||||||
iface_name: Some(iface_name.to_string()),
|
iface_name: Some(iface_name.to_string()),
|
||||||
transport: LeakTransport::Udp53,
|
transport: LeakTransport::Udp53,
|
||||||
qname: Some(qname),
|
qname: Some(parsed.qname),
|
||||||
qtype: Some(qtype),
|
qtype: Some(parsed.qtype),
|
||||||
rcode: Some(rcode),
|
rcode: Some(parsed.rcode),
|
||||||
})
|
is_response: parsed.is_response,
|
||||||
|
answer_ips: parsed.answer_ips,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
@@ -256,20 +276,36 @@ fn parse_tcp(
|
|||||||
dst_ip: IpAddr,
|
dst_ip: IpAddr,
|
||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
iface_name: &str,
|
iface_name: &str,
|
||||||
) -> Option<ClassifiedEvent> {
|
) -> Option<SensorEvent> {
|
||||||
use pnet::packet::tcp::TcpPacket;
|
use pnet::packet::tcp::TcpPacket;
|
||||||
let tcp = TcpPacket::new(payload)?;
|
let tcp = TcpPacket::new(payload)?;
|
||||||
let dst_port = tcp.get_destination();
|
let dst_port = tcp.get_destination();
|
||||||
|
let src_port = tcp.get_source();
|
||||||
let transport = match dst_port {
|
let transport = match dst_port {
|
||||||
53 => LeakTransport::Tcp53,
|
53 => LeakTransport::Tcp53,
|
||||||
853 => LeakTransport::Dot,
|
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(),
|
timestamp_ms: now_ms(),
|
||||||
proto: FlowProtocol::Tcp,
|
proto: FlowProtocol::Tcp,
|
||||||
src_ip,
|
src_ip,
|
||||||
src_port: tcp.get_source(),
|
src_port,
|
||||||
dst_ip,
|
dst_ip,
|
||||||
dst_port,
|
dst_port,
|
||||||
iface_name: Some(iface_name.to_string()),
|
iface_name: Some(iface_name.to_string()),
|
||||||
@@ -277,7 +313,9 @@ fn parse_tcp(
|
|||||||
qname: None,
|
qname: None,
|
||||||
qtype: None,
|
qtype: None,
|
||||||
rcode: None,
|
rcode: None,
|
||||||
})
|
is_response: false,
|
||||||
|
answer_ips: Vec::new(),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pcap")]
|
#[cfg(feature = "pcap")]
|
||||||
|
|||||||
@@ -30,4 +30,4 @@ This is a practical checklist to execute v0.4.0.
|
|||||||
|
|
||||||
## 5) follow-ups
|
## 5) follow-ups
|
||||||
- [ ] add DoH heuristic classification (optional)
|
- [ ] add DoH heuristic classification (optional)
|
||||||
- [ ] add Leak-D mismatch correlation (optional)
|
- [x] add Leak-D mismatch correlation (optional)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ This document tracks the current DNS leak detector implementation against the de
|
|||||||
- Leak-A (plaintext DNS outside safe path).
|
- Leak-A (plaintext DNS outside safe path).
|
||||||
- Leak-B (split-policy intent leak based on proxy-required/allowlist domains).
|
- Leak-B (split-policy intent leak based on proxy-required/allowlist domains).
|
||||||
- Leak-C (encrypted DNS bypass for DoT).
|
- 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`.
|
- Policy profiles: `full-tunnel`, `proxy-stub`, `split`.
|
||||||
- Privacy modes: full/redacted/minimal (redacts qname).
|
- Privacy modes: full/redacted/minimal (redacts qname).
|
||||||
- Process attribution:
|
- Process attribution:
|
||||||
@@ -37,10 +38,16 @@ This document tracks the current DNS leak detector implementation against the de
|
|||||||
|
|
||||||
## Not implemented (v0.4 backlog)
|
## Not implemented (v0.4 backlog)
|
||||||
- DoH heuristic detection (SNI/endpoint list/traffic shape).
|
- DoH heuristic detection (SNI/endpoint list/traffic shape).
|
||||||
- Leak-D mismatch correlation (DNS -> TCP/TLS flows).
|
|
||||||
- GeoIP enrichment of leak events.
|
- GeoIP enrichment of leak events.
|
||||||
- Process tree reporting (PPID chain).
|
- Process tree reporting (PPID chain).
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
- On Windows, pcap capture may require selecting a specific NPF interface; use
|
- On Windows, pcap capture may require selecting a specific NPF interface; use
|
||||||
`dns leak watch --iface-diag` to list interfaces that can be opened.
|
`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 <NPF>` captured UDP/53 and produced Leak-A.
|
||||||
|
- `dns leak watch --duration 15s --iface <NPF>` 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user