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 client goes to a website maintained by the curious admin
- The website contains a JavaScript script which makes an AJAX call (or WebSocket if you prefer)
using a random-generated valid domain name such as
87419943288.leak.example.com.
. - 1: Thus, the client must resolve the domain
87419943288.leak.example.com.
before starting any HTTP connection. It uses the ISP's DNS resolver for that. - 2: The ISP's DNS resolver asks to the authoritative name server, which logs the UID (
87419943288
) and the IP address of the resolver into a database. Then, it responds with the proper IP address, which is forwarded back to the client. - 4: The name is resolved, the client can now make the AJAX call, inside the VPN tunnel.
- 5: The Web service handling the AJAX/WS request has access to the same database where the IP/UID are stored.
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:
- A custom authoritative DNS to handle the zone
dnsleak.domain.tld
- A database to store UIDs and IP addresses
- A simple web service which has access to the same database
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:
173.194.168.67
is owned by Google, in Japan.162.158.117.91
is owned by Cloudflare, in Japan.
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.