OpenBSD Router Guide

OpenBSD Router Guide

Network segmenting firewall, DHCP, DNS with Unbound, domain blocking and much more

Introduction

In this guide we’re going to take a look at how we can use cheap and “low end” hardware to build an amazing OpenBSD router with firewalling capabilities, segmented local area networks, DNS with domain blocking, DHCP and more.

We will use a setup in which the router segments the local area network (LAN) into three separate networks, one for the grown-ups in the house, one for the children, and one for public facing servers, such as a private web server or mail server. We will also look at how we can use DNS to block out ads, porn, and other websites on the Internet. The OpenBSD router can also be used on small to mid-size offices.

Why a firewall?

Almost no matter how you connect to the Internet from your home or office, you need a real firewall between you and the modem or router that your ISP has provided you with.

Very rarely do consumer-grade modems or routers get firmware updates and they are often vulnerable to network attacks that turns these devices into botnets, such like the Mirai malware. Many consumer-grade modems and routers is to blame for some of the largest distributed denial of service (DDoS) attacks.

A firewall between you and your ISP modem or router cannot protect your modem or router device against attacks, but it can protect your computers and devices on the inside of the network, and it can help you monitor and control the traffic that comes and goes to and from your local network.

Without a firewall between your local network and the ISP modem or router you could basically consider this an open door policy, like leaving the door to your house wide open, because you cannot trust the equipment from your ISP.

It is always a really good idea to put a real firewall between your local network and the Internet, and with OpenBSD you get an very solid solution.

The hardware

You don’t have to buy expensive hardware to get an effective router and firewall for your house or office. Even with cheap and “low end” hardware you can get a very solid solution.

I have build multiple solutions with the ASRock Q1900DC-ITX motherboard that comes with an Intel Quad-Core Celeron processor.

ASRock Q1900DC-ITX motherboard

I’ll admit, it’s a pretty “crappy” motherboard, but it gets the job done and I have several builds that have run very solid for many years on gigabit networks with full saturation and the firewall, DNS, etc. working “overtime” and the CPU hardly breaks a sweat.

The ASRock Q1900DC-ITX motherboard has the advantage that it comes with a DC-In Jack that is compatible with a 9~19V power adapter, making it very power saving. Unfortunatly the ASRock Q1900DC-ITX motherboard is no longer made, but I’m just using it as an example, I have used several other cheap boards as well.

I have also used the ASRock Q1900-ITX (it doesn’t come with the DC-In Jack) combined with a PicoPSU.

PicoPSU power supply

You can find different brands and versions of the PicoPSU, some are better quality than others. I have two different brands, the original and a cheaper knockoff, both performs very well and they save quite a bit of power contrary to running with a normal power supply.

Last, I am using a cheap Intel knockoff quad port NIC found on Ebay like this one:

Intel Quad NIC

I know it is better to use quality hardware, especially on a network that you care about, but this tutorial is about how you can get away with using fairly cheep hardware and still get an extremely useful product that will continue to serve you well for many years – at least that is my experience.

I recommend that you look for a low power mini ITX board with hardware supported by OpenBSD, such as an Intel Celeron or Intel i3 processor. These boards are typically cheap, less power hungry, and they don’t take up much space. I don’t recommend using the Intel Atom CPU if you have a gigabit network as they usually choke because they can’t handle the amount of traffic, but your mileage may vary.

You might also need a couple of cheap gigabit switches for the segmented local network, at least if you have more than one computer you want to connect to the same LAN 🙂

Why OpenBSD?

In truth, you can get a similar setup with one of the other BSD flavors or one of the many different Linux distribution, but OpenBSD is specifically very well suited and designed for this kind of task. Not only does it come with all the needed software in the base install, but it also has significantly better security and tons of improved mitigations already build-in into the operating system. I highly recommend OpenBSD over any other operating system for this kind of task.

This guide is not going to show you how to install OpenBSD. If you haven’t done that before I recommend you spin up some kind of virtual machine or see if you have some unused and supported hardware laying around you can play with. OpenBSD is one of the easiest and quickest operating systems to install. Don’t be afraid of the non-gui approach, once you have tried it you will really appreciate the simplicity. Use the default settings when in doubt.

Before you endeavor on this journey make sure to reference the OpenBSD documentation! Not only is everything very well documented, but you will most likely find all the answers you need right there. Read the OpenBSD FAQ and take a look at the different manual pages for the software we’re going to use.

Another really useful place to find general information about OpenBSD is the OpenBSD mailing list archives. Also make sure to stay up to date with relevant information by subscribing to the Announcements and security advisories mailing list.

Last, but not least, please consider supporting OpenBSD! Even if you don’t use OpenBSD on a daily basis, but perhaps make use of OpenSSH on Linux, then you’re really using software from the OpenBSD project. Consider making a small, but steady donation to support the further development of all the great software the OpenBSD developers make!

The network

A router is basically a device that regulate network traffic between two or more separate networks. The router will ensure that network traffic intended for the local network doesn’t run out into the wild on the Internet, and traffic on the Internet, that is not intended for your local network, stays on the Internet.

NOTE:
A router is sometimes also referred to as a gateway, which generally is alright, but in truth a real gateway joins dissimilar systems, while a router joins similar networks. An example of a gateway would be a device that joins a PC network with a telecommunications network.

In this tutorial we’re building a router and we have 4 networks of the same type to work with. One is the Internet and the other three are the internally segmented local area networks (LANs). Some people prefer to work with virtual LANs, but in this tutorial we’re going to use the quad port NIC from the illustration above. You can achieve the same result by using multiple one port NICs if you prefer that, you just have to make sure that you have enough room and free PCI slots on the motherboard. You can also use the Ethernet port on the motherboard itself, but it depends on the driver and support for the device. I have had no problems using the Realtek PCI gigabit Ethernet controller that normally comes with many motherboards even though I recommend Intel over Realtek.

Of course you don’t have to segment the network into several parts if you don’t need that, and it will be very easy to change the settings from this guide, but I have decided to use this approach in order to show you how you can protect your children by segmenting their network into a separate LAN that not only gets ad and porn blocking using DNS blocking (all the segments gets that), but you can even whitelist the parts of the Internet you want them to have access to. The last part about whitelisting is difficult and generally not recommended unless your children requires only very limited access, but it is doable with some work, and the guide is going to show you one way you can do that.

This is an illustration of the network we’re going to setup:


                       Internet
                          |
                    xxx.xxx.xxx.xxx
                    ISP Modem (WAN)
                      10.24.0.23
                          |
                       OpenBSD
                      10.24.0.50
                  (router/firewall)
                          |
     -------------------------------------------
     |                    |                    |
    NIC1                 NIC2                 NIC3
192.168.1.1          192.168.2.1          192.168.3.1
LAN1 switch          LAN2 switch          LAN3 switch
     |                    |                    |
     -- 192.168.1.x       -- 192.168.2.x       -- 192.168.3.2
     |  Grown-up PC       |  Child PC1         |  Public web server
                          |
                          -- 192.168.2.x
                          |  Child PC2

The IP addresses that begins with 10.24.0 are whatever IP addresses your ISP router or modem gives you, it may be something very different. The IP addresses beginning with 192.168 are the IP addresses that we’re going to use in the guide for our local area network (LAN).

The guide does not deal with any kind of wireless connectivity. Wireless chip firmware is notoriously buggy and exploitable and I recommend you don’t use any kind of wireless connectivity, if you can do without. If you do require wireless connectivity I strongly recommend that you disable wireless access from the ISP modem or router completely (if possible), and then buy the best wireless router you can find and put it behind the firewall in an isolated segment instead. That way should your wireless device ever be compromised you can better control the outcome and limit the damage. You can further setup the wireless router such that any devices connected to it have their own IPs that pass directly through the wireless router, but at the same time block traffic directly originating from the wireless router itself. That way you can prevent the wireless router from “phoning home”. You can also get a wireless adapter supported by OpenBSD and have your OpenBSD router run as the actual access point, however I much prefer to segment the wireless part to either a separate wireless router or another OpenBSD machine serving as a wireless access point behind the firewall itself.

NOTE:
At present, as far as I know, none of the OpenBSD wireless drivers are fully without problems yet.

Setting up the network

The first thing we’ll setup is the different NICs on our OpenBSD router. On my particular machine I have disabled the NIC that is build into the motherboard via the BIOS and I am only going to use the four port Intel knockoff NIC.

If you’re following this tutorial and only want a basic firewall then you need at least two separate NICs.

Before we begin make sure you have read and understood the different options in hostname.if man page. Also take a look at the networking section in the OpenBSD FAQ.

Since I am using Intel the em driver is the one OpenBSD loads and each port on the NIC is listed as a separate card. This means that each card is listed with emX where X is the actual number of the port on the given card.

dmesg lists my NIC with the four ports like this:

# dmesg em0 at pci2 dev 0 function 0 "Intel I350" rev 0x01: msi, address a0:36:9f:a1:66:b8
em1 at pci2 dev 0 function 1 "Intel I350" rev 0x01: msi, address a0:36:9f:a1:66:b9
em2 at pci2 dev 0 function 2 "Intel I350" rev 0x01: msi, address a0:36:9f:a1:66:ba
em3 at pci2 dev 0 function 3 "Intel I350" rev 0x01: msi, address a0:36:9f:a1:66:bb

This shows that my card is recognized as an Intel I350-T4 PCI Express Quad Port Gigabit NIC.

The next thing is to figure out which port that physically matches the number listed above. You can do that by manually plugging in an Ethernet wire, coming from an active (turned on) switch, modem or router, into each port, one at a time, in order to see which port gets activated and then note that down somewhere.

