Skip to main content

The DNS Poisoning Problem

Normal DNS Resolution

When you visit discord.com, your system queries your ISP’s DNS server:
Client → [ISP DNS] "What is discord.com?" 
       ← [ISP DNS] "162.159.130.234"
For blocked sites, Turkish ISPs return fake IPs:
Client → [ISP DNS] "What is twitter.com?"
       ← [ISP DNS] "195.175.254.2"  ← Fake IP (redirect page)

Why DNS Poisoning?

  1. Simple to implement: Just modify DNS server responses
  2. Affects all protocols: HTTP, HTTPS, apps, games
  3. User-visible: Browsers show ISP redirect pages
  4. Plausible deniability: “DNS server error”
DNS poisoning complements DPI blocking. Even if you bypass DPI, poisoned DNS will send you to the wrong IP address.

DNS-over-HTTPS (DoH) Solution

What is DoH?

DoH encapsulates DNS queries inside HTTPS requests, making them:
  • Encrypted: ISP can’t read or modify queries
  • Authenticated: Server identity verified via TLS
  • Indistinguishable: Looks like regular HTTPS traffic
Normal DNS:     Client → [UDP port 53, plaintext] → ISP DNS
                          ↑ ISP can read & modify

DNS-over-HTTPS: Client → [HTTPS POST to 1.1.1.1/dns-query] → Cloudflare
                          ↑ ISP sees encrypted HTTPS

TurkeyDPI DoH Resolver

Architecture

// From engine/src/dns.rs:6-23
pub struct DohResolver {
    cache: RwLock<HashMap<String, (Vec<IpAddr>, Instant)>>,
    ttl: Duration,
}

impl DohResolver {
    pub fn new() -> Self {
        Self {
            cache: RwLock::new(HashMap::new()),
            ttl: Duration::from_secs(300),  // 5 minute cache
        }
    }
}

Key Components

cache: RwLock<HashMap<String, (Vec<IpAddr>, Instant)>>
  • Key: Hostname (e.g., “discord.com”)
  • Value: (IPs, expiry_time)
  • Thread-safe: RwLock allows concurrent reads
  • TTL: 5 minutes (configurable)
Why cache?
  • Avoid repeated DoH queries
  • Reduce latency for repeated lookups
  • Minimize external requests
// From engine/src/dns.rs:32-36
let providers = [
    ("1.1.1.1", "/dns-query"),      // Cloudflare
    ("8.8.8.8", "/resolve"),         // Google
    ("9.9.9.9", "/dns-query"),       // Quad9
];
Fallback strategy: Try providers sequentially until one succeeds.Why multiple?
  • Redundancy if one is blocked/down
  • Geographic diversity
  • Different censorship resistance

Resolution Flow

Main Resolution Function

// From engine/src/dns.rs:25-52
pub async fn resolve(&self, hostname: &str) -> std::io::Result<Vec<IpAddr>> {
    // Step 1: Check cache
    if let Some(ips) = self.get_cached(hostname) {
        return Ok(ips);
    }

    // Step 2: Try DoH providers
    let providers = [
        ("1.1.1.1", "/dns-query"),
        ("8.8.8.8", "/resolve"),
        ("9.9.9.9", "/dns-query"),
    ];

    for (server, path) in providers {
        match self.doh_query(server, path, hostname).await {
            Ok(ips) if !ips.is_empty() => {
                self.cache_result(hostname, &ips);
                return Ok(ips);
            }
            _ => continue,  // Try next provider
        }
    }

    // Step 3: All providers failed
    Err(std::io::Error::new(
        std::io::ErrorKind::NotFound,
        format!("Failed to resolve {} via DoH", hostname),
    ))
}

Step-by-Step Resolution

// From engine/src/dns.rs:84-92
fn get_cached(&self, hostname: &str) -> Option<Vec<IpAddr>> {
    let cache = self.cache.read().ok()?;
    let (ips, expiry) = cache.get(hostname)?;
    if Instant::now() < *expiry {
        Some(ips.clone())
    } else {
        None  // Expired
    }
}
Fast path: If hostname was recently resolved and TTL hasn’t expired, return cached IPs immediately.

DoH Providers

Cloudflare (1.1.1.1)

Primary provider. Query format:
GET /dns-query?name=discord.com&type=A HTTP/1.1
Host: 1.1.1.1
Accept: application/dns-json
Response:
{
  "Status": 0,
  "Answer": [
    {
      "name": "discord.com",
      "type": 1,
      "TTL": 300,
      "data": "162.159.130.234"
    },
    {
      "name": "discord.com",
      "type": 1,
      "TTL": 300,
      "data": "162.159.129.234"
    }
  ]
}
Why Cloudflare?
  • Fast (anycast network)
  • Privacy-focused (1.1.1.1 doesn’t log queries)
  • Reliable (99.99% uptime)
  • Not blocked in Turkey (as of 2026)

