Redirecting DNS: every query on port 53 ends up at your own resolver
Summary
In July 2009 I made a note of how my Linux router redirects every DNS query from the internal networks to its own Bind9, no matter which server a device has configured. Three building blocks were enough: DNAT on port 53, an output rule that only lets the Bind user out, and as an alternative rules for the servers in resolv.conf. This post from my old blog explains the rules, shows them in nftables and says where the method ends today with DNS over TLS and HTTPS.
The note from 2009
The page in my old Plone blog was a reminder, not an article. It began with the sentence “Note: in case I ever want to force a DNS server on someone again.” Below it were excerpts from my router’s firewall script; at the time the router had several internal networks on eth1, eth2 and eth4 and reached the internet via eth0. Bind9 ran on the router as the resolver for all networks.
The goal: a device on the network should resolve its names at the router, even if a different DNS server is hard-coded in it. Only then do your own zones for internal names, a shared cache and later blocklists against advertising take effect.
Building block 1: bend everything on port 53
The first group of rules, here the excerpt for eth1, catches in PREROUTING every query to port 53 that comes from an internal network and is not already addressed to the router, and rewrites the destination to the router’s address via DNAT. This happens for UDP and TCP, because large answers and zone transfers use TCP. The variables come from the rest of the script; $ifc_eth1 is 1 if the interface exists.
# alle DNS Anfragen auf lokalen Bind9 umleiten
[ $ifc_eth1 -eq 1 ] && $iptables -t nat -A PREROUTING -i eth1 -p udp -s $net_eth1 -d ! $ip_eth1 --dport 53 -j DNAT --to $ip_eth1
[ $ifc_eth1 -eq 1 ] && $iptables -t nat -A PREROUTING -i eth1 -p tcp -s $net_eth1 -d ! $ip_eth1 --dport 53 -j DNAT --to $ip_eth1To the device everything looks normal. It asks 8.8.8.8, for example, and receives an answer with exactly this sender address, because conntrack rewrites the router’s answer on the way back. The device does not notice that the router actually answered.
Building block 2: only Bind may go out
Redirecting alone is not enough as long as the router itself lets any DNS query out to the internet. The second group of rules allows new connections to port 53 via eth0 only for processes with user ID 103, under which Bind ran on my system. The owner module only works in the OUTPUT and POSTROUTING chains, because only for packets created on the machine itself does the kernel know which process produced them.
# Bind9 darf DNS Anfragen stellen
[ $ifc_eth0 -eq 1 ] && $iptables -A OUTPUT -o eth0 -p udp -s $ip_eth0 --dport 53 -m owner --uid-owner 103 -m state --state NEW -j ACCEPT
[ $ifc_eth0 -eq 1 ] && $iptables -A OUTPUT -o eth0 -p tcp -s $ip_eth0 --dport 53 -m owner --uid-owner 103 -m state --state NEW -j ACCEPTHard-coded numbers are a trap here: on another system, 103 belongs to an entirely different service. The name is better, i.e. --uid-owner bind, which iptables resolves itself when loading the rule.
Variant: forwarder only
As an alternative, the note contained a loop for the case that the router does not resolve recursively itself but only forwards to the provider’s DNS servers. It counts the nameserver lines in /etc/resolv.conf, extracts each address individually and allows outgoing connections to port 53 only to these servers.
# DNS Server aus der /etc/resolv.conf zaehlen
int_dnsserver=$($grep ^nameserver /etc/resolv.conf | $wc -l | $tr -d " ")
# DNS Anfragen auf Server in /etc/resolv.conf
for ((I=1; $I <= $int_dnsserver; I++)) ; do
# DNS-IP's ermitteln
dnsip=$($grep ^nameserver /etc/resolv.conf | $head -n $I | $tail -n 1 | $cut -d " " -f 2)
# $DNSIP in die chains schreiben
[ $ifc_eth0 -eq 1 ] && $iptables -A OUTPUT -o eth0 -p tcp -s $ip_eth0 -d $dnsip --dport 53 -m state --state NEW -j ACCEPT
[ $ifc_eth0 -eq 1 ] && $iptables -A OUTPUT -o eth0 -p udp -s $ip_eth0 -d $dnsip --dport 53 -m state --state NEW -j ACCEPT
# Schleifenabbruch
if [ $I -eq $int_dnsserver ] ; then
break
fi
doneSeen from today: the break at the end is superfluous, the loop ends after the last server anyway. A line such as awk '/^nameserver/ {print $2}' /etc/resolv.conf as the list for for would be shorter. And the rules are only created once when the firewall starts; if DHCP changes resolv.conf later, they no longer match.
Why this soon became more important
On 3 December 2009, not quite half a year after my note, Google launched its public resolver at 8.8.8.8 and 8.8.4.4. From then on more and more devices and guides hard-coded these addresses, bypassing any local resolver. Redirecting port 53 helps exactly against such hard-coded servers.
The same idea today
In iptables the notation -d ! is deprecated; today the exclamation mark goes before the option: ! -d $ip_eth1. Most distributions now use nftables, where a single rule covers UDP and TCP:
table inet dnsumleitung {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "lan0" ip daddr != 192.168.1.1 meta l4proto { tcp, udp } th dport 53 dnat ip to 192.168.1.1
}
}For IPv6 there was no solution in 2009: NAT for IPv6 only entered the kernel with Linux 3.7 at the end of 2012. If you have IPv6 on your network today, you need a second rule with ip6, otherwise devices keep asking whoever they like over IPv6.
The bigger limit is encryption. DNS over TLS (RFC 7858) runs on port 853 and can be blocked; devices that only use it when available then fall back to plain DNS, while devices with a fixed setting stop resolving altogether. DNS over HTTPS (RFC 8484), on the other hand, cannot be distinguished from ordinary HTTPS. It cannot be redirected, only made harder via blocklists of known providers. Firefox additionally checks the so-called canary domain use-application-dns.net: if the network’s resolver answers it with NXDOMAIN, Firefox switches off its automatic use of DoH unless the user has enabled it explicitly.
At home this is now done by the firewall, an OPNsense: Unbound resolves, validates DNSSEC and applies blocklists against advertising, and DNS from the internal networks is redirected to the firewall via NAT. The principle is the same as in 2009, only in a web interface instead of a shell script.
Your own network is not the internet
In your own network and for your own devices, this redirection is self-administration: I decide who resolves my names, which advertising servers are unreachable and what devices in the guest network see. But whoever forces a resolver on others via the network operator or by law builds a censorship infrastructure with the same technology. This is exactly how state DNS blocks work, such as France’s block of TikTok, Instagram, Snapchat and X. I reject that, and the fact that encrypted DNS can bypass such blocks does not change that they are wrong.
Numbers at a glance
- The note was in my old Plone blog under /dokumente/dns-umleiten, last modified on 17 July 2009.
- Google Public DNS launched on 3 December 2009 at 8.8.8.8 and 8.8.4.4.
- The iptables owner module only works in the OUTPUT and POSTROUTING chains, i.e. for packets of the machine itself.
- NAT for IPv6 entered the kernel with Linux 3.7 at the end of 2012.
- DNS over TLS uses port 853, DNS over HTTPS the ordinary HTTPS port 443.
References
- bk99.de 2009: DNS umleiten (Web Archive, German)
- nftables wiki: Performing Network Address Translation (NAT)
- KernelNewbies: Linux 3.7 (IPv6 NAT)
- Mozilla: canary domain use-application-dns.net
- OPNsense manual: Unbound DNS
- Google launches a public DNS resolver (2009)
Remarks
- This post goes back to a note from my old Plone blog that survives in the Web Archive in a single capture from May 2010. In 2026 I took over the rules verbatim, for the redirection only the lines for eth1, and added explanations and today’s state. Code comments are in German as in the original.
- The interface names and user ID 103 come from my router at the time and do not carry over to other systems.
Original note in the Web Archive
Links to the original source and the Web Archive open in a new tab.