You can check the activity status with the ifconfig command. A port without the Ethernet cable will be listed as no carrier in the status field, whereas the port with the cable attached will be listed as active. Like this:

# ifconfig em1: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> mtu 1500
        lladdr a0:36:9f:a1:66:b9
        index 2 priority 0 llprio 3
        media: Ethernet autoselect (none)
        status: active
em2: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> mtu 1500
        lladdr a0:36:9f:a1:66:ba
        index 3 priority 0 llprio 3
        media: Ethernet autoselect (none)
        status: no carrier

We’re going to use the em0 port as the one we connect to the modem or router from the ISP, i.e. the Internet. In my specific case I have a public IP address from my ISP, and you’re going to need that if you want to run something like a web server from your home, but in case you don’t need that you can setup the card with DHCP.

In my case I need to put in a specific fixed IP address for em0 which then gets traffic forwarded by my ISP from my public IP. To do that I set the em0 card with the following information:

# echo 'inet 10.24.0.50 255.255.254.0 NONE' > /etc/hostname.em0

If you don’t need a public IP address and you get your IP from your ISP via DHCP, then just enter dhcp instead:

# echo 'dhcp' > /etc/hostname.em0

Then I’ll set the rest of the NIC ports up with the IP addresses I have previously illustrated.

# echo 'inet 192.168.1.1 255.255.254.0 NONE' > /etc/hostname.em1
# echo 'inet 192.168.2.1 255.255.254.0 NONE' > /etc/hostname.em2
# echo 'inet 192.168.3.1 255.255.254.0 NONE' > /etc/hostname.em3

Take a look at hostname.if for more information.

Then I need to setup the IP of the ISP gateway. Depending on the setup of your ISP this might be another IP address than the one from the ISP modem or router. If you don’t add the /etc/mygate then no default gateway is added to the routing table. You don’t need the /etc/mygate if you get your IP from your ISP modem or router via DHCP. If you use the dhcp directive in any hostname.ifX then the entries in /etc/mygate will be ignored. This is because the card that get its IP address from a DHCP server will also get gateway routing information supplied.

Last, but not least, we need to enable IP forwarding. IP forwarding is the process that enables IP packets to travel between network interfaces on the router. By default OpenBSD will not forward IP packets between various network interfaces. In other words, routing functions (also known as gateway functions) are disabled.

We can enable IP forwarding using the following commands:

 # sysctl net.inet.ip.forwarding=1
# echo 'net.inet.ip.forwarding=1' >> /etc/sysctl.conf

Now OpenBSD will be able to forward IPv4 packets from one NIC to another. Or, as in our specific case with the four port NIC, from one port to another. Take a look at the man page if you need IPv6.

DHCP

Now we’re ready to setup the Dynamic Host Configuration Protocol (DHCP) service we will be running for our different PCs and devices attached to the different LANs. Before we begin make sure you have read and understood the different options in the dhcpd.conf man page.

We have the option to bind specific IP addresses to specific PCs or devices that connect to our different LAN ports. This is needed if we want to forward any traffic from the Internet to something like a web server. We can bind a specific IP address to a specific PC via the MAC address on the NIC of the relevant machine.

In this case I’ll reserve all IP addresses ranging from 10 to 254 for the DHCP, while I’ll leave the few left overs for any possible fixed addresses I might need.

Edit /etc/dhcpd.conf with your favorite text editor and set it up to suit your needs.

subnet 192.168.1.0 netmask 255.255.255.0 {
    option domain-name-servers 192.168.1.1;
    option routers 192.168.1.1;
    range 192.168.1.10 192.168.1.254;
}
subnet 192.168.2.0 netmask 255.255.255.0 {
    option domain-name-servers 192.168.2.1;
    option routers 192.168.2.1;
    range 192.168.2.10 192.168.2.254;
}
subnet 192.168.3.0 netmask 255.255.255.0 {
    option domain-name-servers 192.168.3.1;
    option routers 192.168.3.1;
    range 192.168.3.10 192.168.3.254;
    host web.example.com {
        fixed-address 191.168.3.2;
        hardware ethernet 61:20:42:39:61:AF;
        option host-name "webserver";
    }
}

The option domain-name-servers line specifies the DNS server we will be running on our router.

Also, if you don’t want to segment the network into the different parts, but only want to have one LAN then you can just leave out the other subnets so you just have this:

subnet 192.168.1.0 netmask 255.255.255.0 {
    option domain-name-servers 192.168.1.1;
    option routers 192.168.1.1;
    range 192.168.1.10 192.168.1.254;
}

Then we just need to make sure we enable and start the dhcpd service:

# rcctl enable dhcpd
# rcctl start dhcpd

PF – A packet filtering firewall

A packet-filtering firewall examines each packet that crosses the firewall and decides whether to accept or deny individual packets, based on examining fields in the packet’s IP and protocol headers, according to the set of rules that you specify.

Packet filters work by inspecting the source and destination IP and port addresses contained in each Transmission Control Protocol/Internet Protocol (TCP/IP) packet. TCP/IP ports are numbers that are assigned to specific services that identify which service each packet is intended for.

A common weakness in simple packet filtering firewalls is that the firewall examines each packet in isolation without considering what packets have gone through the firewall before and what packets may follow. This is called a “stateless” firewall. Exploiting a stateless packet filter is fairly easy. PF from OpenBSD is not a stateless firewall, it is a stateful firewall.

A stateful firewall keeps track of open connections and only allows traffic that either matches an existing connection or opens a new allowed connection. When state is specified on a matching rule the firewall dynamically generates internal rules for each anticipated packet being exchanged during the session. It has sufficient matching capabilities to determine if a packet is valid for a session. Any packets that do not properly fit the session template are automatically rejected.

One advantage of stateful filtering is that it is very fast. It allows you to focus on blocking or passing new sessions. If a new session is passed, all its subsequent packets are allowed automatically and any impostor packets are automatically rejected. If a new session is blocked, none of its subsequent packets are allowed. Stateful filtering also provides advanced matching abilities capable of defending against the flood of different attack methods employed by attackers.

Network Address Translation (NAT) enables the private network behind the firewall to share a single public IP address. NAT allows each computer in the private network to have Internet access, without the need for multiple Internet accounts or multiple public IP addresses. NAT will automatically translate the private network IP address for computers or devices on the network to the single public IP address as packets exit the firewall bound for the Internet. NAT also performs the reverse translation for returning packets. With NAT you can redirect specific traffic, usually determined by port number or a range of port numbers, coming in on your public IP address from the Internet to a specific server or servers located somewhere in your local network.

Packet Filter (PF) is OpenBSD’s firewall system for filtering TCP/IP traffic and doing NAT. PF is also capable of normalizing and conditioning TCP/IP traffic, as well as providing bandwidth control and packet prioritization.

PF is actively maintained and developed by the entire OpenBSD team.

PF setup

Before we begin I assume that you have read both the PF – User’s guide and the pf.conf man page, especially the man page is very important. Even if you don’t understand all the different options make sure you read the documentation! For a complete and in-depth view of what PF can do, take a look at the pf man page.

Also, let me start by saying that even though the syntax for PF is very readable, it is very easy to make mistakes when writing firewall rules. Even senior and experienced system administrators makes mistakes when writing firewall rules.

Writing firewall rules requires that you carefully plan out your goals, understand how to implement the different rules in order to achieve the desired results, and at the same time take your precautions against doing it wrong and accidentally logging yourself out 🙂 I think we’ve all done that at one time or another, whether in haste, tiredness, or just by mistake.

Clarifications

I want to start by clarifying some of the common default settings and keywords in PF.

The format is either that we we filter on the destination:

from source IP to destination IP [on]  port

Or it is filtering on the source:

from source IP [on] port to destination

