DNS Leak: How it Works and How to Implement It


Checking for DNS leaks is a technique to guess a client's ISP, even when it is connecting through a VPN or some other proxy service. Implementing a DNS Leak check is pretty straightforward.

How it Works

The successful DNS leak detection occurs when the client configuration is such that the DNS traffic does not go through the VPN service. Most of the time, the DNS traffic goes to the DNS resolver provided by the ISP. That is because home routers commonly advertise the ISP's DNS server via DHCP.

Let's assume there is a curious admin who likes to spy on its visitors. For unknown reasons, that admin likes to see who is using a VPN and from which country, among its visitors. How does the admin can know that?

The curious admin is now able to detect a correlation between the client behind a VPN service and its real ISP.

That means that the admin can detect that the client might use a VPN service because the IP address of the HTTP connection does not belong to the same organization as the IP address of the ISP's DNS previous request.
Since it is possible to determine which ISP the client used (from the IP address), it is possible to determine from which country the client connected with a high degree of confidence.

It is not possible to determine the real IP address of the client, though. (Unless the client is its own DNS resolver, of course.) However, just knowing the real originating country of a connection behind a VPN is powerful enough to compromise online privacy.

What happens when there is no DNS leak?

Most of the time, the VPN operator provides (hopefully anonymous) DNS resolver as well. If used, the curious admin has no way to determine which is the real ISP used by the client, even if he/she knows that the client may use a VPN service.

Another case is when the client uses a DNS provider not provided by its ISP. That may be Google, Cloudflare, Quad9, FDN, ...
However, the big providers uses geographically distributed DNS resolvers to achieve good latency times. That let the curious admin see which in which country the DNS resolver is located and thus have a pretty good idea about the client's real location.

Implementing a DNS Leak Check Tool

Here is what I needed:

Initial setup

Go to your domain name management or edit your zone file and add a new NS record:

leak.example.com. IN NS ns.leak.example.com.

; Glue records (if needed):
ns.leak.example.com. IN A    <ipv4 addr>
ns.leak.example.com. IN AAAA <ipv6 addr>   ; If you want IPv6 support

If you are not familiar with DNS, leak.example.com is handled by ns.leak.example.com. However, we end up in a chicken and egg problem, here. That is why we provide some bootstrap hardcoded IP addresses, so the resolving does not get stuck. That kind of record is called *glue records* for that reason. If you already have one domain resolving to your target server, you can use it as well, so you don't need to provide glue records.

Custom Authority DNS Server

For the DNS server, I used the dnspython library for decoding and encoding forged DNS messages from/to wire format. To keep things simple, we use SQLite to store UIDs and IP addresses.

In this article, I focus on the essential points only. The full Python source code is available here; it is under 100 lines of code.
Beware, the implementation is not production-ready! There is no proper error handling (or too few), it is intended for demonstration purpose only.

Parsing a DNS question and returning the answer. (line 12)

def handle_dns_query(raw_query):
    """
        Return the name and wire-formated DNS response
    """
    query = dns.message.from_wire(raw_query)
    question = str(query.question[0])
    name, rtype = [r.strip() for r in question.split('IN')]

    if rtype == 'A':
        ip_resp = IP4_ADDR
    elif rtype == 'AAAA':
        ip_resp = IP6_ADDR
    else:
        print('Only A and AAAA are supoprted')
        raise NotImplementedError

    response = dns.message.make_response(query)
    rrset = dns.rrset.from_text(name, 10, 1, rtype, ip_resp)
    response.answer.append(rrset)
    wire_resp = response.to_wire()

    return name, wire_resp

raw_query is the DNS message extracted as is from the UDP datagram(s). The function dns.message.from_wire decodes the DNS question (or response), then we can extract the requested name (e.g., 87419943288.leak.example.com and the record type (rtype): A or AAAA. The server does not support any other record.

The dnspython library makes it possible to pre-forge a DNS answer based on a previous DNS question, thanks to the function dns.message.make_response. After that, we only need to add a record data containing the real answer: for name name with record rtype, the answer is ip_resp.

At this stage, we have all information needed: IP address of the resolver (conn tuple), the domain name requested (name). We just have to store, now (line 36)

def store(db, name, conn):
    identifier = int(name.split('.')[0])
    ip_addr, *_ = conn
    db.execute('''
        insert into ip (id, ip_addr) values (?, ?)
        ''', (identifier, ip_addr))
    db.commit()

conn is a connection data containing the IPv4 or IPv6 address of the DNS resolver. It is returned by the socket.recvfrom method.
We extract the UID from the raw requested domain name (87419943288.leak.example.com to 87419943288).
Then, we insert it into the database.

Web Service

For the Web service, I used the very simple framework bottle.py. Here is the complete code:

from bottle import Bottle, route, run, request
import sqlite3

app = Bottle()
db = sqlite3.connect('store.db')

@app.route('/lookup')
def lookup():
    ip_addr = request.headers['x-real-ip'] # IP address of the client behind VPN
    host = request.headers['host'] # will be '87419943288.leak.example.com'
    identifier = host.split('.')[0]

    r = db.execute('''
        select ip_addr from ip where id = ?
    ''', (identifier,))
    res, *_ = r.fetchone()

    return {'host': res}

app.run(host='HOST', port=PORT)

(Notes that request.headers['x-real-ip'] assumes that we have configured the HTTP server to fill the X-Real-IP header with the client's IP address before forwarding to the Python app.)

At this stage, our Web app can correlate all information and try to exploit a DNS leak.

Client's side

As for the JavaScript side, this is pretty straightforward. We make a random integer number and perform an AJAX call.

function randomInt() {
    let min = 0;
    let max = Number.MAX_SAFE_INTEGER;
    return Math.floor(Math.random() * (max - min)) + min;
}

fetch(`https://${randomInt()}.leak.example.com`);

Testing with DNS

dig 42.leak.example.com @8.8.8.8 +short # using Google resolver
172.205.221.180

dig 43.leak.ecample.com @1.1.1.1 +short # using Cloudflare resolver
172.205.221.180

The results in the database:

id | ip_addr
---+---------------
42 | 173.194.168.67
43 | 162.158.117.91

What we can learn:

As you see, the curious admin can determine that I connect from Japan. That, even if I have used Google or Cloudflare instead of my Japanese ISP provider.