Google (8.8.8.8)

Fallback provider. Different endpoint:
GET /resolve?name=discord.com&type=A HTTP/1.1
Host: 8.8.8.8
Response format:
{
  "Status": 0,
  "Answer": [
    {
      "name": "discord.com.",
      "type": 1,
      "TTL": 60,
      "data": "162.159.130.234"
    }
  ]
}

Quad9 (9.9.9.9)

Second fallback. Same format as Cloudflare:
GET /dns-query?name=discord.com&type=A HTTP/1.1
Host: 9.9.9.9
Try in order:
  1. Cloudflare (fastest, most privacy-respecting)
  2. Google (if Cloudflare fails)
  3. Quad9 (if both fail)
First successful response wins. No parallel queries to minimize latency and respect provider resources.

Advanced Resolution

Host:Port Resolution

Convenience method for resolving “hostname:port” strings:
// From engine/src/dns.rs:54-82
pub async fn resolve_host_port(&self, host_port: &str) 
        -> std::io::Result<SocketAddr> {
    // Parse "host:port"
    let (host, port) = if let Some(idx) = host_port.rfind(':') {
        let port: u16 = host_port[idx + 1..].parse()
            .map_err(|_| std::io::Error::new(
                std::io::ErrorKind::InvalidInput, 
                "Invalid port"
            ))?;
        (&host_port[..idx], port)
    } else {
        (host_port, 443)  // Default to HTTPS port
    };

    // Check if already an IP
    if let Ok(ip) = host.parse::<IpAddr>() {
        return Ok(SocketAddr::new(ip, port));
    }

    // Resolve hostname
    let ips = self.resolve(host).await?;
    
    // Prefer IPv4
    let ip = ips.iter()
        .find(|ip| ip.is_ipv4())
        .or(ips.first())
        .ok_or_else(|| std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "No IP addresses returned",
        ))?;

    Ok(SocketAddr::new(*ip, port))
}
Features:
  • Parses “host:port” format
  • Defaults to port 443 if not specified
  • Handles raw IPs (no resolution needed)
  • Prefers IPv4 over IPv6 (better compatibility)

IPv4 Preference

let ip = ips.iter()
    .find(|ip| ip.is_ipv4())   // Try to find IPv4
    .or(ips.first())            // Fall back to first IP (might be IPv6)
    .ok_or_else(|| ...)?;
Why prefer IPv4? Many Turkish ISPs have incomplete IPv6 deployments. IPv4 is more reliable for bypass purposes.

Timeouts and Error Handling

Connection Timeouts

// From engine/src/dns.rs:110-115
let stream = tokio::time::timeout(
    Duration::from_secs(5),
    TcpStream::connect(addr)
).await
    .map_err(|_| std::io::Error::new(
        std::io::ErrorKind::TimedOut, 
        "DoH connect timeout"
    ))??
    .map_err(|e| std::io::Error::new(
        std::io::ErrorKind::ConnectionRefused, 
        e
    ))?;
5-second timeout for TCP connection. Prevents hanging on unreachable providers.

TLS Handshake Timeouts

// From engine/src/dns.rs:123-128
let mut tls_stream = tokio::time::timeout(
    Duration::from_secs(5),
    connector.connect(server, stream)
).await
    .map_err(|_| std::io::Error::new(
        std::io::ErrorKind::TimedOut, 
        "TLS timeout"
    ))??
    .map_err(|e| std::io::Error::new(
        std::io::ErrorKind::Other, 
        e
    ))?;
Another 5-second timeout for TLS handshake. Total worst-case time per provider: 10 seconds (5s connect + 5s TLS)
Total worst-case with 3 providers: 30 seconds before giving up
DoH is critical for bypass. We prefer to wait longer rather than fail prematurely.In practice:
  • Cloudflare typically responds in <500ms
  • Timeouts rarely trigger
  • Cache prevents repeated slow queries

Caching Strategy

Cache Invalidation

// From engine/src/dns.rs:84-92
fn get_cached(&self, hostname: &str) -> Option<Vec<IpAddr>> {
    let cache = self.cache.read().ok()?;
    let (ips, expiry) = cache.get(hostname)?;
    if Instant::now() < *expiry {  // ← Time-based invalidation
        Some(ips.clone())
    } else {
        None  // Expired entry
    }
}
Time-based TTL: Entries expire after 5 minutes regardless of DNS TTL in response. Why not use DNS TTL?
  • Simplicity: Fixed 5-minute window
  • Censorship resistance: Don’t trust ISP-influenced TTLs
  • Balance: Not too short (performance) or too long (stale data)

Cache Concurrency

cache: RwLock<HashMap<String, (Vec<IpAddr>, Instant)>>
RwLock allows:
  • Multiple concurrent readers: Many threads can check cache simultaneously
  • Exclusive writer: Only one thread can update at a time