Please note that the [on] part is not part of the syntax.

  • quick
    • If a packet matches a passblock or match rule, with the quick modifier, the packet is passed without inspecting subsequent filter rules. The rule with the quick modifier becomes the last matching rule.
  • keep state
    • You don’t need to specify the keep state modifier for specific pass or block rules. The first time a packet matches a pass or block rule, a state entry is created by default.Only if no rule matches a packet, the default action is to pass the packet without creating a state.
  • on interface/any
    • This rule applies only to packets coming in on, or going out through, this particular interface or interface group.The on any modifier – will match any existing interface except loopback ones.
  • inet/inet6
    • The inet and inet6 modifiers means that this rule applies only to packets coming in on, or going out through, this particular routing domain, meaning IPv4 or IPv6.You can apply rules to specific routing domains without specifying the NIC. In that case the rule will match all traffic of that particular nature on all NICs. By specifying inet you explicitly address IPv4 traffic only.
  • proto
    • Protocol limiting is done using the proto modifier. A rule applies only to packets of this protocol, other protocols are not affected. You can lookup protocols in /etc/protocols. Common protocols are ICMPTCP, and UDP.
  • in and out
    • This is one of the easiest parts of traffic direction to get wrong. A packet always comes in on, or goes out through, the Ethernet port on the Ethernet interface. in and out apply to incoming and outgoing packets through the physical Ethernet port where the Ethernet cable is attached. If neither are specified, the rule will match packets in both directions.in and out is never used to deal with traffic going from one NIC to another NIC, that is done with network address translation (NAT), using the options nat-to and rdr-toin and out only deals with traffic in and out from the physical Ethernet port on the same card.
  • from and to
    • The from and to rule modifiers apply only to packets with the specified source and destination addresses and ports. Both the hostname or IP address, port, and OS specifications are optional.When we’re dealing with a router with multiple NICs it’s easy to think like this: I want to pass in packets from the external interface (the NIC attached to the Internet) and then have them go to the first LAN interface and from there out to a specific PC on that LAN, meaning we follow the “trail of data” in our minds, and then write that out into something like this: pass in on $ext_if from $ext_if to $p_lan port 80. But this will not make the HTTP traffic “magically” appear on port 80 on the LAN with a PC attached with a specific IP address. We would also require a specific pass out rule and furthermore need to determine exactly on which machine we want the data to end up. Unless you are really dealing with a very specific requirement, you never need such rules in your ruleset! The antispoof and scrub features of PF will protect your internal network very well and with a basic setup of correct network address translation (NAT), with the nat-to option, and redirection with the rdr-to option, PF will handle the packages from the inside to the outside and vice versa.The all parameter is equivalent to writing from any to anyWithout explicitly declaring the direction, the default is from any to any. This rule: pass in on $p_lan proto udp to port dns translates into this: pass in on em3 inet proto udp from any to any port = 53

      There is also no need to use to any port dns, the any part is the default. You do however need the to port dns

  • nat-to and rdr-to
    • Network address translation (NAT) options modify either the source or destination address and port of the packets associated with a stateful connection. PF modifies the specified address and/or port in the packet and recalculates IP, TCP, and UDP checksums as necessary.A nat-to option specifies that IP addresses are to be changed as the packet traverses the given interface. This technique allows one or more IP addresses on the translating host (the OpenBSD router) to support network traffic for a larger range of machines on an inside network, i.e. a LAN.The nat-to option is usually applied outbound, meaning redirected from the inside network to the Internetnat-to to a local IP address is not supported.

      The rdr-to option is usually applied inbound, meaning redirected from the Internet into the inside network.

  • List items and range of addresses and ports
    • When you need to specify multiple items, e.g. multiple port numbers, you can separate them with a whitespace or a comma. Like this port { 53 853 } or like this port { 53, 853 }Ranges of addresses are specified using the <- operator. E.g. 192.168.1.2 - 192.168.1.10 means all IP addresses from 192.168.1.2 until 192.168.1.10, both included.Range of ports has multiple parameters, look at the man page for pf.conf and search for the text Ports and ranges of ports are specified using these operators.

WARNING:
Please note that each time a packet processed by the packet filter comes in on or goes out through an interface, the filter rules are evaluated in sequential order, from first to last. For block and passthe last matching rule decides what action is taken. If no rule matches the packet, the default action is to pass the packet without creating a state. For match, rules are evaluated every time they match.

Domain name or hostname resolution

If you decide to use hostnames and/or domain names in your PF setup you need to know that all domain name and hostname resolution is done at ruleset load-time. This means that when the IP address of a host or a domain name changes, the ruleset must be reloaded for the change to be reflected in the kernel. It is not such that each time a specific rule runs, that has a hostname or domain name listed, that PF will do a new DNS lookup for that particular hostname or domain name. DNS lookup only happens when the ruleset is loaded.

This also means that you must make sure that the DNS server you’re using is up and running before PF is started, otherwise PF will fail at loading the ruleset because it cannot resolve the hostname or domain name.

On OpenBSD PF starts before Unbound or any other installed DNS server, which is the correct thing to do from a security perspective.

I advice that you avoid using hostnames or domain names when using PF rules and stick to IP addresses if possible. It is possible to use hostnames and domain names, but direct IP addressing is by far the easiest and safest.

The ruleset

It is a good idea to test out your ruleset on a test machine. There is almost always more than one way to achieve the same result. Also, never write new rulesets on a remote device you are actively logged into unless you know what you’re doing. Getting logged out of a remote machine is never any fun.

Try to figure out how you can keep your rules as clear and as short as possible, using default values whenever possible. Yet, don’t be afraid to specify modifiers that makes the rules more clear to understand, even though they are identical to the default values. A default value might be any to any, and you can leave that out then, but it might be easier to understand a particular rule when it actually says any to any in the text of the configuration file.

You can always parse the ruleset and check for errors without it being deployed with the command pfctl -nf /etc/pf.conf. Once you have loaded a ruleset with the command pfctl -f /etc/pf.conf you can view how the ruleset has been translated by PF with the pfctl -s rules command, which I advice that you to use regularly.

I prefer to keep my rulesets organized with sections and comments so I’ll do the same in this example.

Use your favorite text editor and open up the file /etc/pf.conf.

First we setup some macros to better remember what NICs we use for what. Using macros for the NICs also makes it easy to change the driver name of the card if we ever buy a new card, or multiple new cards.

#---------------------------------#
# Macros
#---------------------------------#

ext_if="em0" # External NIC connected to the ISP modem (Internet).
g_lan="em1"  # Grown-ups LAN.
c_lan="em2"  # Children's LAN.
p_lan="em3"  # Public LAN.

Next we set up a table for non-routable IP address. We do that because a very common network misconfiguration is the kind that lets traffic with non-routable addresses out to the Internet. We will use the table in our ruleset to block any attempt to initiate contact to non-routable addresses through the routers external interface.

#---------------------------------#
# Tables
#---------------------------------#

# This is a table of non-routable private addresses.
table <martians> { 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16     \
                   172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 224.0.0.0/3 \
                   192.168.0.0/16 198.18.0.0/15 198.51.100.0/24        \
                   203.0.113.0/24 }

WARNING:
Please note that macros and tables always goes at the top of /etc/pf.conf.

Then we begin with a default blocking policy and setup a couple of protective features.

#---------------------------------#
# Protect and block by default
#---------------------------------#
set skip on lo0
match in all scrub (no-df random-id max-mss 1440)

# Spoofing protection for all interfaces.
antispoof quick for { $g_lan $c_lan $p_lan }
block in from no-route
block in quick from urpf-failed

# Block non-routable private addresses.
# We use the "quick" parameter here to make this rule the last.
block in quick on $ext_if from <martians> to any
block return out quick on $ext_if from any to <martians>

# Default blocking all traffic in on all LAN NICs from any PC or device.
block return in on { $g_lan $c_lan $p_lan }

# Default blocking all traffic in on the external interface from the Internet.
# Let's log that too.
block drop in log on $ext_if

# Default allow all NICs to pass out IPv4 and IPv6 data through the Ethernet port.
pass out

My ISP hasn’t rolled out IPv6 yet so I don’t use it. If you don’t need it either, you can change the pass out parameter to pass out inet

scrub enables a “clean up” of packet content, causing fragmented packets to be assembled. scrub also provides some protection against some kinds of attacks based on incorrect handling of packet fragments.

The antispoof modifier is a very important protection. Spoofing is when someone fakes an IP address. The antispoof modifier expands to a set of filter rules that will block all traffic with a source IP from the network, directly connected to the specified interface, from entering the system through any other interface. This is sometimes referred to as “bleeding over” or “bleeding through”.

The above antispoof directive is translated by PF into the following:

block drop in quick on ! em1 inet from 192.168.1.0/24 to any
block drop in quick inet from 192.168.1.1 to any
block drop in quick on ! em2 inet from 192.168.2.0/24 to any
block drop in quick inet from 192.168.2.1 to any
block drop in quick on ! em3 inet from 192.168.3.0/24 to any
block drop in quick inet from 192.168.3.1 to any

If we take, e.g., the em1 NIC rule block drop in quick on ! em1 inet from 192.168.1.0/24 to any then that means: block any traffic from the network with IP addresses ranging from 192.168.1.1 to 192.168.1.255, that doesn’t originate from the em1 interface itself, and that is going anywhere. Since the em1 interface is the NIC in charge of all IP addresses in that specific range, then no traffic with such an IP address should originate from any other NIC.

WARNING:
Usage of antispoof should be restricted to interfaces that have been assigned an IP address, meaning that if you have unused NICs, or ports on a NIC, make sure to assign an IP address to each or don’t include these in the antispoof option.

The IP addresses in the martians macro constitutes the RFC1918 addresses which are not to be used on the Internet. Traffic to and from such addresses is dropped on the routers external interface.

Now we get to the LAN segment for the grown-ups in the house.

#---------------------------------#
# Grown-ups LAN Setup
#---------------------------------#

# Allow any PC on the grown-ups LAN to send data in through the NICs Ethernet
# port.
pass in on $g_lan

# Always block DNS queries not addressed to our DNS server.
block return in quick on $g_lan proto { udp tcp } to ! $g_lan port { 53 853 }

# Block the network printer from "phoning home".
block in quick on $g_lan from 192.168.1.8

In this example we have a network printer attached to the grown-ups network that we don’t want to access the Internet or anywhere else, just in case it has some kind of spying firmware. We do that by saying, block all data coming in on em1 from the IP address 192.168.1.8 going to any IP address.

Also we make sure that all DNS requests on port 53 (regular DNS) and 853 (DNS over TLS) are always blocked if they are not addressed to our DNS server.

NOTE:
Previously I used to redirect all traffic on port 53 not addressed to our DNS server back to our DNS server. I did that because when we block the DNS request on port 53, whether with a return or drop, the request will timeout on the client, which will make most clients cause a delay in the reply. I have since changed it to a block because I believe that it is the more correct approach. All clients need to realize that communication on port 53 is blocked, unless it is addressed to our DNS server. This is also important when we’re troubleshooting our network. If we get a redirected reply from our DNS server we might not notice that we have been redirected.

NOTE:
DNS primarily uses the User Datagram Protocol (UDP) on port number 53 to serve requests, but when the length of the answer exceeds 512 bytes and both client and server support EDNS, larger UDP packets are used. Otherwise, the query is sent again using the Transmission Control Protocol (TCP). Some DNS resolver implementations use TCP for all queries. As such we need both the UDP and TCP protocols in rule for port 53.

