9.2 KiB
doh-forwarder vs Intra — Architectural Comparison
Overview
This document compares doh-forwarder (this project) with Intra by Jigsaw (Google) to understand why Intra can unblock YouTube and other censored sites while doh-forwarder can only resolve their DNS.
The Core Problem
DNS resolution is only the first layer of censorship. Even when DNS is encrypted and returns the correct IP, the actual HTTPS connection to the target server can be blocked by Deep Packet Inspection (DPI) at the TCP/TLS level.
doh-forwarder flow:
Browser → "youtube.com?" → doh-forwarder → DoH → returns IP ✅
Browser → HTTPS to YouTube IP:443 ──────────── BLOCKED by DPI ❌
Intra flow:
Browser → HTTPS to YouTube IP:443 → TUN → Intra split/retry → YouTube ✅
Architectural Differences
doh-forwarder — DNS-only forwarder
+---------------------+
| DNS Client |
| (browser, app, OS) |
+--------+------------+
|
+------------+-------------+
| |
HTTPS (POST/GET) UDP/TCP (port 53)
| |
v v
+-------------------+ +-------------------+
| Axum HTTP Server | | DnsListener |
| (port 8800) | | (UDP + TCP) |
+--------+----------+ +--------+----------+
| |
+-----------+-------------+
|
v
+-------------------+
| DohClient |
| (reqwest HTTP) |
+--------+----------+
|
+-----------+-----------+
| | |
v v v
Cloudflare Google Quad9
(DoH) (DoH) (DoH)
- Accepts DNS queries on port 53 (UDP/TCP) and port 8800 (DoH)
- Forwards queries to upstream DoH resolvers over HTTPS
- Returns DNS responses to clients
- Does not touch any non-DNS traffic
Intra — Full traffic interception + circumvention
+-----------------------------------------------------+
| TUN Device |
| (captures ALL device traffic) |
+---------------------------+--------------------------+
|
+---------------+----------------+
| |
DNS packets (port 53) TCP packets (port 443)
| |
v v
+------------------+ +------------------------+
| DNS Interception | | intraStreamDialer |
| → DoH query via | | → DialWithSplitRetry() |
| HTTP POST | | → TLS hello splitting |
+------------------+ +------------------------+
|
+--------+--------+
| |
Success? Blocked?
| |
v v
Normal TLS Close + retry with
handshake split Client Hello
- Creates a TUN device that captures all device traffic
- Intercepts DNS packets → sends via DoH (like doh-forwarder)
- Intercepts TCP connections to port 443 → applies circumvention
- Uses TLS Client Hello splitting to evade DPI
Feature Comparison
| Feature | doh-forwarder | Intra |
|---|---|---|
| DNS-over-HTTPS forwarding | ✅ | ✅ |
| Multiple upstream resolvers | ✅ | ✅ |
| Round-robin / failover | ✅ | ❌ |
| UDP + TCP DNS listener | ✅ | ✅ (via TUN) |
| HTTP DoH endpoint | ✅ | ❌ |
| TUN device (all traffic) | ❌ | ✅ |
| TCP stream interception | ❌ | ✅ |
| TLS Client Hello splitting | ❌ | ✅ |
| Retry on censorship detection | ❌ | ✅ |
| SNI-based error reporting | ❌ | ✅ |
| EDNS padding | ❌ | ✅ |
| IP map with confirmation tracking | ❌ | ✅ |
| Servfail hangover (rate limiting) | ❌ | ✅ |
| Written in | Rust | Go + Java |
| Platform | Linux (server) | Android (VPN) |
The Missing Piece: TLS Client Hello Splitting
The critical technique Intra uses to bypass censorship is TCP segment splitting on the TLS Client Hello. Here's how it works:
How DPI censorship works
When your browser connects to https://www.youtube.com, the first thing
it sends is a TLS Client Hello packet. This packet contains the
SNI (Server Name Indication) field in plaintext — the domain name
www.youtube.com. A DPI system reads this field and blocks the
connection if the domain is on a blocklist.
How splitting defeats DPI
Intra's splitHello() function splits the TLS Client Hello into two
TCP segments at a random offset (6–64 bytes):
Normal Client Hello (one TCP segment):
[TLS Header (5 bytes)] [SNI: www.youtube.com ... rest of hello]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
DPI reads this → finds "youtube.com" → BLOCKS
Split Client Hello (two TCP segments):
Segment 1: [TLS Header (5 bytes)] [first N bytes...]
Segment 2: [remaining bytes... SNI: www.youtube.com ...]
DPI can't reassemble fast enough → PASSES THROUGH ✅
For TLS Client Hello specifically, Intra also properly fragments the TLS record header — it adjusts the record length field in each segment so the TLS layer itself remains valid:
// First segment: adjust record length to split point
pkt := hello[:splitLen]
binary.BigEndian.PutUint16(pkt[3:5], uint16(recordSplitLen))
// Second segment: copy TLS header, set remaining length
pkt = hello[splitLen-5:]
copy(pkt, hello[:5])
binary.BigEndian.PutUint16(pkt[3:5], recordLen-uint16(recordSplitLen))
The retry mechanism
DialWithSplitRetry() in retrier.go:
- Opens a normal TCP connection to the target
- Sends the TLS Client Hello
- Sets a read deadline (1200ms + 2×RTT)
- If the connection is dropped or times out → censorship detected
- Closes the connection
- Opens a new TCP connection
- Replays the Client Hello as split segments
- If this succeeds → the connection proceeds normally
Why doh-forwarder Can't Unblock YouTube
doh-forwarder operates at the DNS layer only. It has no visibility into or control over:
- TCP connections to resolved IPs
- TLS handshakes
- HTTP requests
The traffic flow is:
1. Browser asks OS: "What's the IP for youtube.com?"
2. OS sends DNS query to doh-forwarder (port 53)
3. doh-forwarder forwards via DoH → gets correct IP (e.g., 142.250.80.46)
4. Browser connects directly to 142.250.80.46:443
5. Browser sends TLS Client Hello with SNI=www.youtube.com
6. ISP's DPI reads SNI → blocks connection
7. Browser shows "connection timed out" or "connection reset"
Step 4–7 happen entirely outside doh-forwarder's scope.
What Would Be Needed
To achieve what Intra does, doh-forwarder would need to evolve from a DNS forwarder into a traffic interception and circumvention proxy:
Option A: SOCKS5 Proxy with Split Transport (M6 path)
Browser → SOCKS5 proxy (doh-forwarder) → split TCP → target server
- Configure browser to use SOCKS5 proxy
- Proxy applies TLS splitting on outbound connections
- Lighter than full TUN, requires browser config
Option B: Transparent Proxy with TUN (Intra-like)
All traffic → TUN device → doh-forwarder → DNS: DoH / TCP: split → target
- Zero browser configuration
- Requires root/CAP_NET_ADMIN for TUN
- Significantly more complex
Option C: Hybrid
- DNS forwarding via DoH (current functionality)
- Optional SOCKS5 proxy for TCP circumvention
- Optional TUN mode for transparent interception
- User chooses based on their environment
Lessons from Intra's Codebase
1. IP Map with Confirmation Tracking
Intra maintains an ipmap that tracks which IPs of a DoH resolver
actually work. If a confirmed IP starts failing, it's disconfirmed and
alternatives are tried. This provides resilience against partial IP
blocking.
2. EDNS Padding
Intra pads DNS queries to standard sizes to prevent traffic analysis that could identify specific domains by query size.
3. Servfail Hangover
When a DoH server returns errors, Intra enters a 10-second "hangover" where it stops sending queries (returns SERVFAIL immediately). This rate-limits queries to misconfigured or blocked servers.
4. SNI Reporter
Intra reports which SNIs are being censored (via the Choir library) to contribute to censorship monitoring without revealing individual user activity.
References
- Intra source code
- Outline SDK — the transport library Intra uses
- RFC 8484 — DNS over HTTPS
- TLS Client Hello splitting — research on DPI evasion