Add: dns leak detection
This commit is contained in:
@@ -4,6 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
hickory-proto = "0.24"
|
||||
mdns-sd = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use hickory_proto::op::{Message, MessageType, Query};
|
||||
use hickory_proto::rr::{Name, RData, RecordType};
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::net::{IpAddr, SocketAddr, UdpSocket};
|
||||
use std::time::{Duration, Instant};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -24,6 +26,17 @@ pub struct SsdpOptions {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlmnrOptions {
|
||||
pub duration_ms: u64,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NbnsOptions {
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MdnsService {
|
||||
pub service_type: String,
|
||||
@@ -56,6 +69,34 @@ pub struct SsdpReport {
|
||||
pub services: Vec<SsdpService>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlmnrAnswer {
|
||||
pub from: String,
|
||||
pub name: String,
|
||||
pub record_type: String,
|
||||
pub data: String,
|
||||
pub ttl: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlmnrReport {
|
||||
pub duration_ms: u64,
|
||||
pub name: String,
|
||||
pub answers: Vec<LlmnrAnswer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NbnsNodeStatus {
|
||||
pub from: String,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NbnsReport {
|
||||
pub duration_ms: u64,
|
||||
pub nodes: Vec<NbnsNodeStatus>,
|
||||
}
|
||||
|
||||
pub async fn mdns_discover(options: MdnsOptions) -> Result<MdnsReport, DiscoverError> {
|
||||
tokio::task::spawn_blocking(move || mdns_discover_blocking(options))
|
||||
.await
|
||||
@@ -68,6 +109,18 @@ pub async fn ssdp_discover(options: SsdpOptions) -> Result<SsdpReport, DiscoverE
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn llmnr_discover(options: LlmnrOptions) -> Result<LlmnrReport, DiscoverError> {
|
||||
tokio::task::spawn_blocking(move || llmnr_discover_blocking(options))
|
||||
.await
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn nbns_discover(options: NbnsOptions) -> Result<NbnsReport, DiscoverError> {
|
||||
tokio::task::spawn_blocking(move || nbns_discover_blocking(options))
|
||||
.await
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?
|
||||
}
|
||||
|
||||
fn mdns_discover_blocking(options: MdnsOptions) -> Result<MdnsReport, DiscoverError> {
|
||||
let daemon = ServiceDaemon::new().map_err(|err| DiscoverError::Mdns(err.to_string()))?;
|
||||
let mut service_types = BTreeSet::new();
|
||||
@@ -174,6 +227,94 @@ fn ssdp_discover_blocking(options: SsdpOptions) -> Result<SsdpReport, DiscoverEr
|
||||
})
|
||||
}
|
||||
|
||||
fn llmnr_discover_blocking(options: LlmnrOptions) -> Result<LlmnrReport, DiscoverError> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
|
||||
let name = options
|
||||
.name
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "wpad".to_string());
|
||||
|
||||
let query = build_llmnr_query(&name)
|
||||
.map_err(|err| DiscoverError::Io(format!("llmnr build query: {err}")))?;
|
||||
let target = "224.0.0.252:5355";
|
||||
let _ = socket.send_to(&query, target);
|
||||
|
||||
let mut answers = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let deadline = Instant::now() + Duration::from_millis(options.duration_ms);
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
while Instant::now() < deadline {
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((len, from)) => {
|
||||
if let Some(entries) = parse_llmnr_response(&buf[..len], from.ip()) {
|
||||
for entry in entries {
|
||||
let key = format!(
|
||||
"{}|{}|{}|{}",
|
||||
entry.from, entry.name, entry.record_type, entry.data
|
||||
);
|
||||
if seen.insert(key) {
|
||||
answers.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LlmnrReport {
|
||||
duration_ms: options.duration_ms,
|
||||
name,
|
||||
answers,
|
||||
})
|
||||
}
|
||||
|
||||
fn nbns_discover_blocking(options: NbnsOptions) -> Result<NbnsReport, DiscoverError> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
socket
|
||||
.set_broadcast(true)
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(200)))
|
||||
.map_err(|err| DiscoverError::Io(err.to_string()))?;
|
||||
|
||||
let query = build_nbns_node_status_query();
|
||||
let _ = socket.send_to(&query, "255.255.255.255:137");
|
||||
|
||||
let mut nodes = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let deadline = Instant::now() + Duration::from_millis(options.duration_ms);
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
while Instant::now() < deadline {
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((len, from)) => {
|
||||
if let Some(names) = parse_nbns_node_status(&buf[..len]) {
|
||||
let key = format!("{}|{}", from.ip(), names.join(","));
|
||||
if seen.insert(key) {
|
||||
nodes.push(NbnsNodeStatus {
|
||||
from: from.ip().to_string(),
|
||||
names,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NbnsReport {
|
||||
duration_ms: options.duration_ms,
|
||||
nodes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_ssdp_response(payload: &str, from: SocketAddr) -> Option<SsdpService> {
|
||||
let mut st = None;
|
||||
let mut usn = None;
|
||||
@@ -207,3 +348,183 @@ fn parse_ssdp_response(payload: &str, from: SocketAddr) -> Option<SsdpService> {
|
||||
server,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_llmnr_query(name: &str) -> Result<Vec<u8>, String> {
|
||||
let name = Name::from_ascii(name).map_err(|err| format!("invalid name: {err}"))?;
|
||||
let mut message = Message::new();
|
||||
message
|
||||
.set_id(0)
|
||||
.set_message_type(MessageType::Query)
|
||||
.set_recursion_desired(false)
|
||||
.add_query(Query::query(name.clone(), RecordType::A))
|
||||
.add_query(Query::query(name, RecordType::AAAA));
|
||||
message.to_vec().map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn parse_llmnr_response(payload: &[u8], from: IpAddr) -> Option<Vec<LlmnrAnswer>> {
|
||||
let message = Message::from_vec(payload).ok()?;
|
||||
if message.message_type() != MessageType::Response {
|
||||
return None;
|
||||
}
|
||||
let mut answers = Vec::new();
|
||||
for record in message.answers() {
|
||||
let record_type = record.record_type();
|
||||
let data = match record.data() {
|
||||
Some(RData::A(addr)) => addr.to_string(),
|
||||
Some(RData::AAAA(addr)) => addr.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
answers.push(LlmnrAnswer {
|
||||
from: from.to_string(),
|
||||
name: record.name().to_string(),
|
||||
record_type: record_type.to_string(),
|
||||
data,
|
||||
ttl: record.ttl(),
|
||||
});
|
||||
}
|
||||
if answers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(answers)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_nbns_node_status_query() -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(50);
|
||||
let id = nbns_query_id();
|
||||
buf.extend_from_slice(&id.to_be_bytes());
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // flags
|
||||
buf.extend_from_slice(&1u16.to_be_bytes()); // qdcount
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // ancount
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // nscount
|
||||
buf.extend_from_slice(&0u16.to_be_bytes()); // arcount
|
||||
buf.extend_from_slice(&nbns_encode_name("*", 0x00));
|
||||
buf.extend_from_slice(&0x0021u16.to_be_bytes()); // NBSTAT
|
||||
buf.extend_from_slice(&0x0001u16.to_be_bytes()); // IN
|
||||
buf
|
||||
}
|
||||
|
||||
fn nbns_query_id() -> u16 {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.subsec_nanos();
|
||||
(nanos & 0xffff) as u16
|
||||
}
|
||||
|
||||
fn nbns_encode_name(name: &str, suffix: u8) -> Vec<u8> {
|
||||
let mut raw = [b' '; 16];
|
||||
let mut bytes = name.as_bytes().to_vec();
|
||||
for byte in bytes.iter_mut() {
|
||||
byte.make_ascii_uppercase();
|
||||
}
|
||||
for (idx, byte) in bytes.iter().take(15).enumerate() {
|
||||
raw[idx] = *byte;
|
||||
}
|
||||
raw[15] = suffix;
|
||||
|
||||
let mut encoded = Vec::with_capacity(34);
|
||||
encoded.push(32);
|
||||
for byte in raw {
|
||||
let high = ((byte >> 4) & 0x0f) + b'A';
|
||||
let low = (byte & 0x0f) + b'A';
|
||||
encoded.push(high);
|
||||
encoded.push(low);
|
||||
}
|
||||
encoded.push(0);
|
||||
encoded
|
||||
}
|
||||
|
||||
fn parse_nbns_node_status(payload: &[u8]) -> Option<Vec<String>> {
|
||||
if payload.len() < 12 {
|
||||
return None;
|
||||
}
|
||||
let flags = u16::from_be_bytes([payload[2], payload[3]]);
|
||||
if flags & 0x8000 == 0 {
|
||||
return None;
|
||||
}
|
||||
let qdcount = u16::from_be_bytes([payload[4], payload[5]]) as usize;
|
||||
let ancount = u16::from_be_bytes([payload[6], payload[7]]) as usize;
|
||||
let mut offset = 12;
|
||||
for _ in 0..qdcount {
|
||||
offset = skip_dns_name(payload, offset)?;
|
||||
if offset + 4 > payload.len() {
|
||||
return None;
|
||||
}
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
let mut names = Vec::new();
|
||||
for _ in 0..ancount {
|
||||
offset = skip_dns_name(payload, offset)?;
|
||||
if offset + 10 > payload.len() {
|
||||
return None;
|
||||
}
|
||||
let rr_type = u16::from_be_bytes([payload[offset], payload[offset + 1]]);
|
||||
let _rr_class = u16::from_be_bytes([payload[offset + 2], payload[offset + 3]]);
|
||||
let _ttl = u32::from_be_bytes([
|
||||
payload[offset + 4],
|
||||
payload[offset + 5],
|
||||
payload[offset + 6],
|
||||
payload[offset + 7],
|
||||
]);
|
||||
let rdlength = u16::from_be_bytes([payload[offset + 8], payload[offset + 9]]) as usize;
|
||||
offset += 10;
|
||||
if offset + rdlength > payload.len() {
|
||||
return None;
|
||||
}
|
||||
if rr_type == 0x0021 && rdlength > 0 {
|
||||
if let Some(list) = parse_nbns_name_list(&payload[offset..offset + rdlength]) {
|
||||
names.extend(list);
|
||||
}
|
||||
}
|
||||
offset += rdlength;
|
||||
}
|
||||
|
||||
if names.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(names)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_nbns_name_list(payload: &[u8]) -> Option<Vec<String>> {
|
||||
let count = *payload.first()? as usize;
|
||||
let mut offset = 1;
|
||||
let mut names = Vec::new();
|
||||
for _ in 0..count {
|
||||
if offset + 18 > payload.len() {
|
||||
return None;
|
||||
}
|
||||
let name_bytes = &payload[offset..offset + 15];
|
||||
let suffix = payload[offset + 15];
|
||||
let name = String::from_utf8_lossy(name_bytes)
|
||||
.trim_end()
|
||||
.to_string();
|
||||
names.push(format!("{name}<{suffix:02x}>"));
|
||||
offset += 18;
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
|
||||
fn skip_dns_name(payload: &[u8], mut offset: usize) -> Option<usize> {
|
||||
if offset >= payload.len() {
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
let len = *payload.get(offset)?;
|
||||
if len & 0xc0 == 0xc0 {
|
||||
if offset + 1 >= payload.len() {
|
||||
return None;
|
||||
}
|
||||
return Some(offset + 2);
|
||||
}
|
||||
if len == 0 {
|
||||
return Some(offset + 1);
|
||||
}
|
||||
offset += 1 + len as usize;
|
||||
if offset >= payload.len() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user