The children’s part of the LAN is very similar (a more restricted setup is demonstrated in the children’s whitelist section).

#---------------------------------#
# Children's LAN Setup
#---------------------------------#

# Allow any PC on the children's LAN to send data in through the NICs Ethernet
# port.
pass in on $c_lan

# Always block DNS queries not addressed to our DNS server.
block return in quick on $c_lan proto { udp tcp} to ! $c_lan port { 53 853 }

Then we get to the LAN with a publicly facing web server. Since we have a publicly facing web server we set up a couple of restrictions. Should the web server ever get compromised the intruder will have a hard time figuring out what else is located on our internal network.

We block all access except for DHCP, in order for the web server to get an IP address from our router, and then only manually open other things up whenever we need to update the machine or do something else. I have commented out the options we need, when we need to open things up, leaving the restricting parts enabled. When you need to update the server you open up for DNS and general access to the Internet.

NOTE:
Rather than manually changing the ruleset each time we need to open up for the web server to be updated, we can also use an anchor, but for simplicity’s sake we don’t do that here.

#---------------------------------#
# Public LAN Setup
#---------------------------------#

# Allow access to DHCP.
pass in on $p_lan inet proto udp from any port 67

# Allow access to the Internet by removing the comment.
# This rule will also block access to our two other segments, the grown-ups LAN
# and the children's LAN.
# pass in on $p_lan to { ! 192.168.1.0/24 ! 192.168.2.0/24 }

# Always block DNS queries not addressed to our DNS server.
block return in quick on $p_lan proto { udp tcp} to ! $p_lan port { 53 853 }

In this setup, the only thing that the web server can do is to get an IP address from the router. It cannot ping or otherwise contact any other machine on our internal network, and it cannot access the Internet unless the comment is removed from the rule pass in on $p_lan to { ! 192.168.1.0/24 ! 192.168.2.0/24 }.

These restrictions doesn’t mean that the web server cannot respond to oncoming requests. The reason for this is that we will add a rule in our redirect section in a moment that allows clients on the Internet to access our publicly faced web server, when this happens the response from the web server will become a part of the state established by the original connection from the client from outside, which the web server will then be permitted to respond to.

Now we come to the network address translation (NAT). This is where the router routes packages from one segment of the network to another, in this specific case from our internal network to the Internet outside, and then any reply coming from the Internet outside, back in to the originator of the transmission. I prefer the :network parameter, which translates to the network(s) attached to the interface, and I prefer to be specific with one rule for each relevant segment.

#---------------------------------#
# NAT
#---------------------------------#

pass out on $ext_if inet from $g_lan:network to any nat-to ($ext_if)
pass out on $ext_if inet from $c_lan:network to any nat-to ($ext_if)
pass out on $ext_if inet from $p_lan:network to any nat-to ($ext_if)

PF will keep a track of all traffic and when, e.g., a web browser on the grown-ups LAN requests a web page on some website on the Internet, the response from the web server on the Internet gets routed through our external interface through to our internal grown-ups LAN interface and then straight to the PC that originated the request.

Last we get to the redirecting part of our ruleset. This is where we allow traffic from the Internet outside in to our publicly facing web server on the public LAN. You should, of course, leave this part out if you don’t have any publicly facing servers that requires redirection. In this example I’m only allowing IPv4 traffic.

#---------------------------------#
# Redirects
#---------------------------------#

# Our web server - let the Internet access it.
pass in on $ext_if inet proto tcp to $ext_if port { 80 443 } rdr-to 192.168.3.2

WARNING:
Redirects always goes last in the ruleset!

That’s it for our basic setup of firewall rules.

The children’s whitelist

If you want to block the entire Internet for the children, except for perhaps a few websites or perhaps a few game servers, you need to figure out what the IP addresses of those services are and create a whitelist using those IP addresses.

If it is a single website with a single IP address it is very easy and you can do it with this rule placed last in the children’s block (you need to replace the x.x.x.x part with the relevant IP address):

#---------------------------------#
# Children's LAN Setup
#---------------------------------#

# Allow any PC on the children's LAN to only reach x.x.x.x.
pass in on $c_lan to x.x.x.x

# Always block DNS queries not addressed to our DNS server.
block return in quick on $c_lan proto { udp tcp} to ! $c_lan port { 53 853 }

If the website has multiple IP addresses you need to figure out what those are. Sometimes a domain name lookup can reveal all the relevant IP addresses at once. At other times you need to repeat the lookup multiple times at different intervals in the day in order to get the full range of IP addresses. You can do that by setting up an automated script.

Sometimes you may need to contact the relevant company and ask if you can get the IP range for your whitelist (some companies keep the information public, others refuse to release the information out of fear for malicious usage). Once you have determined what the IP range is you can put those into a PF table and then use that.

In this example we add a new table to the table section of the rules and then change the settings in the children’s rules.

#---------------------------------#
# Tables
#---------------------------------#

# This is a table of non-routable private addresses.
table <martians> { 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16     \
                   172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 224.0.0.0/3 \
                   192.168.0.0/16 198.18.0.0/15 198.51.100.0/24        \
                   203.0.113.0/24 }

# Whitelist for the children.
table <whitelist> { x.x.x.x y.y.y.y z.z.z.z }

And then in the children’s section:

#---------------------------------#
# Children's LAN Setup
#---------------------------------#

# Allow any PC on the children's LAN to only access whitelisted IPs.
pass in on $c_lan to <whitelist>

# Always block DNS queries not addressed to our DNS server.
block return in quick on $c_lan proto { udp tcp} to ! $c_lan port { 53 853 }

It is not always possible to get all the needed IP addresses into a whitelist all at once, but by monitoring the network, using e.g. tcpdump, when the game is trying to access a server, you can put together a working list, bit by bit.

Using a persistent table

Another approach to IP collecting is to use a persistent table in combination with /etc/rc.local and domain name lookups. /etc/rc.local is only run after PF is started and as such problems with domain name resolving will not cause PF any problems.

Should you want to run with the persistent table solution you can do it by adding a persistent table to the table section in /etc/pf.conf:

table <whitelist> persist

In the children’s section you still need to pass data in that goes to the whitelist like in the above:

pass in on $c_lan to <whitelist>

Then in /etc/rc.local you can add the following command:

pfctl -t whitelist -T add foo.bar

Where foo.bar is the domain you want PF to lookup.

Whenever your kids cannot get access because the valid IP addresses might have changed, you can login to the firewall and then manually update the table with more IP addresses by running the command manually:

# pfctl -t whitelist -T add foo.bar

If you want to see what has been added to the list you can do it with:

# pfctl -t whitelist -T show 74.6.143.25
74.6.143.26
74.6.231.20
74.6.231.21
98.137.11.163
98.137.11.164
216.58.208.110
2001:4998:24:120d::1:0
2001:4998:24:120d::1:1
2001:4998:44:3507::8000
2001:4998:44:3507::8001
2001:4998:124:1507::f000
2001:4998:124:1507::f001
2a00:1450:400e:80e::200e

In the example above I am using IP addresses from yahoo.com.

Eventually you can add all the IP addresses you collect (before they get flushed) into a physical file as the persist option can take input from a file as well:

table <whitelist> persist file "/etc/pf-whitelist.txt"

NOTE:
The file will not get IP addresses added using the add option to pfctl. A persistent table either resides in memory or on a file, but the add option cannot write to disk, only to memory. A persistent table from a file is one you need to manually edit with a text editor.

Loading the rules

Once you have finished setting up your ruleset you can test it with:

# pfctl -nf /etc/pf.conf

If all is well, you load the ruleset by removing the -n option:

# pfctl -f /etc/pf.conf

Take a look at the translated result with:

# pfctl -s rules

Logging and monitoring

This is an example output from the PF log of blocked attempts to access the external interface on a setup of mine. I have cleaned out the output a bit and removed some specific data, and 0.0.0.0 is of course not my public IP address, but you already knew that right 😉

# tcpdump -n -e -ttt -r /var/log/pflog 23:11:12 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.3422: S 1501043655:1501043655(0) win 1024
23:11:12 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.3481: S 311078394:311078394(0) win 1024
23:11:31 rule 14/(match) block in on em0: 176.214.44.229.25197 > 0.0.0.0.23: S 2084440900:2084440900(0) win 33620
23:11:33 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.3431: S 2774981044:2774981044(0) win 1024
23:11:43 rule 14/(match) block in on em0: 81.68.114.52.17191 > 0.0.0.0.23: S 1346864438:1346864438(0) win 26375
23:12:08 rule 14/(match) block in on em0: 193.27.229.26.53865 > 0.0.0.0.443: S 1057596009:1057596009(0) win 1024
23:12:31 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.4186: S 1233742605:1233742605(0) win 1024
23:12:44 rule 14/(match) block in on em0: 74.120.14.70.65509 > 0.0.0.0.9125: S 1836577847:1836577847(0) win 1024 <mss 1460> [tos 0x20]
23:12:44 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.4128: S 2112968453:2112968453(0) win 1024
23:13:15 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.3669: S 3627248539:3627248539(0) win 1024
23:13:19 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.3654: S 3889665614:3889665614(0) win 1024
23:13:29 rule 14/(match) block in on em0: 45.129.33.129.42239 > 0.0.0.0.4997: S 2249816896:2249816896(0) win 1024
23:13:37 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.3612: S 3797528151:3797528151(0) win 1024
23:14:03 rule 14/(match) block in on em0: 190.207.89.17.64372 > 0.0.0.0.445: S 1097568353:1097568353(0) win 8192 <mss 1460,nop,wscale 2,nop,nop,sackOK> (DF)
23:14:15 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.4219: S 2834775769:2834775769(0) win 1024
23:14:39 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.3702: S 1855726637:1855726637(0) win 1024
23:14:39 rule 14/(match) block in on em0: 45.129.33.4.45980 > 0.0.0.0.4210: S 3052103070:3052103070(0) win 1024

