Reading DNS off an Android VPN without routing your traffic
ยท by Sk Masum Ali
Lantern shows which app on your phone looked up which domain and actively blocks trackers. Android won't hand an app that information directly, but it does let an app become a VPN, and a VPN sits in the path of packets. The trick is being a VPN that sees only DNS and touches nothing else.
One route, one address
Most VPN apps route everything through the tunnel. Lantern does the opposite. It gives the tunnel an address, tells Android that the DNS server is a made-up address inside it, and adds a route for exactly that one address. Every other packet on the phone ignores the tunnel and goes out the normal way.
val descriptor = Builder()
.addAddress(TUN_ADDRESS, 32) // 10.0.0.1
.addDnsServer(SENTINEL_DNS) // 10.0.0.2
.addRoute(SENTINEL_DNS, 32)
.setMtu(1500)
.setBlocking(true)
.setSession(SESSION_NAME)
.establish()Because of that /32 route, the only packets that arrive in the tunnel are the ones apps send to the sentinel resolver: DNS lookups. Lantern never sees the content of anything else, which keeps it light and keeps its promise about what it can and can't see.
Parsing the packet by hand
What comes out of the tunnel file descriptor is a raw IP packet. A reader thread pulls bytes off it, and the parser walks the headers: it checks that the packet is IPv4 and UDP, that the destination port is 53, and that it is a query rather than a response. Then it reads the question section to get the domain name.
That last step is where careless parsers get hurt, because DNS names can compress, with a pointer that says to continue reading from somewhere else in the message. A malicious or broken packet can make those pointers loop. The parser caps the number of jumps at 10, rejects labels longer than 63 bytes, and returns null if any length or offset would run past the end of the buffer. A bad packet is dropped, never a crash.
length and 0xC0 == 0xC0 -> {
if (pos + 1 >= dns.size) return null
if (++jumps > MAX_POINTER_JUMPS) return null
if (endPos == -1) endPos = pos + 2
val ptrOffset = ((length and 0x3F) shl 8) or (dns[pos + 1].toInt() and 0xFF)
if (ptrOffset >= dns.size) return null
pos = ptrOffset
}
length > MAX_LABEL_LENGTH -> return nullSinkhole or encrypt upstream
Once a query is parsed, Lantern evaluates it against its reverse suffix trie. If it matches a threat, Lantern immediately constructs a synthetic 0.0.0.0 or NXDOMAIN response in <0.05ms without sending a single byte outside the device. Permitted queries are forwarded to upstream resolvers via encrypted DoH (RFC 8484) or DoT (RFC 7858) using protected sockets.
The detail that makes this work is protect(). The forwarding socket lives inside the same app that owns the VPN, so without it the upstream query would be routed back into the tunnel and loop. Calling service.protect(socket) exempts that socket from the VPN, so it goes straight out to the network.
DatagramSocket().use { socket ->
service.protect(socket)
socket.soTimeout = ResolverConfig.QUERY_TIMEOUT_MS.toInt()
socket.send(DatagramPacket(rawDns, rawDns.size, upstream, ResolverConfig.DNS_PORT))
// wait for the reply, wrap it, write it back into the tunnel
}