All posts

Reading DNS off an Android VPN without routing your traffic

ยท by Sk Masum Ali

Vigil shows which app on your phone looked up which domain. 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. Vigil 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. Vigil 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 null

Watch, then get out of the way

Once a query is logged, something still has to answer it, or the phone loses DNS. Vigil sends the original bytes to an upstream resolver (1.1.1.1 by default, changeable in settings), wraps the reply in new IP and UDP headers addressed back to the app, and writes it into the tunnel. It never blocks anything. It is an audit tool, not a firewall.

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
}

Command menu

Jump to a page, open a profile, or run an action