As you can see it’s quite busy, and I have nothing running that is facing the Internet on that setup.

You can also monitor PF in real time with:

# tcpdump -n -e -ttt -i pflog0

DNS

Domain Name Service (DNS) is used to translate a domain name into an IP address or vise versa. For example, when you type wikipedia.org in your web browsers address field, an authoritative DNS server translates the domain name “wikipedia.org” to an IPv4 address such as 91.198.174.192 and/or IPv6 address such as 2620:0:862:ed1a::1.

DNS is also used, among many other things, to store information about which mail servers a specific domain name belongs to, if any.

If you’re running a UNIX-like operating system, you can start up a terminal and try to perform a manual domain name lookup with host:

$ host wikipedia.org wikipedia.org has address 91.198.174.192
wikipedia.org has IPv6 address 2620:0:862:ed1a::1
wikipedia.org mail is handled by 10 mx1001.wikimedia.org.
wikipedia.org mail is handled by 50 mx2001.wikimedia.org.

NOTE:
If you don’t have host installed, depending on what platform you’re on, you might need to install bind or dnsutils. You can also use something like dig, also from bind, or drill from ldns

The following list describes some of the terms associated with DNS:

  • Forward DNS
    • Mapping of hostnames and domain names to IP addresses.
  • Reverse DNS
    • Mapping of IP addresses to hostnames and domain names.
  • Resolver
    • A system through which a machine queries a name server for zone information, i.e. another name for a “DNS server”.
  • Root zone
    • The beginning of the Internet zone hierarchy. All zones fall under the root zone, similar to how all files in a file system fall under the root directory.

This is an example of zones:

  • . (a period) is how the root zone is usually referred to in documentation.
  • org. is a Top-Level Domain (TLD) under the root zone.
  • wikipedia.org. is a zone under the org. TLD.
  • 1.168.192.in-addr.arpa is a zone referencing all IP addresses which fall under the 192.168.1.* IP address space.

When a computer on the Internet needs to resolve a domain name the resolver breaks the name up into its labels from right to left. The first component, the Top-Level Domain (TLD), is queried using a root server to obtain the responsible authoritative server. Queries for each label return more specific name servers until a name server returns the answer of the original query.

Even though any local DNS server can implement its own private root name servers, the term “root name server” is used to describe the thirteen well-known root name servers that implement the root name space domain for the Internet’s official global implementation of the Domain Name System. Resolvers use a small 3 KB root.hints file, published by Internic, to bootstrap this initial list of root server addresses. For many pieces of software, including Unbound, this list is built into the software.

On the The Root Zone Database you can lookup the delegation details of top-level domains, including TLDs such as .com, .org, and country-code TLDs such as .uk and .de.

NOTE:
Since you can lookup delegation details of top-level domains, you might expect that it would be possible to go deeper and actually look up every domain that a particular domain server has registered in its database. Since we, for example, can get a list of the responsible top-level domain servers for the .dk TLD, we might expect that it is possible to query one of those listed name servers for its entire database of authoritative servers, and then query one of those for all registered domains in its database. But that’s not how DNS works. There are only two ways that a DNS servers complete database map can be obtained. Either you have to have access to the relevant zone files, or you need to physically construct a database by examining DNS traffic through a recursive DNS server and then reconstruct zone data based upon the data that is collected, until you get everything, which is highly unlikely that you ever will.

There are two DNS server configuration types:

  • Authoritative
    • Authoritative name servers publish IP addresses for domains under their authoritative control. These servers are listed as being at the top of the authority chain for their respective domains, and are capable of providing a definitive answer.Authoritative name servers can be primary name servers, also known as master servers, i.e. they contain the original set of data, or they can be secondary or slave name servers, containing data copies usually obtained from synchronization directly with the primary server.An authoritative name server is a name server that only gives answers to DNS queries from data that has been configured by an original source, for example, the domain administrator.

      Every DNS zone must be assigned a set of authoritative name servers. This set of servers is stored in the parent domain zone with name server (NS) records. An authoritative server indicates its status of supplying definitive answers, deemed authoritative, by setting a protocol flag, called the “Authoritative Answer” (AA) bit in its responses.

      You can use a network tool such as dig or drill to lookup a domain name, the tool will reply with an authoritative flag that reveals whether the DNS server you have queried is the authoritative one.

  • Recursive
    • Recursive servers, sometimes called “DNS caches” or “caching-only name servers”, provide DNS name resolution for applications, by relaying the requests of the client application to the chain of authoritative name servers to fully resolve a network name. They also (typically) cache the result to answer potential future queries within a certain expiration time period.Most Internet users access a public recursive DNS server provided by their ISP or a public DNS service provider.In theory, authoritative name servers are sufficient for the operation of the Internet. However, with only authoritative name servers operating, every DNS query must start with recursive queries at the root zone of the Domain Name System and each user system would have to implement resolver software capable of recursive operation. To improve efficiency, reduce DNS traffic across the Internet, and increase performance in end-user applications, the Domain Name System supports recursive resolvers.

      A recursive DNS query is one for which the DNS server answers the query completely by querying other name servers as needed.

A nameserver can be both authoritative and recursive at the same time, but it is recommended not to combine the configuration types. To be able to perform their work, authoritative servers should be available to all clients all the time. On the other hand, since the recursive lookup takes far more time than authoritative responses, recursive servers should be available to a restricted number of clients only, otherwise they are prone to distributed denial of service (DDoS) attacks.

NOTE:
If needed, I recommend that you read “How DNS Works” in chapter 6 of the Linux Network Administrators Guide. I also recommend that you read Domain Name Service (DNS) on Wikipedia.

I present to you, Unbound

Unbound is a recursive, caching and validating Open Source DNS resolver with the following features:

  • Cache with optional prefetching of popular items before they expire.
  • DNS over TLS (DoT) forwarding and server, with domain-validation.
  • DNS over HTTPS (DoH).
  • Query Name Minimization.
  • Aggressive Use of DNSSEC-Validated Cache.
  • Authority zones, for a local copy of the root zone.
  • DNS64.
  • DNSCrypt.
  • DNSSEC validating.
  • EDNS Client Subnet.

Unbound is designed to be fast and secure and it incorporates modern features based on open standards. Late 2019, Unbound was rigorously audited.

TIP:
One of the main reasons to use Ubound over several other simple caching-only resolvers, such as dnsmasq for example, is that if you do not use the forward option in Unbounds configuration, Unbound will query the root servers directly using their registered IP addresses listed in the Root Hints File. This will free you of your ISP DNS servers and any public DNS servers, such as Google or Cloudflare, and whatever data recording, selling and manipulation they’re doing is avoided. A simple caching server such as dnsmasq will always forward queries to another server, whereas Unbound queries the root servers directly and works its way down the domain chain until it gets the relevant record from the registered authoritative DNS server for the relevant domain. This means that the DNS server that specifically knows what you’re looking for is also the one that is authoritative to answer the question.

WARNING:
If you ISP is hijacking DNS traffic, Unbound will not help you in any way. See DNS hijacking for information on how you can determine if you DNS traffic is getting hijacked.

In our setup with Unbound, a query for a domain such as “wikipedia.org” will look like this:

  1. Your browser sends a query to the operating system with the question, “What is the IP address of wikipedia.org”?
  2. The operating system, more specifically the resolver routines in the C library, which provide access to the Internet Domain Name System, will then forward the DNS request to the domain name server(s) listed in /etc/resolv.conf (on UNIX-like operating systems).
  3. Unbound receives the query and first looks for “wikipedia.org” in its cache and if not found, Unbound queries one of the root servers listed in its Root Hints File for the top-level domain “.org”.
  4. The root server replies with a referral to the relevant servers for the “.org” top-level domain.
  5. Unbound then sends a query to one of the relevant servers asking for the authoritative DNS servers for “wikipedia.org”.
  6. The server replies with a referral to the authoritative name servers registered for “wikipedia.org”.
  7. Unbound then sends a query to one of those authoritative name servers and asks for the IP address for “wikipedia.org”.
  8. The authoritative name server replies by sending the IP address it has listed in its “A” and/or “AAAA” record for the domain “wikipedia.org”.
  9. Unbound receives the IP address from the authoritative name server and returns the answer to the client.
  10. If enabled, Unbound then caches the information for a pre-determined length of time for future queries for the same domain.

You can try to do a DNS trace yourself to see the above. I’m using drill in this example with the trace option enabled.

# drill -T wikipedia.org .       518400  IN      NS      l.root-servers.net.
.       518400  IN      NS      k.root-servers.net.
.       518400  IN      NS      e.root-servers.net.
.       518400  IN      NS      a.root-servers.net.
.       518400  IN      NS      m.root-servers.net.
.       518400  IN      NS      h.root-servers.net.
.       518400  IN      NS      i.root-servers.net.
.       518400  IN      NS      f.root-servers.net.
.       518400  IN      NS      c.root-servers.net.
.       518400  IN      NS      b.root-servers.net.
.       518400  IN      NS      g.root-servers.net.
.       518400  IN      NS      d.root-servers.net.
.       518400  IN      NS      j.root-servers.net.
org.    172800  IN      NS      a0.org.afilias-nst.info.
org.    172800  IN      NS      a2.org.afilias-nst.info.
org.    172800  IN      NS      b0.org.afilias-nst.org.
org.    172800  IN      NS      b2.org.afilias-nst.org.
org.    172800  IN      NS      c0.org.afilias-nst.info.
org.    172800  IN      NS      d0.org.afilias-nst.org.
wikipedia.org.  86400   IN      NS      ns0.wikimedia.org.
wikipedia.org.  86400   IN      NS      ns1.wikimedia.org.
wikipedia.org.  86400   IN      NS      ns2.wikimedia.org.
wikipedia.org.  600     IN      A       91.198.174.192

