DNS Bypass Mitigation with RouterOS
Can we block unauthorized use of DoH and DoQ from clients on our network without breaking HTTP and QUIC? Let's find out...
Where it Began
A few years ago, I wrote up an article on Cisco Umbrella Branch. To summarize, the service provides enhanced security for clients by intercepting DNS requests rather than performing HTTPS interception. Since then, a number of other products have come along offering similar functionality, but they all have the same achilles' heel: the ability of clients to bypass the DNS process entirely.
This was nine years ago and DNS bypass, for the most part, could be eliminated by filtering the DNS ports and forcing the clients to use authorized resolvers within the organization. This approach wasn't going to last.
DNS over TLS (DoT) had already been published as RFC 7858 (later to include DNS over DTLS with RFC 8310) but we could add 853/tcp-udp to our DNS filtering rules and be done with that.
Enter DNS over HTTPS (DoH) with RFC 8484 and DNS over QUIC with RFC 9250. Now DNS is encapsulated in HTTPS/QUIC and there's no real way to block that without shutting down access to the bulk of Internet... or is there? Good question. I didn't really have time to dig into it at the time, so I moved on to other things.
I was chatting with Daryll Swer a few weeks ago and he mentioned that it is almost impossible to block DoH/DoQ. He's right, but he brought me back to thinking about this and whether there might be a way...
Configuration
Here's how I went about it.
DNS Resolution on the Local Router
Start with setting up the local network router to be the DNS server for clients on its networks. We can use anything we want here, but for this example, I'm going to use CIRA's Canadian Shield's "Protected" profile, which blocks known malware and phishing destinations.
I use a MikroTik RB5009 running RouterOS 7.19.3 as my office edge router, so I'll be using that for my examples.
/ip/dns/set servers=2620:10A:80BB::20,2620:10A:80BC::20 allow-remote-requests=yes
Obviously we don't want the local router's DNS to be exposed to the world, so we'll add a filter restricing access to the LAN interface list.
/ip/firewall/filter
add chain=input action=accept in-interface-list=interfaceListLan protocol=udp dst-port=53 comment="Permit inbound DNS traffic from LAN"
add chain=input action=drop protocol=udp dst-port=53 comment="Deny all other inbound DNS traffic"
Basic DNS/DoTLS/DoDTLS Filtering
Add filters to prevent client devices from connecting to unauthorized DNS, DoTLS or DoDTLS servers. These have well-known port/protocol combinations and just about any network device can build a filter for them, so easily done.
/ip/firewall/filter
add chain=forward action=drop protocol=tcp port=53 comment="Deny DNS (tcp) traffic through the router"
add chain=forward action=drop protocol=udp port=53 comment="Deny DNS (udp) traffic through the router"
add chain=forward action=drop protocol=tcp port=853 comment="Deny DoTLS traffic through the router"
add chain=forward action=drop protocol=udp port=853 comment="Deny DoDTLS traffic through the router"
The Challenge
DoH and DoQ are a bit harder. If we put the DNS resolver on the client's local network router and filter based on its DNS cache, this might be workable. Configure clients to use the local network router for DNS and set the local router to permit client connections only to addresses that have been successfully resolved by an authorized DNS. This is where we need some flexibility. How do we apply the entries in the DNS cache to the firewall?
Transposing the DNS Cache into an Address List
Enter RouterOS scripting. It's relatively easy to transpose the DNS cache to a firewall address list that we can reference in a filter rule. Something like this:
/system script
add name=systemScriptDnsCacheToAddressList source="# Transpose IPv4 DNS Cache to Firewall Address List\
\n\
\n:local addressList \"addressListDnsCache\"\
\n\
\n/ip/dns/cache\
\n\
\n:foreach cacheEntry in=[find where type=\"A\"] do={\
\n :local cacheEntryValues {[get \$cacheEntry data];[get \$cacheEntry ttl]};\
\n :if ( [/ip/firewall/address-list/find where address=(\$cacheEntryValues->0)] ) do={} else={\
\n /ip/firewall/address-list/add address=(\$cacheEntryValues->0) list=\$addressList timeout=(\$cacheEntryValues->1);\
\n }\
\n}\
\n\
\n# Transpose IPv6 DNS Cache to Firewall Address List\
\n\
\n:foreach cacheEntry in=[find where type=\"AAAA\"] do={\
\n :local cacheEntryValues {[get \$cacheEntry data].\"/128\";[get \$cacheEntry ttl]};\
\n :if ( [/ipv6/firewall/address-list/find where address=(\$cacheEntryValues->0)] ) do={} else={\
\n :if ((\$cacheEntryValues->1) > [:totime \"00:00:01\"]) do={\
\n /ipv6/firewall/address-list/add address=(\$cacheEntryValues->0) list=\$addressList timeout=(\$cacheEntryValues->1);\
\n }\
\n }\
\n}"
Here's a cleaner copy of the script itself without the RouterOS CLI control artificts:
# Transpose IPv4 DNS Cache to Firewall Address List
:local addressList "addressListDnsCache"
/ip/dns/cache
:foreach cacheEntry in=[find where type="A"] do={
:local cacheEntryValues {[get $cacheEntry data];[get $cacheEntry ttl]};
:if ( [/ip/firewall/address-list/find where address=($cacheEntryValues->0)] ) do={} else={
/ip/firewall/address-list/add address=($cacheEntryValues->0) list=$addressList timeout=($cacheEntryValues->1);
}
}
# Transpose IPv6 DNS Cache to Firewall Address List
:foreach cacheEntry in=[find where type="AAAA"] do={
:local cacheEntryValues {[get $cacheEntry data]."/128";[get $cacheEntry ttl]};
:if ( [/ipv6/firewall/address-list/find where address=($cacheEntryValues->0)] ) do={} else={
:if (($cacheEntryValues->1) > [:totime "00:00:01"]) do={
/ipv6/firewall/address-list/add address=($cacheEntryValues->0) list=$addressList timeout=($cacheEntryValues->1);
}
}
}
The above script will copy all DNS cache entries for A records and AAAA records as dynamic entries into the appropriate address lists for the filters. The script preserved the DNS cache entry's TTL in the dynamic entry of the address list, so it drops off of the address list at roughly the same time as it drops off of the DNS cache itself.
On my router, which only had a couple of hundred entries in the cache, the script completed in 0.2s with approximately 18% untilization on one of my router's CPU cores. It's got some impact, but nothing catastrophic.
I recommend keeping this function as close to the client device as possible to minimize the number of entries to be processed.
The Scheduler
The next step was to ensure that the address lists were kept up to date.
/system scheduler
add interval=1s name=systemSchedulerDnsCacheToAddressList on-event=systemScriptDnsCacheToAddressList
This scheduler entry runs the script every second, constantly refreshing the address list with new entries.
DoHTTPS/DoQUIC Filters
Lastly, we build the filters:
/ip firewall filter
add chain=forward action=accept in-interface-list=interfaceListLan protocol=tcp dst-port=443 dst-address-list=addressListDnsCache comment="Permit HTTPS traffic to authorized destinations"
add chain=forward action=accept in-interface-list=interfaceListLan protocol=udp dst-port=443 dst-address-list=addressListDnsCache comment="Permit QUIC traffic to authorized destinations"
add chain=forward action=reject in-interface-list=interfaceListLan protocol=tcp dst-port=443 comment="Deny other HTTPS traffic"
add chain=forward action=reject in-interface-list=interfaceListLan protocol=udp dst-port=443 comment="Deny other QUIC traffic"
These will permit HTTPS/QUIC connections to only the destinations that have been successfully resolved by the router itself. Everything else gets rejected.
Disclaimer
This is 100% a mad mage's proof of concept. If you're looking at doing something like this in production, definitely run it through a lot of testing first. I can tell you that it works in the lab, but we've all heard that story enough times to be wary. Be wary.
The Whisper in the Wires
For every privacy technology that comes out, an organizational workaround follows. For every workaround we build, another privacy technology comes along to challenge it. It's a never-ending game of Whac-A-Mole, but it's still kinda fun.