
DNSSEC-aware DNS caching with Unbound
Name Game
DNSSEC [1] protects against falsified DNS records by cryptographically signing DNS information. The root servers sign the record. One step lower in the hierarchy, the .com DNS servers sign records leading to the servers that provide A records for domains like hanscees.com. DNSSEC was designed to protect against intrusion techniques that result in client systems receiving forged or manipulated DNS data. Because all DNS operations pass through a hierarchy of cryptographically signed records, any computer on the Internet can determine whether a DNS record it receives is valid: it is valid if the signature matches the public key. (Look online for more on how DNSSEC works [2].)
In this way, DNSSEC helps protect against DNS poisoning or man-in-the-middle (MITM) attacks. Even if a false TLS certificate exists, your browser cannot be led to a false IP address, because DNSSEC will reject the spoofed DNS records.
The number of DNS records signed with DNSSEC is continually on the rise. However, because most ISPs don't offer their customers DNSSEC-aware name resolution, chances are, your computers are not yet protected by DNSSEC.
Unbound to the Rescue
Even if your ISP does not offer DNSSEC security, you can easily set it up yourself. This article describes how to build a private DNSSEC-aware DNS resolver, so you won't have to use your ISP's resolver.
I use the Unbound DNS resolver because of its secure-by-design stance. Unbound is a modern successor to Dan Bernstein's djbdns [3], because its design is focused on security and it includes DNSSEC. Developer NLnet Labs describes Unbound as a validating, recursive, caching DNS resolver [4].
I will set up Unbound to resolve DNSSEC and also serve some local zones to my LAN (my local timeserver). Unbound won't perform add-blocking, because I will use a Pi-hole DNS server [5] for that.
I run Unbound on Docker and Docker on Photon OS. Containerizing makes solutions portable and easy to set up. Since I use VMware, I will set up Docker inside a Photon OS virtual machine (VM). Photon OS [6] is a specialized small Linux built for Docker, Kubernetes, and security.
A Photon VM is about 150MB on disk: Photon runs on VMware, as well as Amazon and Azure. Of course, you are free to use another Docker-friendly OS for your implementation. To build the Unbound DNSSEC system, I take the following steps:
- Build the Docker layer with
docker-compose
. - Build and test the Unbound server.
- Add some iptables rules and control stuff and update precautions (for Docker)
- Be happy and drink coffee
Building the Docker Layer
The first step is to install Docker and docker-compose
and test whether Docker restarts after a reboot. For Photon OS, I'll use the following commands (adapt as needed for your operating system):
tdnf install docker docker-compose systemctl enable docker reboot
After the reboot, the ps
command should turn up numerous Docker processes:
ps waux | egrep docker
If Docker does not restart, you should enable it, probably with a systemd command.
To run Unbound in a container, you need to write a YAML definition file. Find a suitable image at Docker Hub and place it in its own directory. (The image I selected from the Docker Hub is mvance/unbound:latest.)
mkdir -p /root/containers/unbound cd /root/containers/unbound
Now place the contents of Listing 1 into a YAML file:
vi docker-compose.yml
Listing 1: YAML File Contents
version: '2' services: dns: image: mvance/unbound:latest restart: always container_name: unbound ports: - "953:953/tcp" - "53:53/tcp" - "53:53/udp" volumes: - unbound_conf:/opt/unbound/etc/unbound/
Now fire up the Unbound container:
cd /root/containers/unbound/ docker-compose up
Usually when you define a YAML file and start the container for the first time, you will see some errors. If you made typos in the YAML file, Docker will complain (and stop). When I launched the container, a problem occurred because systemd was listening on the DNS port (UDP 53).
Google helped me find this solution:
systemctl stop systemd-resolved systemctl disable systemd-resolved.service rm /etc/resolv.conf echo "nameserver 1.1.1.1" > /etc/resolv.conf
Now port 53 is free and the system can still resolve DNS itself (for updates). When you know your config file is error-free, you can start the container with the -d
option to run the container in the background:
docker-compose up -d
However, in the early stages, it is better to omit the -d
so you can discover errors that could cause Unbound to stop directly.
Unbound should be working now. You can test the Unbound DNS resolver from another computer with dig
:
dig @192.168.0.110 www.cnn.com
If you don't have dig
, you can install it by installing dnsutils on any Linux system.
The DNS server (at 192.168.0.110 in the preceding example) answered the query successfully, so name resolution is working.
Configuring Unbound
To see if Unbound is resolving DNSSEC correctly, go to the Root Canary test page (Figure 1) [7] and see if you pass.