NOTE:
Unbound has the ability to validate the responses it receives as correct. This is usually accomplished using Domain Name System Security Extensions (DNSSEC) or by using 0x20-encoded random bits in the query to foil spoof attempts. With the exception of 0x20-encoded random bits, all the other validation settings such as harden-glue and hardened dnssec-stripped data are all enabled by default in Unbound on OpenBSD.

Blocking with DNS

DNS blocking, also called filtering, or DNS spoofing, is the process in which you supply the client that does the query with a “fake” reply. We block a request for a valid IP address either by replying with a NXDOMAIN, meaning non-existent domain, or with a redirect to another IP address than the intended by the owner of the domain.

This enables us to create a list, or multiple lists, of domains we want to block and rather than providing the user with the correct IP address for a certain domain, we return the message that the domain is “non-existent”, which will block the application for further communication to the intended destination.

Normally all DNS requests are send to port 53 using either the UDP or TCP protocol, and by setting up a DNS server, which is what we do with Unbound, and by making sure that all traffic to port 53 reaches our DNS server or otherwise gets blocked, we can make sure that all DNS replies originates from our internal Unbound server that is running on our OpenBSD router.

NOTE:
You cannot fully trust DNS blocking because DNS blocking can be circumvented. Even though we have a solid approach in place it is always possible for someone to use a VPN service to circumvent this setup. We’re not trying to build a 100% foolproof system – even though we will be looking a bit further into that a little later in the guide – we’re just trying to protect our families in better ways. There are also always other access points to the Internet we need to consider, such as phones, friends phones and houses, public Internet access, etc.

NXDOMAIN vs redirecting

When we want to block a domain using DNS we can choose between several methods, but the two most popular is to either redirect the DNS query to a local IP address, such as 127.0.0.1 or 0.0.0.0, or to reply with a Non-existent Internet Domain Names Definition (NXDOMAIN). The NXDOMAIN is a standard reply for a “non-existent Internet or Intranet domain name”. If the domain name is unable to be resolved using DNS, a condition called NXDOMAIN occurred.

We can try to resolve a non-existing domain with the host command:

$ host a1b7c3n9m3b0.com Host a1b7c3n9m3b0.com not found: 3(NXDOMAIN)

Since the domain name “a1b7c3n9m3b0.com” isn’t registered by anyone (at least not while I write this), we get a “NXDOMAIN” response.

We can also use drill. The relevant information from the output of drill is the rcode field in the “HEADER” section:

$ drill a1b7c3n9m3b0.com
;; ->>HEADER<<- opcode: QUERY, rcode: NXDOMAIN, id: 39710
...

Or if you prefer dig, then the relevant information is located in the status field in the “HEADER” section:

$ dig a1b7c3n9m3b0.com

; <<>> DiG 9.16.8 <<>> +search a1b7c3n9m3b0.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 48858
...

Using the NXDOMAIN reply is not only the correct way to block a domain, according to RFC 8020, but it is also the best way since a redirect to an IP address like 127.0.0.1 or 0.0.0.0 will simply make the client that initiated the DNS query talk to itself.

It may be that the browser will reply with something like: Firefox can't establish a connection to the server at 0.0.0.0.. However, because the IP address 0.0.0.0 simply translates to our local machine, we’re still able to ping that address as it is synonymous to pinging 127.0.0.1:

$ ping 0.0.0.0 PING 0.0.0.0 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.019 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.049 ms

As such I recommend that you use the NXDOMAIN reply, which is what we’re going to use in this tutorial.

TIP:
Unbound can handle huge lists of blocked domains with a NXDOMAIN reply, but it cannot handle large lists of domains that needs to be redirected very well. If for some reason you should insist on redirecting instead of using NXDOMAIN, I recommend you setup dnsmasq with the --addn-hosts=<file> option, then make dnsmasq listen on port 53 and have dnsmasq redirect all blocked domains, while it then forwards normal DNS queries to Unbound, setup to listen on a non-standard port, such as port 5353. Contrary to Unbound, dnsmasq can handle huge lists of redirects very well, but it cannot handle large lists of NXDOMAIN domains very well, it becomes extremely slow.

The problem with DNS over HTTPS (DoH)

With the introduction of DNS over HTTPS (DoH), DNS blocking has become much more difficult. And while I certainly respect the original idea behind the promotion of DoH from a privacy point of view, DoH is a bad construction from a security point of view, and it is the WRONG approach.

With the already growing number of public DNS servers capable of serving DNS over HTTPS, any application can now utilize DoH and completely circumvent private and enterprise level DNS blocking. Not only that, but DoH has opened the door wide up for application developers to setup their own DoH servers and have their applications use those instead of the regular DNS server attached to the internal network. This is especially problematic regarding proprietary sofware in which you not only cannot see the source code, but you can also not change any DoH settings.

Because of DoH we cannot simply block domains, like ad and porn, we must also begin blocking public DoH servers via the firewall too. However, while keeping a list of a growing number of IP addresses of public DoH servers is problematic enough, keeping a list of unknown public DoH servers, which might get utilized by proprietary software, like firmware in IoT devices, is impossible.

DoH has also been a complete nightmare for enterprises because it basically makes it possible to overwrite centrally-imposed DNS settings. This makes it impossible to provide filtering solutions, such as the one we’re making, with ad and porn blocking, and it also makes it impossible for system administrators to monitor DNS settings across operating systems to prevent DNS hijacking attacks. Having multiple applications with their own unique DoH settings is a nightmare.

DoH also completely messes up network analysis and monitoring of DNS traffic for security purposes. In 2019, Godlua, a Linux DDoS bot, was the first malware application seen using DoH to hide its DNS traffic.

Furthermore, and perhaps most important, DoH does NOT fully prevent the tracking of users. Some parts of the HTTPS connection are not encrypted, such as SNI fields (it’s slowly getting there though), OCSP connections, and of course the destination IP addresses, which in my humble opinion is the most crucial part of the communication that needs to be hidden!

People who truly need privacy, like journalists in countries with a privacy compromising policy, cannot trust DoH! The IP address of the destination server cannot be hidden with DoH, even if everything about the traffic itself is encrypted. If someone truly needs to encrypt communication the person needs a completely different strategy than DoH.

This makes me wonder who in the world thought that DoH was a good idea to begin with!? Did they not understand the basics behind communication with HTTPS, or has this agenda perhaps been pushed forward by a few private DNS service companies, such as Cloudflare, who gain profit by further collecting user data?

Some public DNS service providers state that from a privacy perspective DoH is better than the alternatives, such as DNS over TLS (DoT), as DNS queries are hidden within the larger flow of HTTPS traffic. This gives network administrators less visibility but provides users with more privacy.

That message is problematic. While it is true that the initial domain name lookup is hidden in the HTTPS traffic, the destination IP address provided by the DoH server isn’t. When the client application visits the destination IP address, both the source IP address and the destination IP addresses are logged at the ISP level (and possibly multiple other levels as well).

While it isn’t immediately possible to determine exactly what domain name the user is trying to reach on the destination web server, especially if the web server is running multiple domains under the same IP address, it is definitely neither impossible nor even difficult.

NOTE:
In the appendix you can find a section called Inspecting DNS over HTTPS (DoH), in which we will look at a demonstration on how the destination IP address is revealed in the DoH communication. You can also find a section called Blocking DNS over HTTPS (DoH) in which we use the PF firewall to block known public DoH servers.

Setting up Unbound

Basic settings

Setting up Unbound is very easy as Unbound not only comes with great defaults, but it is also very well documented. Before we begin I advice that you take a look at the OpenBSD man page for unboundunbound-checkconf and unbound.conf.

Because Unbound is chrooted on OpenBSD, the configuration file unbound.conf doesn’t reside in /etc, as it otherwise normally would, instead it resides in /var/unbound/etc/.

Copy the existing Unbound configuration file:

# mv /var/unbound/etc/unbound.conf /var/unbound/etc/unbound.conf.backup

Then use your favorite text editor and create a new /var/unbound/etc/unbound.conf file and populate it with the following contents:

server:

    # Logging (default is no).
    # Uncomment this section if you want to enable logging.
    # Note enabling logging makes the server (significantly) slower.
    # verbosity: 2
    # log-queries: yes
    # log-replies: yes
    # log-tag-queryreply: yes
    # log-local-actions: yes

    interface: 127.0.0.1
    interface: 192.168.1.1
    interface: 192.168.2.1
    interface: 192.168.3.1

    # In case you need Unbound to listen on an alternative port, this is the
    # syntax:
    # interface: 127.0.0.1@5353

    # Control who has access.
    access-control: 0.0.0.0/0 refuse
    access-control: 127.0.0.0/8 allow
    access-control: ::0/0 refuse
    access-control: ::1 allow
    access-control: 192.168.1.0/24 allow
    access-control: 192.168.2.0/24 allow
    access-control: 192.168.3.0/24 allow

    # "id.server" and "hostname.bind" queries are refused.
    hide-identity: yes

    # "version.server" and "version.bind" queries are refused.
    hide-version: yes

    # Cache elements are prefetched before they expire to keep the cache up to date.
    prefetch: yes

    # Our LAN segments.
    private-address: 192.168.0.0/16

    # We want DNSSEC validation.
    auto-trust-anchor-file: "/var/unbound/db/root.key"