Read-heavy workload optimization: Cache lookups are much more common than updates.

Response Parsing

JSON Extraction

The parser is intentionally simple:
// From engine/src/dns.rs:151-172
fn parse_doh_response(&self, response: &str) -> std::io::Result<Vec<IpAddr>> {
    let body = response.split("\r\n\r\n").nth(1).unwrap_or("");
    let mut ips = Vec::new();
    
    // Find all "data":"<IP>" fields
    for part in body.split("\"data\"") {
        if let Some(start) = part.find(":\"") {
            let rest = &part[start + 2..];
            if let Some(end) = rest.find('"') {
                let ip_str = &rest[..end];
                if let Ok(ip) = ip_str.parse::<IpAddr>() {
                    ips.push(ip);
                }
            }
        }
    }

    Ok(ips)
}
Algorithm:
  1. Split HTTP headers from body at \r\n\r\n
  2. Find all occurrences of "data"
  3. Extract string between :" and "
  4. Parse as IP address
  5. Collect all valid IPs
Robustness: Even if JSON is malformed, this will extract valid IPs. Won’t be confused by extra fields or formatting.

Example Parsing

Input:
{"Status":0,"Answer":[{"name":"discord.com","type":1,"TTL":300,"data":"162.159.130.234"}]}
Steps:
  1. Split at "data"[... "162.159.130.234"}]}]
  2. Find :"162.159.130.234"}]}
  3. Find closing "162.159.130.234
  4. Parse as IP → 162.159.130.234

Testing

Unit Tests

// From engine/src/dns.rs:180-201
#[test]
fn test_parse_cloudflare_response() {
    let resolver = DohResolver::new();
    let response = r#"HTTP/1.1 200 OK
Content-Type: application/dns-json

{"Status":0,"Answer":[{"name":"discord.com","type":1,"TTL":300,"data":"162.159.130.234"},{"name":"discord.com","type":1,"TTL":300,"data":"162.159.129.234"}]}"#;
    
    let ips = resolver.parse_doh_response(response).unwrap();
    assert!(!ips.is_empty());
    assert!(ips.iter().any(|ip| ip.to_string().starts_with("162.159")));
}

#[test]
fn test_parse_google_response() {
    let resolver = DohResolver::new();
    let response = r#"HTTP/1.1 200 OK

{"Status":0,"Answer":[{"name":"discord.com.","type":1,"TTL":60,"data":"162.159.130.234"}]}"#;
    
    let ips = resolver.parse_doh_response(response).unwrap();
    assert!(!ips.is_empty());
}
Validates parser works with both Cloudflare and Google JSON formats.

Performance Characteristics

Latency Analysis

~1-10μs
  • HashMap lookup: O(1)
  • RwLock read acquisition: Fast (uncontended)
  • TTL check: Simple comparison
Negligible overhead.

Memory Usage

  • Cache entry: ~100 bytes (hostname + 2-3 IPs + timestamp)
  • Typical cache: 10-100 entries = 1-10KB
  • Max practical size: Unbounded, but TTL limits growth
Memory footprint: Negligible (<1MB even with thousands of domains)

Integration with Bypass Engine

The DoH resolver is used transparently by the proxy layer:
// Pseudocode from proxy/backend
let resolver = DohResolver::new();

// When connecting to blocked site
let target_addr = resolver.resolve_host_port("discord.com:443").await?;
let stream = TcpStream::connect(target_addr).await?;
// ... proceed with proxying ...
The application never uses system DNS for blocked domains.

Why DoH Defeats DNS Poisoning

Encryption

ISP cannot read the hostname in the DoH query:
System DNS: "What is twitter.com?" → Plaintext on wire → ISP sees & poisons
DoH:        [Encrypted HTTPS] → ISP sees only "traffic to 1.1.1.1" → Can't poison

Authentication

TLS verifies the DoH provider’s identity:
System DNS: No authentication → ISP can spoof responses
DoH:        TLS certificate for 1.1.1.1 → ISP can't impersonate Cloudflare

Centralization Resistance

Multiple fallback providers prevent single point of failure:
If Cloudflare blocked → Try Google
If Google blocked → Try Quad9
If all blocked → User can add custom DoH servers

Limitations and Trade-offs

Problem: DoH adds HTTPS overhead vs. UDP DNSMitigation:
  • Aggressive caching (5 min TTL)
  • Cache hit rate typically >95% after warmup
  • Only affects connection establishment, not data transfer
Problem: Must trust DoH provider with DNS queriesMitigation:
  • Use privacy-focused providers (Cloudflare doesn’t log)
  • Rotate providers
  • Future: Support for custom DoH servers
Problem: ISP could block 1.1.1.1, 8.8.8.8, etc.Mitigation:
  • Multiple providers
  • DoH traffic looks like normal HTTPS
  • Blocking these IPs would break many services

See Also

Build docs developers (and LLMs) love