This test will only work on your computer if it uses the Unbound DNS server (192.168.0.110 in this example) to resolve DNS. On my Ubuntu 18, I changed /etc/resolv.conf
to read:
nameserver 192.168.0.110
Then, reboot.
Configuring Unbound
After I prove that Unbound does validate DNSSEC, I need to make some changes to the config files for both Unbound and the Docker container
To change the Unbound configuration, I edit the configuration file:
cd /var/lib/docker/volumes/unbound_unbound_conf/_data/ cp unbound.conf unbound.conf.old #just in case
I do not want to use any forward DNS servers, so I delete all the lines of the forward section (Listing 2, with hashes). No forward zone at all should remain in the config file.
Listing 2: Forward Section
forward-zone: # Forward all queries (except those in cache and # local zone) to upstream recursive servers #name: "." # Queries to this forward zone use TLS #forward-tls-upstream: yes # Cloudflare #forward-addr: 1.1.1.1@853#cloudflare-dns.com #forward-addr: 1.0.0.1@853#cloudflare-dns.com
I will also tweak the security settings (Listing 3). You'll need to adjust the access-control
setting to your internal LAN IP range: the IP ranges you provide are networks allowed to use recursive resolving. If you don't set up this section properly, you might end up amplifying distributed denial of service (DDoS) attacks on the Internet.
Listing 3: Security Settings
# only give access to recursion clients from LAN IPs access-control: 127.0.0.1/32 allow access-control: 192.168.0.0/24 allow # adjust to your lan ip-range auto-trust-anchor-file: "var/root.key" chroot: "/opt/unbound/etc/unbound" harden-algo-downgrade: no harden-below-nxdomain: yes harden-dnssec-stripped: yes harden-glue: yes harden-large-queries: yes harden-referral-path: no harden-short-bufsize: yes hide-identity: yes hide-version: yes identity: "DNS" # These private network addresses are not allowed to be returned for public # internet names. Any occurrence of such addresses are removed from DNS # answers. Additionally, the DNSSEC validator may mark the answers bogus. # This protects against DNS Rebinding private-address: 10.0.0.0/8 private-address: 172.16.0.0/12 private-address: 192.168.0.0/16 private-address: 169.254.0.0/16 ratelimit: 1000 tls-cert-bundle: /etc/ssl/certs/ca-certificates.crt unwanted-reply-threshold: 10000 use-caps-for-id: no #gives many false errors val-clean-additional: yes
You also should adjust the performance settings according to the number of CPU cores you have (Listing 4). My VM has one CPU with two cores. See the Unbound documentation for more on optimizing Unbound [8].
Listing 4: Performance Settings
## performance settings infra-cache-slabs: 4 key-cache-slabs: 4 msg-cache-size: 302921045 msg-cache-slabs: 4 num-queries-per-thread: 4096 num-threads: 2 outgoing-range: 8192 rrset-cache-size: 605842090 rrset-cache-slabs: 4 minimal-responses: yes so-reuseport: yes prefetch: yes prefetch-key: yes serve-expired: yes
Of course, if the server does not work after a change; start it again with:
docker-compose up
Look for the errors, then look for a solution online.
You can also move into the container and ask Unbound to check the configuration:
docker exec -ti unbound /bin/bash sudo unbound-checkconf exit
Adding a Local Zone
In my LAN, I have a few (Dockerized) servers I want to be able to find easily: let's say time.hanscees.com, canon.hanscees.com (a printer), and bananas (a Banana Pi NAS server). Unbound can serve these DNS resource records (RRs) without a problem. It is handy to give zones their own file by adding a line in unbound.conf
pointing to a separate file:
include: /opt/unbound/etc/unbound/a-records.conf include: /opt/unbound/etc/unbound/ptr-records.conf
To define local records, edit a-records.conf
and add entries for the local records (Listing 5).
Listing 5: Adding Local Records
#local-zone: "hanscees.com" static local-data: "canon.hanscees.com A 192.168.0.60" local-data: "time.hanscees.com A 192.168.0.61" local-data: "bananas.hanscees.com A 192.168.0.62"
Notice you can choose whether to define the zone in the config file or not: uncheck the local-zone
line if you want to define the local zone. If you do, Unbound assumes that somename.hanscees.com does not exist if you have not added it to the config file. If you do not define the local-zone
, and a name does not exist in the config file, Unbound will try to look it up on the Internet.
Control Client
Unbound has a control feature that lets you connect the Unbound client to the Unbound server. This way, you can get statistics and add hosts and changes to your configuration while Unbound is running.
The default config file has the control feature switched off. To enable it, you need to tweak the configuration file, generate some keys for authentication, and expose the port via Docker. For better security, I will configure the Unbound server to allow only one IP address to the control port. See the changes in Listing 6.
Listing 6: The Control Client
01 remote-control: 02 control-enable: yes 03 control-interface: 172.18.0.2 04 control-port: 953 05 server-key-file: "/opt/unbound/etc/unbound/unbound_server.key" 06 server-cert-file: "/opt/unbound/etc/unbound/unbound_server.pem" 07 control-key-file: "/opt/unbound/etc/unbound/unbound_control.key" 08 control-cert-file: "/opt/unbound/etc/unbound/unbound_control.pem"
The control-interface
line is a bit tricky: you need to pick the IP address Unbound can see inside the container, and it needs to be the interface that the VM exposes via your LAN device.
You can easily see the necessary address and gateway with:
docker inspect caa97f2fe1fe | egrep "Gateway|IPAddress" | egrep 172
To generate the keys, enter:
docker exec -ti unbound /bin/bash #log into container unbound-control-setup
You will see Unbound generating keys. Now restart container and test whether nothing has broken:
docker restart caa97f2fe1fe dig @192.168.0.110 time.hanscees.com
To install the remote client, just install Unbound on a remote Linux machine. In my case, this also installed the Unbound server, so I had to stop it.
The client needs a config file with the right keys to authenticate itself to the server. These commands worked on the client PC (running Ubuntu 18):
sudo apt-get install unbound sudo systemctl stop unbound.service sudo systemctl disable unbound.service
To authenticate the client to the Unbound server, move some of the control keys from the server to the control client:
sudo scp root@192.168.0.110:/var/lib/docker/volumes/unbound_unbound_conf/_data/unbound_control.key ~/keys/ sudo scp root@192.168.0.110:/var/lib/docker/volumes/unbound_unbound_conf/_data/unbound_server.pem ~/keys/
Also, reflect this information in the config file for the client (Listing 7).
Listing 7: Remote Control Settings
remote-control: server-cert-file: "/home/hanscees/keys/unbound_server.pem" control-key-file: "/home/hanscees/keys/unbound_control.key" control-cert-file: "/home/hanscees/keys/unbound_control.pem"
Now I can start using the client. I'll enable extended stats and then take a look at some statistics:
sudo unbound-control -c ~/keys/unbound.conf -s 192.168.0.112@953 set_option extended-statistics: yes sudo unbound-control -c ~/keys/unbound.conf -s 192.168.0.112@953 stats_noreset | egrep "total.num|secure"
The output appears in Listing 8.
Listing 8: Statistics
[sudo] password for hanscees: total.num.queries=88937 total.num.queries_ip_ratelimited=0 total.num.cachehits=83513 total.num.cachemiss=5424 total.num.prefetch=22534 total.num.zero_ttl=11075 total.num.recursivereplies=5424 num.answer.secure=238
The statistics there show that, from 88,937 queries, 8,351 were served out of the cache (94%), and 238 of these queries were validated by DNSSEC.
Firewall
If configured incorrectly, DNS servers can amplify DDoS attacks. In this example, I have configured the Unbound DNS server so only computers on the LAN can use it to resolve DNS. However, it won't hurt to add another layer of defense. Because this server does not act as an authoritative nameserver on the Internet, I can block ports on the outside interface. I will block incoming traffic into the forward chain on the outside interface because Docker forwards traffic from outside interfaces to "virtual internal NICs" (bridge interfaces) via the forward chain. I will also block incoming traffic in the iptables input chain, although this step will not block any traffic to the Dockerized service.
Be very careful when messing with iptables and Docker, because Docker generates many rules in the forward chains. To minimize the chances of disruption, only add rules to the front of the forward chain (using I instead of A, and without flushing a chain). Any traffic coming into any Docker bridge from the Internet that's part of an existing connection is allowed. All other traffic from the Internet to Docker is blocked. Traffic to port 935 from the LAN and the control client is fine, and all other port 935 traffic from the LAN is blocked (Listing 9).
Listing 9: iptables Rules
iptables -I FORWARD -i eth0 -j DROP iptables -I FORWARD -m state --state RELATED,ESTABLISHED -i eth0 -j ACCEPT iptables -I FORWARD -i eth1 -p tcp -dport 935 -j DROP iptables -I FORWARD -i eth1 -p tcp -s 192.168.0.2 --dport 935 -j ACCEPT
Now I'll add rules to block incoming traffic to the VM itself. The rules in Listing 10 flush the input chain, block non-reply incoming traffic into the host itself on the outside interface, allow port 22 traffic from the LAN and reply traffic, and block the rest.
Listing 10: Input Chain
iptables -F INPUT iptables -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT iptables -A INPUT -i eth0 -j DROP iptables -A INPUT -i eth1 -p tcp --dport 22 -j ACCEPT iptables -A INPUT -i eth1 -j DROP
Summary
This article described how to set up a DNSSEC-enabled recursive resolving validator using Unbound. Enabling DNSSEC will diminish the chances that you will suffer from a fishing, spoofing, or MITM attack. Although most DNS queries are not DNSSEC-protected, those of many banks and pay sites are. Overall, Unbound can help you create a safer surfing experience.