# Enable the usage of the unbound-control command.
remote-control:
    control-enable: yes
    control-interface: /var/run/unbound.sock

I have commented the options above, but if you need further explanation for the configuration take a look at each setting in the man page for unbound.conf.

Logging is done to syslog by default. If you want to change that you can create a log file in Unbounds chroot and then have Unbound log to that:

# mkdir /var/unbound/log
# touch /var/unbound/log/unbound.log
# chown -R root._unbound /var/unbound/log
# chmod -R 774 /var/unbound/log

Then in the unbound.conf file, add the following options to the logging section:

logfile: "/log/unbound.log"
use-syslog: no
log-time-ascii: yes

NOTE:
We do not use the full path to the log file because Unbound is chrooted. With the logfile option above the log file ends up in /var/unbound/log/unbound.log.

Then restart Unbound:

# rcctl restart unbound

In the settings above I have allowed Unbound to listen on the loopback interface (127.0.0.1) in order for local network applications to be able to do lookups if needed. In /etc/resolv.conf on our OpenBSD router I have listed our Unbound DNS server as I don’t want anything on the router to query ISP DNS servers:

nameserver 127.0.0.1

If you are using DHCP on the external interface (the interface connected to your ISP modem or router) you need to make sure that dhclient doesn’t change /etc/resolv.conf. Edit /etc/dhclient.conf and add:

supersede domain-name-servers 127.0.0.1;

This will make sure that we only have our local DNS server listed.

Enable Unbound with:

# rcctl enable unbound

Whenever you change the Unbound configurations you can either just restart Unbound with:

# rcctl restart unbound

Or simply reload the configuration options afresh (this also flushes the cache):

# unbound-control reload

You can list the settings Unbound is started with by running the following command (this goes for any service running on OpenBSD):

# rcctl get unbound

If you want to get some statistical data, you can run:

# # unbound-control stats_noreset thread0.num.queries=2056
thread0.num.queries_ip_ratelimited=0
thread0.num.cachehits=678
thread0.num.cachemiss=1378
thread0.num.prefetch=15
thread0.num.expired=0
...

You can also get a dump of the cache:

# unbound-control dump_cache|less

If you want to see what name servers Unbound queries for a specific domain, you can do that with:

# unbound-control lookup wikipedia.org

Take a look at the man page for unbound-control for further options and commands.

Let’s block some domains!

Now we get to the interesting part about domain blocking.

I have created a simple shell script called DNSBlockBuster that automatically downloads a set of hosts files from various online sources, concatenates them into one, does some cleanup, and then convert the result into a domain block list for both Unbound and dnsmasq. It mainly blocks ads, porn sites and tracking.

With DNSBlockBuster you have the option to create a whitelist, should any of the domains listed in the hosts files be a false positive for you, and you can add your own blacklist in case you want to manually block some domains that aren’t listed in the hosts files. You can also easily add new block lists or remove any of the provided block lists.

You don’t need to use my script of course, but I will use the script in this tutorial.

Currently the script creates a huge domain list with almost two million domains listed and Unbound takes up about 705MB of memory in total when the entire block list is loaded.

In order to prevent Unbound from timing out during the loading of the list, edit /etc/rc.conf.local and add the following:

unbound_timeout=240

Then restart Unbound:

# rcctl restart unbound

Take a look at the Usage section in the documentation for DNSBlockBuster on how to use it. It’s easy and simple.

Once you have created your block list for Unbound place it in /var/unbound/etc/, then edit the Unbound configuration file /var/unbound/etc/unbound.conf and insert the following somewhere:

include: "/var/unbound/etc/unbound-blocked-hosts.conf"

Now reload Unbound with:

# unbound-control reload

If you run the top command in another terminal you will notice that Unbound takes up quite a bit of CPU while it is initially loading the list. Also notice the memory usage.

You can now test our DNS blocking by querying one of the blocked domains from the list:

$ drill 3lift.com ;; ->>HEADER<<- opcode: QUERY, rcode: NXDOMAIN, id: 55906
...

Then try the same with Cloudflares DNS server:

$ drill 3lift.com @1.1.1.1 ;; ->>HEADER<<- opcode: QUERY, rcode: NOERROR, id: 48771
...

As we can see from the queries, our DNS server blocks access to the domain 3lift.com by replying with a NXDOMAIN, while Cloudflares DNS server replies with the correct IP address.

DNS security

DNS security is a broad subject. In this section we’ll deal with a few of the topics that mostly concern us with regard to running our own DNS server.

The DNS protocol is unencrypted and does not, by default, account for any confidentiality, integrity or authentication. If you use an untrusted network or a malicious ISP, your DNS queries can be eavesdropped and the responses manipulated. Furthermore, ISPs can conduct DNS hijacking.

DNS hijacking

DNS hijacking means that the DNS queries you perform gets redirecting to another DNS server. This is typically done by redirecting all traffic on port 53 from one destination to another.

One of the simplest ways to determine whether your ISP is hijacking your DNS traffic is to query an authoritative DNS server directly.

We can use multiple tools for this. In this example we’ll first use drill. The options, in this example, are the same for dig. We’ll use the domain “wikipedia.org” again.

First we need to get the authoritative servers. They will appear in the “ANSWER SECTION”:

$ drill NS wikipedia.org ;; ->>HEADER<<- opcode: QUERY, rcode: NOERROR, id: 28789
;; flags: qr rd ra ; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;; wikipedia.org.       IN      NS

;; ANSWER SECTION:
wikipedia.org.  85948   IN      NS      ns2.wikimedia.org.
wikipedia.org.  85948   IN      NS      ns0.wikimedia.org.
wikipedia.org.  85948   IN      NS      ns1.wikimedia.org.

;; AUTHORITY SECTION:

;; ADDITIONAL SECTION:

;; Query time: 1 msec
;; SERVER: 127.0.0.1
;; WHEN: Thu Nov  5 07:53:19 2020
;; MSG SIZE  rcvd: 95

Then we need to query one of those authoritative servers directly. The important field to pay attention to is the flags in the “HEADER” field. In order for the answer to be authoritative the flag aa must be listed.

$ drill @ns1.wikimedia.org wikipedia.org ;; ->>HEADER<<- opcode: QUERY, rcode: NOERROR, id: 57611
;; flags: qr aa rd ; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;; wikipedia.org.       IN      A

;; ANSWER SECTION:
wikipedia.org.  600     IN      A       91.198.174.192

;; AUTHORITY SECTION:

;; ADDITIONAL SECTION:

;; Query time: 127 msec
;; SERVER: 208.80.153.231
;; WHEN: Thu Nov  5 07:56:10 2020
;; MSG SIZE  rcvd: 47

This shows that the reply we got was not hijacked as the reply was authoritative. Let’s try to give the Cloudflare public DNS server the same query:

$ drill @1.1.1.1 wikipedia.org ;; ->>HEADER<<- opcode: QUERY, rcode: NOERROR, id: 40562
;; flags: qr rd ra ; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;; wikipedia.org.       IN      A

;; ANSWER SECTION:
wikipedia.org.  555     IN      A       91.198.174.192

;; AUTHORITY SECTION:

;; ADDITIONAL SECTION:

;; Query time: 3 msec
;; SERVER: 1.1.1.1
;; WHEN: Thu Nov  5 08:02:58 2020
;; MSG SIZE  rcvd: 47

Notice how the aa flag is missing from the “HEADER” field. This means that the reply was not authoritative.

Another more simple tool is nslookup. Let’s first query for the authoritative name servers:

nslookup -querytype=NS wikipedia.org Server:         127.0.0.1
Address:        127.0.0.1#53

Non-authoritative answer:
wikipedia.org   nameserver = ns1.wikimedia.org.
wikipedia.org   nameserver = ns2.wikimedia.org.
wikipedia.org   nameserver = ns0.wikimedia.org.

Then let’s try to query our own DNS server for the domain:

$ nslookup wikipedia.org Server:         127.0.0.1
Address:        127.0.0.1#53

Non-authoritative answer:
Name:   wikipedia.org
Address: 91.198.174.192

Server:         ns2.wikimedia.org
Address:        91.198.174.239#53

Name:   wikipedia.org
Address: 91.198.174.192

The message Non-authoritative clearly demonstrates that the reply isn’t from an authoritative DNS server. That’s fine, we did query our own DNS server. Let’s try to query one of the authoritative servers directly:

$ nslookup wikipedia.org ns0.wikimedia.org Server:         ns0.wikimedia.org
Address:        208.80.154.238#53

Name:   wikipedia.org
Address: 91.198.174.192

The message Non-authoritative is gone, the reply we got was authoritative, which means that our DNS query was not hijacked.

I have now enabled a VPN service that I know intercepts DNS queries in order to protect customers against DNS leakage and I am now going to query one of the authoritative servers again:

$ nslookup wikipedia.org ns0.wikimedia.org Server:         ns0.wikimedia.org
Address:        208.80.154.238#53

Non-authoritative answer:
Name:   wikipedia.org
Address: 91.198.174.192
Name:   wikipedia.org
Address: 2620:0:862:ed1a::1

As expected the answer was not authoritative even though I queried the authoritative server directly. The DNS traffic was hijacked and the reply was redirected to another unknown DNS server.

DNS hijacking, whether performed by the ISP or someone else, is highly problematic. First of all, we cannot fully trust the answer we get from the DNS server. Secondly, even if the DNS reply does deliver untampered data, the DNS traffic has been hijacked for some unknown reason, which might be data collection and logging, or completely different.

