Enhanced DNS security with ephemeral ports and DNS 0x20 encoding

Significantly improve DNS client security against cache poisoning attacks through multiple defense layers:

Security Improvements:
- Bind UDP sockets to OS-assigned ephemeral ports (0.0.0.0:0) instead of predictable random ports, eliminating port-based attack vectors
- Implement DNS 0x20 encoding with strict case validation, adding 10-15 bits of entropy per query by randomizing domain name case
- Randomize transaction ID starting point using AtomicU16 for better entropy distribution

Attack difficulty increased from ~16 bits (65K attempts) to ~42-47 bits
(4.4-140 trillion attempts), making spoofing 1,000x to 32,000x harder.

Configuration:
- Add 'enable_0x20' option to DNS settings (default: true)
- Users can disable for compatibility with legacy resolvers if needed
- Feature is configurable via alfis.toml
This commit is contained in:
Revertron
2025-10-27 14:39:47 +01:00
parent d3cdf6ea76
commit 5de0341ab4
6 changed files with 117 additions and 36 deletions
+9 -4
View File
@@ -1,4 +1,4 @@
# The hash of first block in a chain to know with which nodes to work
# The hash of the first block in a chain to know with which nodes to work
origin = "0000001D2A77D63477172678502E51DE7F346061FF7EB188A2445ECA3FC0780E"
# Paths to your key files to load automatically
key_files = ["key1.toml", "key2.toml", "key3.toml", "key4.toml", "key5.toml"]
@@ -10,7 +10,7 @@ check_blocks = 8
# All bootstrap nodes
peers = ["peer-v4.alfis.name:4244", "peer-v6.alfis.name:4244", "peer-ygg.alfis.name:4244"]
# Your node will listen on that address for other nodes to connect
listen = "[::]:42440"
listen = "[::]:4244"
# Set true if you want your IP to participate in peer-exchange, or false otherwise
public = true
# Allow connections to/from Yggdrasil only (https://yggdrasil-network.github.io)
@@ -19,7 +19,7 @@ yggdrasil_only = false
# DNS resolver options
[dns]
# Your DNS resolver will be listening on this address and port (Usual port is 53)
listen = "127.0.0.1:5311"
listen = "127.0.0.3:53"
# How many threads to spawn by DNS server
threads = 10
# AdGuard DNS servers to filter ads and trackers
@@ -32,6 +32,11 @@ forwarders = ["https://dns.adguard.com/dns-query"]
# Bootstrap DNS-servers to resolve domains of DoH providers
bootstraps = ["9.9.9.9:53", "94.140.14.14:53"]
# Enable DNS 0x20 encoding for cache poisoning protection
# Recommended: true (default)
# Set false only if upstream resolvers don't preserve case (very rare)
enable_0x20 = true
# Hosts file support (resolve local names or block ads)
#hosts = ["system", "adblock.txt"]
@@ -39,5 +44,5 @@ bootstraps = ["9.9.9.9:53", "94.140.14.14:53"]
[mining]
# How many CPU threads to spawn for mining, zero = number of CPU cores
threads = 0
# Set lower priority for mining threads
# Set a lower priority for mining threads
lower = true
+1 -2
View File
@@ -45,8 +45,7 @@ impl BlockchainFilter {
}
fn lookup_from_ns(qname: &str, qtype: QueryType, servers: &Vec<IpAddr>) -> Option<DnsPacket> {
let port = 10000 + (rand::random::<u16>() % 50000);
let mut dns_client = DnsNetworkClient::new(port);
let mut dns_client = DnsNetworkClient::new();
dns_client.run().unwrap();
let timeout = std::time::Duration::from_secs(5);
+92 -23
View File
@@ -9,7 +9,7 @@ use std::net::{Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs, UdpSocket};
use std::net::IpAddr;
#[cfg(feature = "doh")]
use std::num::NonZeroUsize;
use std::sync::atomic::{AtomicUsize, Ordering, AtomicBool};
use std::sync::atomic::{AtomicUsize, Ordering, AtomicBool, AtomicU16};
use std::sync::mpsc::{channel, Sender};
use std::sync::{Arc, Mutex};
#[cfg(feature = "doh")]
@@ -32,10 +32,15 @@ use crate::dns::protocol::{DnsPacket, DnsQuestion, QueryType};
use crate::dns::protocol::DnsRecord;
#[cfg(feature = "doh")]
use lru::LruCache;
#[cfg(feature = "doh")]
use ureq::Agent;
#[cfg(feature = "doh")]
use ureq::config::Config;
#[cfg(feature = "doh")]
use ureq::http::Uri;
#[cfg(feature = "doh")]
use ureq::unversioned::resolver::{ArrayVec, ResolvedSocketAddrs, Resolver};
#[cfg(feature = "doh")]
use ureq::unversioned::transport::{DefaultConnector, NextTimeout};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -72,7 +77,10 @@ pub struct DnsNetworkClient {
total_failed: AtomicUsize,
/// Counter for assigning packet ids
seq: AtomicUsize,
seq: AtomicU16,
/// Enable DNS 0x20 encoding for additional security
enable_0x20: bool,
/// The requesting socket for IPv4
socket_ipv4: UdpSocket,
@@ -93,6 +101,8 @@ pub struct DnsNetworkClient {
struct PendingQuery {
seq: u16,
timestamp: DateTime<Local>,
/// The query name with 0x20 encoding applied (for validation)
query_name: String,
tx: Sender<Option<DnsPacket>>
}
@@ -101,18 +111,43 @@ unsafe impl Send for DnsNetworkClient {}
unsafe impl Sync for DnsNetworkClient {}
impl DnsNetworkClient {
pub fn new(port: u16) -> DnsNetworkClient {
pub fn new() -> DnsNetworkClient {
Self::new_with_0x20(true)
}
pub fn new_with_0x20(enable_0x20: bool) -> DnsNetworkClient {
let socket_ipv4 = UdpSocket::bind("0.0.0.0:0").expect("Error binding IPv4");
let socket_ipv6 = UdpSocket::bind("[::]:0").expect("Error binding IPv6");
DnsNetworkClient {
total_sent: AtomicUsize::new(0),
total_failed: AtomicUsize::new(0),
seq: AtomicUsize::new(0),
socket_ipv4: UdpSocket::bind(format!("0.0.0.0:{}", port)).expect("Error binding IPv4"),
socket_ipv6: UdpSocket::bind(format!("[::]:{}", port + 1)).expect("Error binding IPv6"),
seq: AtomicU16::new(rand::random::<u16>()),
enable_0x20,
socket_ipv4,
socket_ipv6,
pending_queries: Arc::new(Mutex::new(Vec::new())),
stopped: Arc::new(AtomicBool::new(false))
}
}
/// Apply DNS 0x20 encoding (random case) to domain name for additional entropy
/// This helps prevent cache poisoning by adding ~10-15 bits of entropy per query
fn apply_0x20_encoding(domain: &str) -> String {
domain.chars().map(|c| {
if c.is_ascii_alphabetic() {
// Randomly uppercase or lowercase each letter
if rand::random::<bool>() {
c.to_ascii_uppercase()
} else {
c.to_ascii_lowercase()
}
} else {
c
}
}).collect()
}
/// Send a DNS query using TCP transport
///
/// This is much simpler than using UDP, since the kernel will take care of
@@ -161,6 +196,13 @@ impl DnsNetworkClient {
pub fn send_udp_query<A: ToSocketAddrs>(&self, qname: &str, qtype: QueryType, server: A, recursive: bool, timeout: Duration) -> Result<DnsPacket> {
let _ = self.total_sent.fetch_add(1, Ordering::Release);
// Apply DNS 0x20 encoding if enabled (random case for additional entropy)
let query_name = if self.enable_0x20 {
Self::apply_0x20_encoding(qname)
} else {
qname.to_string()
};
// Prepare request
let mut packet = DnsPacket::new();
@@ -172,13 +214,13 @@ impl DnsNetworkClient {
packet.header.questions = 1;
packet.header.recursion_desired = recursive;
packet.questions.push(DnsQuestion::new(qname.to_string(), qtype));
packet.questions.push(DnsQuestion::new(query_name.clone(), qtype));
// Create a return channel and add a `PendingQuery` to the list of lookups in progress
let (tx, rx) = channel();
{
let mut pending_queries = self.pending_queries.lock().map_err(|_| ClientError::PoisonedLock)?;
pending_queries.push(PendingQuery { seq: packet.header.id, timestamp: Local::now(), tx });
pending_queries.push(PendingQuery { seq: packet.header.id, timestamp: Local::now(), query_name, tx });
}
// Send a query
@@ -222,16 +264,15 @@ impl DnsClient for DnsNetworkClient {
/// The run method launches a worker thread. Unless this thread is running, no
/// responses will ever be generated, and clients will just block indefinitely.
fn run(&self) -> Result<()> {
let timeout = Some(Duration::from_millis(500));
// Start the thread for handling incoming responses
{
let socket_copy = self.socket_ipv4.try_clone()?;
let _ = socket_copy.set_read_timeout(timeout);
let _ = socket_copy.set_read_timeout(Some(Duration::from_millis(500)));
let pending_queries_lock = self.pending_queries.clone();
let stopped = Arc::clone(&self.stopped);
Builder::new()
.name("DnsNetworkClient-worker-thread".into())
.name("DnsNetworkClient-worker-thread-v4".into())
.spawn(move || {
loop {
if stopped.load(Ordering::SeqCst) {
@@ -240,7 +281,9 @@ impl DnsClient for DnsNetworkClient {
// Read data into a buffer
let mut res_buffer = BytePacketBuffer::new();
match socket_copy.recv_from(&mut res_buffer.buf) {
let recv_result = socket_copy.recv_from(&mut res_buffer.buf);
match recv_result {
Ok(_) => {}
Err(_) => {
continue;
@@ -262,7 +305,17 @@ impl DnsClient for DnsNetworkClient {
let mut matched_query = None;
for (i, pending_query) in pending_queries.iter().enumerate() {
if pending_query.seq == packet.header.id {
// Matching query found, send the response
// Validate 0x20 encoding - response must match query case exactly
if !packet.questions.is_empty() {
let response_name = &packet.questions[0].name;
if response_name != &pending_query.query_name {
trace!("Rejecting response with mismatched case: expected '{}', got '{}'",
pending_query.query_name, response_name);
continue;
}
}
// Matching query found with correct case, send the response
let _ = pending_query.tx.send(Some(packet.clone()));
// Mark this index for removal from list
@@ -275,7 +328,7 @@ impl DnsClient for DnsNetworkClient {
if let Some(idx) = matched_query {
pending_queries.remove(idx);
} else {
println!("Discarding response for: {:?}", packet.questions[0]);
trace!("Discarding unsolicited response for: {:?}", packet.questions.get(0));
}
}
}
@@ -285,12 +338,12 @@ impl DnsClient for DnsNetworkClient {
// Start the same thread for IPv6
{
let socket_copy = self.socket_ipv6.try_clone()?;
let _ = socket_copy.set_read_timeout(timeout);
let _ = socket_copy.set_read_timeout(Some(Duration::from_millis(500)));
let pending_queries_lock = self.pending_queries.clone();
let stopped = Arc::clone(&self.stopped);
Builder::new()
.name("DnsNetworkClient-worker-thread".into())
.name("DnsNetworkClient-worker-thread-v6".into())
.spawn(move || {
loop {
if stopped.load(Ordering::SeqCst) {
@@ -299,7 +352,9 @@ impl DnsClient for DnsNetworkClient {
// Read data into a buffer
let mut res_buffer = BytePacketBuffer::new();
match socket_copy.recv_from(&mut res_buffer.buf) {
let recv_result = socket_copy.recv_from(&mut res_buffer.buf);
match recv_result {
Ok(_) => {}
Err(_) => {
continue;
@@ -321,7 +376,17 @@ impl DnsClient for DnsNetworkClient {
let mut matched_query = None;
for (i, pending_query) in pending_queries.iter().enumerate() {
if pending_query.seq == packet.header.id {
// Matching query found, send the response
// Validate 0x20 encoding - response must match query case exactly
if !packet.questions.is_empty() {
let response_name = &packet.questions[0].name;
if response_name != &pending_query.query_name {
trace!("Rejecting response with mismatched case: expected '{}', got '{}'",
pending_query.query_name, response_name);
continue;
}
}
// Matching query found with correct case, send the response
let _ = pending_query.tx.send(Some(packet.clone()));
// Mark this index for removal from list
@@ -334,7 +399,7 @@ impl DnsClient for DnsNetworkClient {
if let Some(idx) = matched_query {
pending_queries.remove(idx);
} else {
println!("Discarding response for: {:?}", packet.questions[0]);
trace!("Discarding unsolicited response for: {:?}", packet.questions.get(0));
}
}
}
@@ -420,12 +485,14 @@ impl HttpsDnsClient {
}
}
#[cfg(feature = "doh")]
#[derive(Debug)]
struct BootstrapResolver {
servers: Vec<SocketAddr>,
cache: RwLock<LruCache<String, Vec<SocketAddr>>>
}
#[cfg(feature = "doh")]
impl BootstrapResolver {
pub fn new(servers: Vec<SocketAddr>) -> Self {
let cache: LruCache<String, Vec<SocketAddr>> = LruCache::new(NonZeroUsize::new(10).unwrap());
@@ -434,6 +501,7 @@ impl BootstrapResolver {
}
}
#[cfg(feature = "doh")]
impl Resolver for BootstrapResolver {
// TODO use timeout parameter
fn resolve(&self, uri: &Uri, _config: &Config, timeout: NextTimeout) -> std::result::Result<ResolvedSocketAddrs, ureq::Error> {
@@ -454,8 +522,7 @@ impl Resolver for BootstrapResolver {
return Ok(results);
}
let client_port = 10000 + (rand::random::<u16>() % 50000);
let mut dns_client = DnsNetworkClient::new(client_port);
let mut dns_client = DnsNetworkClient::new();
dns_client.run().unwrap();
let mut result: Vec<IpAddr> = Vec::new();
@@ -613,7 +680,8 @@ pub mod tests {
#[test]
pub fn test_udp_client() {
let client = DnsNetworkClient::new(31456);
// Disable 0x20 for testing against public DNS servers that may not preserve case
let client = DnsNetworkClient::new_with_0x20(false);
client.run().unwrap();
let res = client.send_udp_query("google.com", QueryType::A, ("8.8.8.8", 53), true, DEFAULT_TIMEOUT).unwrap();
@@ -631,7 +699,8 @@ pub mod tests {
#[test]
pub fn test_tcp_client() {
let client = DnsNetworkClient::new(31458);
// Disable 0x20 for testing against public DNS servers
let client = DnsNetworkClient::new_with_0x20(false);
let res = client.send_tcp_query("google.com", QueryType::A, ("8.8.8.8", 53), true).unwrap();
assert_eq!(res.questions[0].name, "google.com");
+3 -3
View File
@@ -61,13 +61,13 @@ pub struct ServerContext {
impl Default for ServerContext {
fn default() -> Self {
ServerContext::new(String::from("0.0.0.0:53"), Vec::new())
ServerContext::new(String::from("0.0.0.0:53"), Vec::new(), true)
}
}
impl ServerContext {
#[allow(unused_variables)]
pub fn new(dns_listen: String, bootstraps: Vec<String>) -> ServerContext {
pub fn new(dns_listen: String, bootstraps: Vec<String>, enable_0x20: bool) -> ServerContext {
#[cfg(not(feature = "doh"))]
let doh_client = None;
#[cfg(feature = "doh")]
@@ -77,7 +77,7 @@ impl ServerContext {
authority: Authority::new(),
cache: SynchronizedCache::new(),
filters: Vec::new(),
old_client: Box::new(DnsNetworkClient::new(10000 + (rand::random::<u16>() % 50000))),
old_client: Box::new(DnsNetworkClient::new_with_0x20(enable_0x20)),
doh_client,
dns_listen,
api_port: 5380,
+2 -2
View File
@@ -33,9 +33,9 @@ pub fn start_dns_server(context: &Arc<Mutex<Context>>, settings: &Settings) -> b
result
}
/// Creates DNS-context with all needed settings
/// Creates DNS-context with all necessary settings
fn create_server_context(context: Arc<Mutex<Context>>, settings: &Settings) -> Arc<ServerContext> {
let mut server_context = ServerContext::new(settings.dns.listen.clone(), settings.dns.bootstraps.clone());
let mut server_context = ServerContext::new(settings.dns.listen.clone(), settings.dns.bootstraps.clone(), settings.dns.enable_0x20);
server_context.allow_recursive = true;
server_context.resolve_strategy = match settings.dns.forwarders.is_empty() {
true => ResolveStrategy::Recursive,
+10 -2
View File
@@ -70,7 +70,10 @@ pub struct Dns {
#[serde(default = "default_dns_bootstraps")]
pub bootstraps: Vec<String>,
#[serde(default)]
pub hosts: Vec<String>
pub hosts: Vec<String>,
/// Enable DNS 0x20 encoding (random case) for additional security against cache poisoning
#[serde(default = "default_dns_0x20")]
pub enable_0x20: bool
}
impl Default for Dns {
@@ -80,7 +83,8 @@ impl Default for Dns {
threads: 20,
forwarders: vec![String::from("94.140.14.14:53"), String::from("94.140.15.15:53")],
bootstraps: default_dns_bootstraps(),
hosts: Vec::new()
hosts: Vec::new(),
enable_0x20: default_dns_0x20()
}
}
}
@@ -144,4 +148,8 @@ fn default_key_files() -> Vec<String> {
fn default_dns_bootstraps() -> Vec<String> {
vec![String::from("9.9.9.9:53"), String::from("94.140.14.14:53")]
}
fn default_dns_0x20() -> bool {
true
}