NOTE:
Some ISPs such as Optimum Online, Comcast, Time Warner, Cox Communications, RCN, Rogers, Charter Communications, Verizon, Virgin Media, Frontier Communications, Bell Sympatico, Airtel, OpenDNS and others started the practice of DNS hijacking on non-existent domain names (NXDOMAIN) for making money by displaying advertisements. The DNS server redirected a request to a non-existing domain name to a fake IP address that contained a website with ads. I don’t know how many ISPs and public DNS service providers that still do that.

DNS hijacking prevention

If you have discovered that your DNS traffic on port 53 gets hijacked you basically only got three options in order to protect yourself:

  1. If you have the option then change your ISP! Your ISP should not be hijacking your DNS traffic.
  2. Setup your own remote DNS server on a hosting center that doesn’t hijack or block port 53. Then have your remote DNS server listen for DNS connections on a non-standard port and forward all your DNS queries to your remote DNS server.
  3. Use a trusted VPN that doesn’t hijack DNS traffic, or if it does, make sure you can trust their non-logging policy.

DNS spoofing

DNS spoofing, also referred to as DNS cache poisoning, is something different from DNS hijacking. While the traffic gets redirected from one destination to another in a DNS hijacking attack, it is the data itself that gets manipulated in a DNS spoofing attack. Often the two attack strategies are combined.

In a DNS spoofing attach, manipulated data is introduced into the DNS resolver’s cache, causing the name server to return an incorrect result, e.g. a wrong IP address.

DNS spoofing prevention

This kind of attack can be mitigated at the transport layer or application layer by performing end-to-end validation once a connection is established. A common example of this is the use of Transport Layer Security (TLS) and digital signatures.

Secure DNS (DNSSEC) uses cryptographic digital signatures signed with a trusted public key certificate to determine the authenticity of data. DNSSEC can protect against DNS spoofing, however many DNS administrators have still not implemented it.

As of 2020, all of the original TLDs support DNSSEC, as do country code TLDs of most large countries, but many country code TLDs still do not.

Appendix

Inspecting DNS over HTTPS (DoH)

I want to illustrate the fact that DoH doesn’t really provide any true privacy as both the source IP address and the destination IP address can be seen clearly in the HTTPS communication.

First I have made sure that DoH is disabled in Firefox, on one of the computers on the grown-ups LAN, and are monitoring traffic on the em1 interface with the usage of tcpdump. I have also enabled the log file on Unbound, just to avoid filling op syslog with too much DNS noise, and I am using tail to monitor the log.

I’ll head over to “wikipedia.org” in the browser and then see what the surveillance on the router reveals.

# tcpdump -n -i em1 src host 192.168.1.5 and not arp tcpdump: listening on em1, link-type EN10MB
23:30:33.494352 192.168.1.5.55724 > 192.168.1.1.53: 58136+ A? wikipedia.org.(31) (DF)
23:30:33.774439 192.168.1.5.58372 > 192.168.1.1.53: 58448+ A? www.wikipedia.org.(35) (DF)
23:30:34.184287 192.168.1.5.46639 > 192.168.1.1.53: 15167+ A? www.wikipedia.org.(35) (DF)
...
# tail -f /var/unbound/log/unbound.log Nov 05 23:30:33 unbound[12636:0] query: 192.168.1.5 wikipedia.org. A IN
Nov 05 23:30:33 unbound[12636:0] reply: 192.168.1.5 wikipedia.org. A IN NOERROR 0.097209 0 47
Nov 05 23:30:33 unbound[12636:0] query: 192.168.1.5 www.wikipedia.org. A IN
Nov 05 23:30:33 unbound[12636:0] reply: 192.168.1.5 www.wikipedia.org. A IN NOERROR 0.154989 0 80
Nov 05 23:30:34 unbound[12636:0] query: 192.168.1.5 www.wikipedia.org. A IN
Nov 05 23:30:34 unbound[12636:0] reply: 192.168.1.5 www.wikipedia.org. A IN NOERROR 0.000000 1 80
...

Naturally we’re seeing the query both on the interface traffic as well as in the Unbound log.

I have then enabled DoH and disabled regular DNS in firefox, by setting the value of network.trr.mode to 4. I have then changed the Network settings and set Cloudflare as the DoH provider.

TIP:
If you just enable DoH in Firefox via the preferences pane, Firefox will still use regular DNS as a fallback. In order to force Firefox to only use DoH you can set the value of network.trr.mode.

Type about:config in the URL bar and press Enter to access Firefox’s hidden configuration panel.

Step 2: Look for the setting network.trr.mode. This controls DoH support. This setting supports four values:

1 – DoH is disabled.
2 – DoH is enabled, but Firefox uses both DoH and regular DNS based on which returns faster query responses
3 – DoH is enabled, and regular DNS works as a backup
4 – DoH is enabled, and regular DNS is disabled
5 – DoH is disabled

Step 3: Look for the setting network.trr.bootstrapAddress. This controls the numerical IP address for your DoH server. Input the value of 1.1.1.1 into the field and press Enter.

This time I’ll visit “freebsd.org”.

# tcpdump -n -i em1 src 192.168.1.5 and not arp tcpdump: listening on em1, link-type EN10MB
00:21:10.944243 192.168.1.5.32856 > 1.1.1.1.443: P 2223446146:2223446202(56) ack 157857007 win 501 (DF)
00:21:10.948719 192.168.1.5.46584 > 96.47.72.84.80: S 922508523:922508523(0) win 64240 <mss 1460,sackOK,timestamp 1673624773 0,nop,wscale 7> (DF)
00:21:11.133801 192.168.1.5.33298 > 96.47.72.84.443: S 3275123911:3275123911(0) win 64240 <mss 1460,sackOK,timestamp 1673624958 0,nop,wscale 7> (DF)
...
# tail -f /var/unbound/log/unbound.log Nov 05 23:30:33 unbound[12636:0] query: 192.168.1.5 wikipedia.org. A IN
Nov 05 23:30:33 unbound[12636:0] reply: 192.168.1.5 wikipedia.org. A IN NOERROR 0.097209 0 47
Nov 05 23:30:33 unbound[12636:0] query: 192.168.1.5 www.wikipedia.org. A IN
Nov 05 23:30:33 unbound[12636:0] reply: 192.168.1.5 www.wikipedia.org. A IN NOERROR 0.154989 0 80
Nov 05 23:30:34 unbound[12636:0] query: 192.168.1.5 www.wikipedia.org. A IN
Nov 05 23:30:34 unbound[12636:0] reply: 192.168.1.5 www.wikipedia.org. A IN NOERROR 0.000000 1 80
...

This reveals, from the monitoring of the network interface, that a connection was made to Cloudflares DNS server on 1.1.1.1 on port 443 (HTTPS) and that we visited the IP destination address 96.47.72.84 right after. At the same time nothing has happened in the Unbound log, tail still just shows the previous query.

If we do a regular DNS query on the router we can verify that the IP address 96.47.72.84 is indeed the IP address for “freebsd.org”.

Furthermore, in this specific example we can even get straight to the website of “freebsd.org” just by inputting the destination IP address 96.47.72.84 into the browsers address field.

This demonstrates that even though DoH bypasses the regular DNS query, it is not able to hide the destination IP address that is still present in clear text in the communications traffic.

Blocking DNS over HTTPS (DoH)

Previously the DNSBlockBuster script already had some DoH domain names in the list, that I had randomly thrown in, but I have since removed DoH blocking from the DNS server as it really needs happen on the firewall level only.

Blocking DoH via domain names doesn’t make much sense in my humble opinion as a domain name has to be looked up in the first place. Most clients that use DoH has the host IP address for the DoH server encoded directly into the source code.

I have searched multiple sites on the Internet, but haven’t found a single up to date list of public DoH servers, so I have decided to make my own list called DoHBlockBuster. However, this is a tremendous task, something which I know I wont have time to keep updated in the future unless others pitch in, so if you have got some spare time, please help keep the lists updated (either make a pull request or send me an email). Also this list is in no way exhaustive.

If you don’t use IPv6 you can block all outgoing IPv6 traffic and then only use the IPv4 list from DoHBlockBuster. Change the pass out parameter, in the “Default protect and block” section of /etc/pf.conf, to pass out inet. That way you only allow outgoing IPv4 traffic and don’t need to specifically block IPv6 DoH IP addresses.

Download the lists from DoHBlockBuster and edit the lists to suit your needs and put them somewhere on disk.

I have made a subdirectory /etc/pf-block-lists where I place all IP block lists I need for PF.

Then create a persistent file for PF in the “Tables” section of /etc/pf.conf:

# Public DoH servers.
table <block_doh> persist file "/etc/pf-block-lists/dohblockbuster-ipv4.txt"

If you need IPv6 then add that too:

table <block_doh> persist file "/etc/pf-block-lists/dohblockbuster-ipv4.txt" file "/etc/pf-block-lists/dohblockbuster-ipv6.txt"

And then add a block to the “Protect and block by default” section of the firewall:

# Let's block DoH.
block in quick on { $g_lan $c_lan $p_lan } to <block_doh>

Reload with:

# pfctl -f /etc/pf.conf

Check the list with:

# pfctl -vvt block_doh -T show

If – after some time – you want to see what IP addresses that actually has been used in a blocking, you can filter the output:

# pfctl -vvt block_doh -T show | awk '/\[/ {p+=$4; b+=$6} END {print p, b}'

As mentioned previously, this solution doesn’t take unknown DoH servers into consideration. Also in order for the list to be effective, it needs to be kept up to date.

TODO

Planned upcoming improvements.

  • IPv6
  • More on network monitoring
  • Local search domain

If you have anything important or worth while you think this guide is missing, please let me know, I’ll look into it.

Read more