├── _config.yml ├── filtering.png ├── requirements.txt ├── Blockpage ├── blocked.png └── index.php ├── BIND ├── Zones │ ├── db.sinkhole │ ├── db.rpz.google │ ├── db.rpz.quad9 │ ├── db.rpz.safedns │ ├── db.rpz.strongarm │ ├── db.rpz.comodosecure │ ├── db.rpz.nortonconnectsafe │ └── db.rpz.safe └── named.conf ├── LICENSE ├── README.md ├── dnsMasterChef.py └── index.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-midnight -------------------------------------------------------------------------------- /filtering.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NavyTitanium/DNSMasterChef/HEAD/filtering.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dnslib==0.9.7 2 | pyasn==1.6.0b1 3 | IPy==0.83 4 | dnspython==1.15.0 5 | -------------------------------------------------------------------------------- /Blockpage/blocked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NavyTitanium/DNSMasterChef/HEAD/Blockpage/blocked.png -------------------------------------------------------------------------------- /BIND/Zones/db.sinkhole: -------------------------------------------------------------------------------- 1 | @ 8600 IN SOA sinkhole. root.sinkhole. (201702121 604800 86400 2419200 604800 ) 2 | @ 8600 IN NS LOCALHOST. 3 | @ IN A 192.168.1.220 4 | * A 192.168.1.220 -------------------------------------------------------------------------------- /Blockpage/index.php: -------------------------------------------------------------------------------- 1 | 2 |

Your query:
' . $escaped_url . '

'; 7 | echo '

Header:

'; 8 | echo ' 15 | -------------------------------------------------------------------------------- /BIND/Zones/db.rpz.google: -------------------------------------------------------------------------------- 1 | $TTL 300 2 | 3 | @ IN SOA localhost. need.to.know.only. ( 4 | 201702121 ; Serial number 5 | 3600 ; refresh 1 hour 6 | 600 ; retry 10 minutes 7 | 86400 ; expiry 1 week 8 | 600 ) ; min ttl 10 minutes 9 | 10 | @ IN NS LOCALHOST. 11 | 12 | ; IP Trigger Local-Data Action 13 | ; any answer containing IP range 14 | 32.7.0.0.127.rpz-ip CNAME googledrop.sinkhole. 15 | -------------------------------------------------------------------------------- /BIND/Zones/db.rpz.quad9: -------------------------------------------------------------------------------- 1 | $TTL 300 2 | 3 | @ IN SOA localhost. need.to.know.only. ( 4 | 201702121 ; Serial number 5 | 3600 ; refresh 1 hour 6 | 600 ; retry 10 minutes 7 | 86400 ; expiry 1 week 8 | 600 ) ; min ttl 10 minutes 9 | 10 | @ IN NS LOCALHOST. 11 | 12 | ; IP Trigger Local-Data Action 13 | ; any answer containing IP range 14 | 32.2.0.0.127.rpz-ip CNAME quad9drop.sinkhole. 15 | -------------------------------------------------------------------------------- /BIND/Zones/db.rpz.safedns: -------------------------------------------------------------------------------- 1 | $TTL 300 2 | 3 | @ IN SOA localhost. need.to.know.only. ( 4 | 201702121 ; Serial number 5 | 3600 ; refresh 1 hour 6 | 600 ; retry 10 minutes 7 | 86400 ; expiry 1 week 8 | 600 ) ; min ttl 10 minutes 9 | 10 | @ IN NS LOCALHOST. 11 | 12 | ; IP Trigger Local-Data Action 13 | ; any answer containing IP range 14 | 32.4.0.0.127.rpz-ip CNAME safednsdrop.sinkhole. 15 | -------------------------------------------------------------------------------- /BIND/Zones/db.rpz.strongarm: -------------------------------------------------------------------------------- 1 | $TTL 300 2 | 3 | @ IN SOA localhost. need.to.know.only. ( 4 | 201702121 ; Serial number 5 | 3600 ; refresh 1 hour 6 | 600 ; retry 10 minutes 7 | 86400 ; expiry 1 week 8 | 600 ) ; min ttl 10 minutes 9 | 10 | @ IN NS LOCALHOST. 11 | 12 | ; IP Trigger Local-Data Action 13 | ; any answer containing IP range 14 | 32.3.0.0.127.rpz-ip CNAME strongarmdrop.sinkhole. 15 | -------------------------------------------------------------------------------- /BIND/Zones/db.rpz.comodosecure: -------------------------------------------------------------------------------- 1 | $TTL 300 2 | 3 | @ IN SOA localhost. need.to.know.only. ( 4 | 201702121 ; Serial number 5 | 3600 ; refresh 1 hour 6 | 600 ; retry 10 minutes 7 | 86400 ; expiry 1 week 8 | 600 ) ; min ttl 10 minutes 9 | 10 | @ IN NS LOCALHOST. 11 | 12 | ; IP Trigger Local-Data Action 13 | ; any answer containing IP range 14 | 32.5.0.0.127.rpz-ip CNAME comodosecuredrop.sinkhole. 15 | -------------------------------------------------------------------------------- /BIND/Zones/db.rpz.nortonconnectsafe: -------------------------------------------------------------------------------- 1 | $TTL 300 2 | 3 | @ IN SOA nortonconnectsafe.local. nortonconnectsafe.local. ( 4 | 201702121 ; Serial number 5 | 3600 ; refresh 1 hour 6 | 600 ; retry 10 minutes 7 | 86400 ; expiry 1 week 8 | 600 ) ; min ttl 10 minutes 9 | 10 | @ IN NS LOCALHOST. 11 | 12 | ; IP Trigger Local-Data Action 13 | ; any answer containing IP range 14 | 32.6.0.0.127.rpz-ip CNAME nortonconnectsafedrop.sinkhole. 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2014 Peter Kacherginsky 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 3. Neither the name of the copyright holder nor the names of its contributors 13 | may be used to endorse or promote products derived from this software without 14 | specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 20 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /BIND/Zones/db.rpz.safe: -------------------------------------------------------------------------------- 1 | $TTL 300 2 | 3 | @ IN SOA localhost. need.to.know.only. ( 4 | 201702121 ; Serial number 5 | 3600 ; refresh 1 hour 6 | 600 ; retry 10 minutes 7 | 86400 ; expiry 1 week 8 | 600 ) ; min ttl 10 minutes 9 | 10 | @ IN NS LOCALHOST. 11 | 12 | ; OpenDNS IPs 13 | 14 | 32.208.67.222.222.rpz-ip IN CNAME rpz-passthru. 15 | 32.208.67.220.220.rpz-ip IN CNAME rpz-passthru. 16 | 17 | ; Whitelist of domains that provides Blacklists 18 | 19 | openphish.com CNAME rpz-passthru. 20 | v.firebog.net CNAME rpz-passthru. 21 | firebog.net CNAME rpz-passthru. 22 | joewein.net CNAME rpz-passthru. 23 | www.joewein.net CNAME rpz-passthru. 24 | isc.sans.edu CNAME rpz-passthru. 25 | sans.edu CNAME rpz-passthru. 26 | amazonaws.com CNAME rpz-passthru. 27 | s3.amazonaws.com CNAME rpz-passthru. 28 | cybercrime-tracker.net CNAME rpz-passthru. 29 | squidblacklist.org CNAME rpz-passthru. 30 | www.squidblacklist.org CNAME rpz-passthru. 31 | sysctl.org CNAME rpz-passthru. 32 | zeustracker.abuse.ch CNAME rpz-passthru. 33 | abuse.ch CNAME rpz-passthru. 34 | www.malwaredomainlist.com CNAME rpz-passthru. 35 | malwaredomainlist.com CNAME rpz-passthru. 36 | someonewhocares.org CNAME rpz-passthru. 37 | adaway.org CNAME rpz-passthru. 38 | winhelp2002.mvps.org CNAME rpz-passthru. 39 | mvps.org CNAME rpz-passthru. 40 | mirror1.malwaredomains.com CNAME rpz-passthru. 41 | malwaredomains.com CNAME rpz-passthru. 42 | pgl.yoyo.org CNAME rpz-passthru. 43 | yoyo.org CNAME rpz-passthru. 44 | 45 | ; My sinkhole domain 46 | drop.sinkhole CNAME rpz-passthru. 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | DNSMasterChef is a POC based on the original project DNSChef. I integrated my project [Dns-online-filter](https://github.com/NavyTitanium/Dns-online-filter) to it so it will only forward safe domains, while spoofing unsafe domains. 4 | 5 | It will verify 'A' queries and forward directly all other type of requests. The script listen locally on port #5353, so you can forward BIND requests to it. 6 | 7 | ## Installation 8 | The script work fine with Python3.6. 9 | You will need the following python packages installed: **dnslib**, **pyasn**, **IPy** and **dnspython**. 10 | ```sh 11 | pip3.6 install -r requirements.txt 12 | ``` 13 | 14 | ## Running DNSMasterChef 15 | ``` 16 | [root@localhost]# python3.6 dnsMasterChef.py 17 | [*] Listening on an alternative port 5353 18 | [*] DNSChef started on interface: 127.0.0.1 19 | [*] Using the following nameservers: 208.67.222.222, 208.67.220.220 20 | [*] No parameters were specified. Running in full proxy mode 21 | ``` 22 | ## Output when proxying safe domains 23 | ### Test 1 24 | Client: 25 | ``` 26 | [root@localhost]# ping github.com 27 | PING github.com (192.30.253.112) 56(84) bytes of data. 28 | ``` 29 | Script: 30 | ``` 31 | github.com is safe, proxying... 32 | Filtering PTR requests not supported, Forwarding... 33 | [02:27:25] 127.0.0.1: proxying the response of type 'PTR' for 112.253.30.192.in-addr.arpa 34 | ``` 35 | ### Test 2 36 | Client: 37 | ``` 38 | [root@localhost]# dig outlook.com 39 | ... 40 | ;; ANSWER SECTION: 41 | outlook.com. 285 IN A 40.97.116.82 42 | outlook.com. 285 IN A 40.97.128.194 43 | outlook.com. 285 IN A 40.97.148.226 44 | outlook.com. 285 IN A 40.97.153.146 45 | ... 46 | ``` 47 | Script: 48 | ``` 49 | outlook.com is safe, proxying... 50 | ``` 51 | ## Output when receiving requests for unsafe domains 52 | ### Test 1 53 | Client: 54 | ``` 55 | [root@localhost]# ping stat-dns.com 56 | PING nortonconnectsafedrop.sinkhole (192.168.1.220) 56(84) bytes of data. 57 | ``` 58 | Script: 59 | ``` 60 | stat-dns.com Spoofing because it is filtered by NortonConnectSafe 61 | ``` 62 | ### Test 2 63 | Client: 64 | ``` 65 | [root@localhost]# dig thephonecompany.com 66 | ... 67 | ;; ANSWER SECTION: 68 | thephonecompany.com. 5 IN CNAME quad9drop.sinkhole. 69 | quad9drop.sinkhole. 8600 IN A 192.168.1.220 70 | ... 71 | ``` 72 | Script: 73 | ``` 74 | thephonecompany.com Spoofing because it is filtered by Quad9 75 | ``` 76 | 77 | 78 | -------------------------------------------------------------------------------- /BIND/named.conf: -------------------------------------------------------------------------------- 1 | // Blacklisted bogus network range. 2 | acl bogusnets-acl { 3 | 0.0.0.0/8; 10.0.0.0/8; 4 | 100.64.0.0/10; 169.254.0.0/16; 5 | 172.16.0.0/12; 192.0.0.0/24; 6 | 192.0.2.0/24; 198.18.0.0/15; 7 | 198.51.100.0/24; 203.0.113.0/24; 8 | 224.0.0.0/4; 240.0.0.0/4; 9 | }; 10 | 11 | acl trusted-acl { 12 | 127.0.0.1/32; 192.168.0.0/16; 13 | }; 14 | 15 | options { 16 | // Ban bogus networks 17 | blackhole { bogusnets-acl; }; 18 | 19 | // Only allows trusted client to use the service 20 | allow-query { trusted-acl; }; 21 | allow-recursion{ trusted-acl; }; 22 | allow-query-cache { trusted-acl; }; 23 | 24 | // Hiding our version 25 | version "Not available"; 26 | 27 | // Improved performance 28 | minimal-any yes; 29 | minimal-responses yes; 30 | 31 | // Interval scanning network interfaces 32 | interface-interval 0; 33 | 34 | // no NOTIFY messages are sent 35 | notify no; 36 | 37 | // Ban everyone by default (transfer,update,notify) 38 | allow-transfer {none;}; 39 | allow-update {none;}; 40 | allow-notify { none; }; 41 | allow-update-forwarding {none;}; 42 | 43 | // Disabling DNSSEC because OpenDNS doesn't support it :( 44 | dnssec-enable no; 45 | dnssec-validation no; 46 | 47 | // IPv6 48 | listen-on-v6 { any; }; 49 | 50 | // Directory where bind should create files if not explicitly stated 51 | directory "/var/named"; 52 | 53 | response-policy { 54 | # Safe domains I whitelisted. Eg: Domains used to fetch OSINT lists 55 | zone "rpz.safe"; 56 | 57 | # Alexa TOP x list 58 | zone "rpz.alexa"; 59 | 60 | # OSINT lists aggregated 61 | zone "rpz.blacklist"; 62 | 63 | # Custom sinkhole 64 | zone "rpz.google"; 65 | zone "rpz.safedns"; 66 | zone "rpz.strongarm"; 67 | zone "rpz.nortonconnectsafe"; 68 | zone "rpz.quad9"; 69 | zone "rpz.comodosecure"; 70 | }; 71 | 72 | // Forward queries to local proxy script 73 | forwarders { 74 | 127.0.0.1 port 5353; 75 | }; 76 | 77 | // Only forward, don't attempt to answer the query 78 | forward only; 79 | }; 80 | 81 | zone "sinkhole." { 82 | type master; 83 | file "/var/named/db.sinkhole"; 84 | allow-update { none; }; 85 | allow-transfer { none; }; 86 | allow-query { trusted-acl;}; 87 | }; 88 | 89 | #------------------------------------------------------------------------------ 90 | # Local RPZ Files 91 | #------------------------------------------------------------------------------ 92 | 93 | zone "rpz.safe" { 94 | type master; 95 | file "/var/named/db.rpz.safe"; 96 | allow-update { none; }; 97 | allow-transfer { none; }; 98 | allow-query { none; }; 99 | }; 100 | 101 | zone "rpz.alexa" { 102 | type master; 103 | file "/var/named/db.rpz.alexa"; 104 | allow-update { none; }; 105 | allow-transfer { none; }; 106 | allow-query { none; }; 107 | }; 108 | 109 | zone "rpz.blacklist" { 110 | type master; 111 | file "/var/named/db.rpz.blacklist"; 112 | allow-update { none; }; 113 | allow-transfer { none; }; 114 | allow-query { none; }; 115 | }; 116 | 117 | zone "rpz.google" { 118 | type master; 119 | file "/var/named/db.rpz.google"; 120 | allow-update { none; }; 121 | allow-transfer { none; }; 122 | allow-query { none; }; 123 | }; 124 | 125 | zone "rpz.safedns" { 126 | type master; 127 | file "/var/named/db.rpz.safedns"; 128 | allow-update { none; }; 129 | allow-transfer { none; }; 130 | allow-query { none; }; 131 | }; 132 | 133 | zone "rpz.strongarm" { 134 | type master; 135 | file "/var/named/db.rpz.strongarm"; 136 | allow-update { none; }; 137 | allow-transfer { none; }; 138 | allow-query { none; }; 139 | }; 140 | 141 | zone "rpz.nortonconnectsafe" { 142 | type master; 143 | file "/var/named/db.rpz.nortonconnectsafe"; 144 | allow-update { none; }; 145 | allow-transfer { none; }; 146 | allow-query { none; }; 147 | }; 148 | 149 | zone "rpz.quad9" { 150 | type master; 151 | file "/var/named/db.rpz.quad9"; 152 | allow-update { none; }; 153 | allow-transfer { none; }; 154 | allow-query { none; }; 155 | }; 156 | 157 | zone "rpz.comodosecure" { 158 | type master; 159 | file "/var/named/db.rpz.comodosecure"; 160 | allow-update { none; }; 161 | allow-transfer { none; }; 162 | allow-query { none; }; 163 | }; 164 | 165 | #----- Aggressive logging for debugging 166 | 167 | logging { 168 | channel default_file { 169 | file "/var/named/log/default.log" versions 3 size 5m; 170 | severity dynamic; 171 | print-time yes; 172 | }; 173 | channel general_file { 174 | file "/var/named/log/general.log" versions 3 size 5m; 175 | severity dynamic; 176 | print-time yes; 177 | }; 178 | channel database_file { 179 | file "/var/named/log/database.log" versions 3 size 5m; 180 | severity dynamic; 181 | print-time yes; 182 | }; 183 | channel security_file { 184 | file "/var/named/log/security.log" versions 3 size 5m; 185 | severity dynamic; 186 | print-time yes; 187 | }; 188 | channel config_file { 189 | file "/var/named/log/config.log" versions 3 size 5m; 190 | severity dynamic; 191 | print-time yes; 192 | }; 193 | channel resolver_file { 194 | file "/var/named/log/resolver.log" versions 3 size 5m; 195 | severity dynamic; 196 | print-time yes; 197 | }; 198 | channel xfer-in_file { 199 | file "/var/named/log/xfer-in.log" versions 3 size 5m; 200 | severity dynamic; 201 | print-time yes; 202 | }; 203 | channel xfer-out_file { 204 | file "/var/named/log/xfer-out.log" versions 3 size 5m; 205 | severity dynamic; 206 | print-time yes; 207 | }; 208 | channel notify_file { 209 | file "/var/named/log/notify.log" versions 3 size 5m; 210 | severity dynamic; 211 | print-time yes; 212 | }; 213 | channel client_file { 214 | file "/var/named/log/client.log" versions 3 size 5m; 215 | severity dynamic; 216 | print-time yes; 217 | }; 218 | channel unmatched_file { 219 | file "/var/named/log/unmatched.log" versions 3 size 5m; 220 | severity dynamic; 221 | print-time yes; 222 | }; 223 | channel queries_file { 224 | file "/var/named/log/queries.log" versions 3 size 5m; 225 | severity dynamic; 226 | print-time yes; 227 | }; 228 | channel network_file { 229 | file "/var/named/log/network.log" versions 3 size 5m; 230 | severity dynamic; 231 | print-time yes; 232 | }; 233 | channel update_file { 234 | file "/var/named/log/update.log" versions 3 size 5m; 235 | severity dynamic; 236 | print-time yes; 237 | }; 238 | channel dispatch_file { 239 | file "/var/named/log/dispatch.log" versions 3 size 5m; 240 | severity dynamic; 241 | print-time yes; 242 | }; 243 | 244 | channel rpzlog{ 245 | file "/var/named/log/rpz.log" versions unlimited size 1000m; 246 | severity info; 247 | print-time yes; 248 | print-category yes; 249 | print-severity yes; 250 | }; 251 | 252 | category default { default_file; }; 253 | category general { general_file; }; 254 | category database { database_file; }; 255 | category security { security_file; }; 256 | category config { config_file; }; 257 | category resolver { resolver_file; }; 258 | category xfer-in { xfer-in_file; }; 259 | category xfer-out { xfer-out_file; }; 260 | category notify { notify_file; }; 261 | category client { client_file; }; 262 | category unmatched { unmatched_file; }; 263 | category queries { queries_file; }; 264 | category network { network_file; }; 265 | category update { update_file; }; 266 | category dispatch { dispatch_file; }; 267 | category rpz { rpzlog; }; 268 | }; 269 | -------------------------------------------------------------------------------- /dnsMasterChef.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | 4 | from optparse import OptionParser, OptionGroup 5 | from configparser import ConfigParser 6 | from dnslib import * 7 | from IPy import IP 8 | import threading 9 | import random 10 | import operator 11 | import time 12 | import socketserver 13 | import socket 14 | import sys 15 | import os 16 | import binascii 17 | import string 18 | import base64 19 | import time 20 | import dns.resolver 21 | import dns.query 22 | import hashlib 23 | import pyasn 24 | import asyncio 25 | import concurrent.futures 26 | from datetime import datetime 27 | 28 | # The database to correlate IP with ASN 29 | ip_to_as = "ipasn_201803.dat" 30 | asndb = "" 31 | 32 | if os.path.exists(ip_to_as): 33 | asndb = pyasn.pyasn(ip_to_as) 34 | else: 35 | print(ip_to_as + " is not there! I need a ip to AS database...") 36 | exit(0) 37 | 38 | # Providers variable definition 39 | Google = dns.resolver.Resolver() 40 | Google.Name = "Google DNS" 41 | Strongarm = dns.resolver.Resolver() 42 | Strongarm.Name = "Strongarm" 43 | Quad9 = dns.resolver.Resolver() 44 | Quad9.Name = "Quad9" 45 | SafeDNS = dns.resolver.Resolver() 46 | SafeDNS.Name = "SafeDNS" 47 | ComodoSecure = dns.resolver.Resolver() 48 | ComodoSecure.Name = "ComodoSecure" 49 | NortonConnectSafe = dns.resolver.Resolver() 50 | NortonConnectSafe.Name = "NortonConnectSafe" 51 | 52 | # Setting IP address of each DNS provider 53 | Google.nameservers = ['8.8.8.8', '8.8.4.4'] 54 | Google.Sinkhole = '127.0.0.7' 55 | Quad9.nameservers = ['9.9.9.9', '149.112.112.112'] 56 | Quad9.Sinkhole = '127.0.0.2' 57 | Strongarm.nameservers = ['54.174.40.213', '52.3.100.184'] 58 | Strongarm.Sinkhole = '127.0.0.3' 59 | SafeDNS.nameservers = ['195.46.39.39', '195.46.39.40'] 60 | SafeDNS.Sinkhole = '127.0.0.4' 61 | ComodoSecure.nameservers = ['8.26.56.26', '8.20.247.20'] 62 | ComodoSecure.Sinkhole = '127.0.0.5' 63 | NortonConnectSafe.nameservers = ['199.85.126.30', '199.85.127.30'] 64 | NortonConnectSafe.Sinkhole = '127.0.0.6' 65 | 66 | Providers = [Strongarm, NortonConnectSafe, ComodoSecure, Quad9, SafeDNS] 67 | NumberOfProviders = len(Providers) 68 | 69 | # Query a provider and verify the answer 70 | async def Query(domain,DnsResolver,asn_baseline,hash_baseline): 71 | try: 72 | #Get the A record for the specified domain with the specified provider 73 | Answers = DnsResolver.query(domain, "A") 74 | 75 | #Domain did not resolve 76 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): 77 | return [False, DnsResolver] 78 | 79 | #List of returned IP 80 | Arecords = [] 81 | for rdata in Answers: 82 | Arecords.append(rdata.address) 83 | 84 | #Compare the answer with the baseline to see if record(s) differ 85 | if hashlib.md5(str(sorted(Arecords)).encode('utf-8')).hexdigest() != hash_baseline.hexdigest(): 86 | 87 | #Record(s) differ, checking if the first one is in the same BGP AS 88 | if(asndb.lookup(sorted(Arecords)[0])[0] != asn_baseline): 89 | return [False, DnsResolver] 90 | 91 | #Domain is safe 92 | return [True, DnsResolver] 93 | 94 | # Creates the parallels tasks 95 | async def main(domain,asn_baseline,hash_baseline): 96 | with concurrent.futures.ThreadPoolExecutor(max_workers=NumberOfProviders) as executor: 97 | tasks = [ 98 | asyncio.ensure_future(Query(domain, Providers[i],asn_baseline,hash_baseline)) 99 | for i in range(NumberOfProviders) 100 | ] 101 | 102 | for IsSafe,provider in await asyncio.gather(*tasks): 103 | #One DNS provider in the function 'Query' returned False, so the domain is unsafe 104 | if IsSafe == False: 105 | return [False, provider] 106 | pass 107 | 108 | #Function 'Query' never returned False at this point, the domain is safe 109 | return [True, provider] 110 | 111 | # Create the loop 112 | def Createloop(domain,asn_baseline,hash_baseline): 113 | loop = asyncio.new_event_loop() 114 | asyncio.set_event_loop(loop) 115 | result = loop.run_until_complete(main(domain,asn_baseline,hash_baseline)) 116 | 117 | # return is received, let's close the objects 118 | loop.run_until_complete(loop.shutdown_asyncgens()) 119 | return result 120 | 121 | #Establish a baseline with Google Public DNS and call function "loop" 122 | def launch(domain): 123 | hash_baseline = hashlib.md5() 124 | try: 125 | #Lookup the 'A' record(s) 126 | Answers_Google = Google.query(domain, "A") 127 | 128 | #Domain did not resolve 129 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): 130 | return [False, Google] 131 | 132 | # Contain the returned A record(s) 133 | Arecords = [] 134 | for rdata in Answers_Google: 135 | Arecords.append(rdata.address) 136 | 137 | #Looking the ASN of the first A record (sorted) 138 | asn_baseline = asndb.lookup(sorted(Arecords)[0])[0] 139 | 140 | #MD5 Fingerprint of the anwser is the sorted list of A record(s) 141 | #Because of the round-robin often used in replies. 142 | #Ex. NS1 returns IP X,Y and NS2 returns IP Y,X 143 | hash_baseline.update(str(sorted(Arecords)).encode('utf-8')) 144 | 145 | return Createloop(domain,asn_baseline,hash_baseline) 146 | 147 | 148 | # DNSHandler Mixin. The class contains generic functions to parse DNS requests and 149 | # calculate an appropriate response based on user parameters. 150 | class DNSHandler: 151 | 152 | def parse(self, data): 153 | response = '' 154 | try: 155 | # Parse data as DNS 156 | d = DNSRecord.parse(data) 157 | except Exception as e: 158 | 159 | print(('[%s] %s: ERROR: %s' % (time.strftime('%H:%M:%S'), 160 | self.client_address[0], 'invalid DNS request'))) 161 | if self.server.log: 162 | self.server.log.write( 163 | '[%s] %s: ERROR: %s\n' % 164 | (time.strftime('%d/%b/%Y:%H:%M:%S %z'), 165 | self.client_address[0], 166 | 'invalid DNS request')) 167 | else: 168 | 169 | # Only Process DNS Queries 170 | if QR[d.header.qr] == 'QUERY': 171 | qname = str(d.q.qname) 172 | 173 | # Chop off the last period 174 | if qname[-1] == '.': qname = qname[:-1] 175 | 176 | qtype = QTYPE[d.q.qtype] 177 | 178 | # Proxy the request 179 | if qtype not in ['SOA', 'A']: 180 | print("Filtering " + qtype + " requests not supported, Forwarding...") 181 | print ( 182 | "[%s] %s: proxying the response of type '%s' for %s" % 183 | (time.strftime("%H:%M:%S"), self.client_address[0], qtype, qname)) 184 | if self.server.log: 185 | self.server.log.write( 186 | "[%s] %s: proxying the response of type '%s' for %s\n" % 187 | (time.strftime("%d/%b/%Y:%H:%M:%S %z"), self.client_address[0], qtype, qname)) 188 | 189 | nameserver_tuple = random.choice(self.server.nameservers).split('#') 190 | response = self.proxyrequest(data, *nameserver_tuple) 191 | else: 192 | IsSafe, ProviderName = launch(qname) 193 | if IsSafe: 194 | print(qname + " is safe, proxying...") 195 | nameserver_tuple = random.choice(self.server.nameservers).split('#') 196 | response = self.proxyrequest(data, *nameserver_tuple) 197 | else: 198 | fake_records = dict() 199 | fake_record = ProviderName.Sinkhole 200 | fake_records[qtype] = qtype 201 | 202 | # Create a custom response to the query 203 | response = DNSRecord(DNSHeader(id=d.header.id, bitmap=d.header.bitmap, qr=1, aa=1, ra=1), q=d.q) 204 | 205 | if qtype == "SOA": 206 | mname, rname, t1, t2, t3, t4, t5 = fake_record.split(" ") 207 | times = tuple([int(t) for t in [t1, t2, t3, t4, t5]]) 208 | 209 | # dnslib doesn't like trailing dots 210 | if mname[-1] == ".": 211 | mname = mname[:-1] 212 | if rname[-1] == ".": 213 | rname = rname[:-1] 214 | 215 | response.add_answer(RR(qname, getattr(QTYPE, qtype), 216 | rdata=RDMAP[qtype](mname, rname, times))) 217 | 218 | elif qtype == "A": 219 | if fake_record[-1] == ".": 220 | fake_record = fake_record[:-1] 221 | response.add_answer(RR(qname, getattr(QTYPE, qtype),rdata=RDMAP[qtype](fake_record))) 222 | 223 | response = response.pack() 224 | print(qname + ' Spoofing because it is filtered by ' + ProviderName.Name) 225 | return response 226 | 227 | # Find appropriate ip address to use for a queried name. The function can 228 | 229 | def findnametodns(self, qname, nametodns): 230 | # Make qname case insensitive 231 | 232 | qname = qname.lower() 233 | 234 | # Split and reverse qname into components for matching. 235 | 236 | qnamelist = qname.split('.') 237 | qnamelist.reverse() 238 | 239 | # HACK: It is important to search the nametodns dictionary before iterating it so that 240 | # global matching ['*.*.*.*.*.*.*.*.*.*'] will match last. Use sorting 241 | # for that. 242 | 243 | for (domain, host) in sorted(iter(list(nametodns.items())), 244 | key=operator.itemgetter(1)): 245 | 246 | # NOTE: It is assumed that domain name was already lowercased 247 | # when it was loaded through --file, --fakedomains or --truedomains 248 | # don't want to waste time lowercasing domains on every request. 249 | 250 | # Split and reverse domain into components for matching 251 | 252 | domain = domain.split('.') 253 | domain.reverse() 254 | 255 | # Compare domains in reverse. 256 | 257 | for (a, b) in map(None, qnamelist, domain): 258 | if a != b and b != '*': 259 | break 260 | else: 261 | 262 | # Could be a real IP or False if we are doing reverse matching 263 | # with 'truedomains' 264 | 265 | return host 266 | else: 267 | return False 268 | 269 | 270 | # Obtain a response from a real DNS server. 271 | 272 | def proxyrequest( 273 | self, 274 | request, 275 | host, 276 | port='53', 277 | protocol='udp', 278 | ): 279 | reply = None 280 | try: 281 | if self.server.ipv6: 282 | 283 | if protocol == 'udp': 284 | sock = socket.socket(socket.AF_INET6, 285 | socket.SOCK_DGRAM) 286 | elif protocol == 'tcp': 287 | sock = socket.socket(socket.AF_INET6, 288 | socket.SOCK_STREAM) 289 | else: 290 | 291 | if protocol == 'udp': 292 | sock = socket.socket(socket.AF_INET, 293 | socket.SOCK_DGRAM) 294 | elif protocol == 'tcp': 295 | sock = socket.socket(socket.AF_INET, 296 | socket.SOCK_STREAM) 297 | 298 | sock.settimeout(3.0) 299 | 300 | # Send the proxy request to a randomly chosen DNS server 301 | 302 | if protocol == 'udp': 303 | sock.sendto(request, (host, int(port))) 304 | reply = sock.recv(1024) 305 | sock.close() 306 | elif protocol == 'tcp': 307 | 308 | sock.connect((host, int(port))) 309 | 310 | # Add length for the TCP request 311 | 312 | length = binascii.unhexlify('%04x' % len(request)) 313 | sock.sendall(length + request) 314 | 315 | # Strip length from the response 316 | 317 | reply = sock.recv(1024) 318 | reply = reply[2:] 319 | 320 | sock.close() 321 | except Exception as e: 322 | 323 | print(('[!] Could not proxy request: %s' % e)) 324 | else: 325 | return reply 326 | 327 | 328 | # UDP DNS Handler for incoming requests 329 | 330 | class UDPHandler(DNSHandler, socketserver.BaseRequestHandler): 331 | 332 | def handle(self): 333 | (data, socket) = self.request 334 | response = self.parse(data) 335 | 336 | if response: 337 | socket.sendto(response, self.client_address) 338 | 339 | 340 | # TCP DNS Handler for incoming requests 341 | 342 | class TCPHandler(DNSHandler, socketserver.BaseRequestHandler): 343 | 344 | def handle(self): 345 | data = self.request.recv(1024) 346 | 347 | # Remove the addition "length" parameter used in the 348 | # TCP DNS protocol 349 | 350 | data = data[2:] 351 | response = self.parse(data) 352 | 353 | if response: 354 | # Calculate and add the additional "length" parameter 355 | # used in TCP DNS protocol 356 | 357 | length = binascii.unhexlify('%04x' % len(response)) 358 | self.request.sendall(length + response) 359 | 360 | 361 | class ThreadedUDPServer(socketserver.ThreadingMixIn, 362 | socketserver.UDPServer): 363 | 364 | # Override SocketServer.UDPServer to add extra parameters 365 | 366 | def __init__( 367 | self, 368 | server_address, 369 | RequestHandlerClass, 370 | nametodns, 371 | nameservers, 372 | ipv6, 373 | log, 374 | ): 375 | self.nametodns = nametodns 376 | self.nameservers = nameservers 377 | self.ipv6 = ipv6 378 | self.address_family = \ 379 | (socket.AF_INET6 if self.ipv6 else socket.AF_INET) 380 | self.log = log 381 | 382 | socketserver.UDPServer.__init__(self, server_address, 383 | RequestHandlerClass) 384 | 385 | 386 | class ThreadedTCPServer(socketserver.ThreadingMixIn, 387 | socketserver.TCPServer): 388 | # Override default value 389 | 390 | allow_reuse_address = True 391 | 392 | # Override SocketServer.TCPServer to add extra parameters 393 | 394 | def __init__( 395 | self, 396 | server_address, 397 | RequestHandlerClass, 398 | nametodns, 399 | nameservers, 400 | ipv6, 401 | log, 402 | ): 403 | self.nametodns = nametodns 404 | self.nameservers = nameservers 405 | self.ipv6 = ipv6 406 | self.address_family = \ 407 | (socket.AF_INET6 if self.ipv6 else socket.AF_INET) 408 | self.log = log 409 | 410 | socketserver.TCPServer.__init__(self, server_address, 411 | RequestHandlerClass) 412 | 413 | 414 | # Initialize and start the DNS Server 415 | 416 | def start_cooking( 417 | interface, 418 | nametodns, 419 | nameservers, 420 | tcp=False, 421 | ipv6=False, 422 | port='55', 423 | logfile=None, 424 | ): 425 | try: 426 | 427 | if logfile: 428 | log = open(logfile, 'a', 0) 429 | log.write('[%s] DNSChef is active.\n' 430 | % time.strftime('%d/%b/%Y:%H:%M:%S %z')) 431 | else: 432 | log = None 433 | 434 | if tcp: 435 | print('[*] DNSChef is running in TCP mode') 436 | server = ThreadedTCPServer( 437 | (interface, int(port)), 438 | TCPHandler, 439 | nametodns, 440 | nameservers, 441 | ipv6, 442 | log, 443 | ) 444 | else: 445 | server = ThreadedUDPServer( 446 | (interface, int(port)), 447 | UDPHandler, 448 | nametodns, 449 | nameservers, 450 | ipv6, 451 | log, 452 | ) 453 | 454 | # Start a thread with the server -- that thread will then start 455 | # more threads for each request 456 | 457 | server_thread = threading.Thread(target=server.serve_forever) 458 | 459 | # Exit the server thread when the main thread terminates 460 | 461 | server_thread.daemon = True 462 | server_thread.start() 463 | 464 | # Loop in the main thread 465 | 466 | while True: 467 | time.sleep(100) 468 | except (KeyboardInterrupt, SystemExit): 469 | 470 | if log: 471 | log.write('[%s] DNSChef is shutting down.\n' 472 | % time.strftime('%d/%b/%Y:%H:%M:%S %z')) 473 | log.close() 474 | 475 | server.shutdown() 476 | print('[*] DNSChef is shutting down.') 477 | sys.exit() 478 | except IOError: 479 | 480 | print('[!] Failed to open log file for writing.') 481 | except Exception as e: 482 | 483 | print(('[!] Failed to start the server: %s' % e)) 484 | 485 | 486 | if __name__ == '__main__': 487 | # Parse command line arguments 488 | parser = OptionParser(usage="dnschef.py [options]:\n") 489 | 490 | rungroup = OptionGroup(parser, "Optional runtime parameters.") 491 | rungroup.add_option("--logfile", action="store", help="Specify a log file to record all activity") 492 | rungroup.add_option("-i", "--interface", metavar="127.0.0.1 or ::1", default="127.0.0.1", action="store", 493 | help='Define an interface to use for the DNS listener. By default, the tool uses 127.0.0.1 for IPv4 mode and ::1 for IPv6 mode.') 494 | rungroup.add_option("--nameservers", metavar="208.67.222.222#53 or 208.67.220.220#53", 495 | default='208.67.222.222,208.67.220.220', action="store") 496 | rungroup.add_option("-t", "--tcp", action="store_true", default=False, 497 | help="Use TCP DNS proxy instead of the default UDP.") 498 | rungroup.add_option("-p", "--port", action="store", metavar="5353", default="5353", 499 | help='Port number to listen for DNS requests.') 500 | parser.add_option_group(rungroup) 501 | 502 | (options, args) = parser.parse_args() 503 | options.ipv6 = False 504 | 505 | # Main storage of domain filters 506 | # NOTE: RDMAP is a dictionary map of qtype strings to handling classes 507 | nametodns = dict() 508 | for qtype in list(RDMAP.keys()): 509 | nametodns[qtype] = dict() 510 | 511 | # Notify user about alternative listening port 512 | if options.port != '53': 513 | print(('[*] Listening on an alternative port %s' % options.port)) 514 | 515 | 516 | print(('[*] DNSChef started on interface: %s ' % options.interface)) 517 | 518 | # Use alternative DNS servers 519 | if options.nameservers: 520 | nameservers = options.nameservers.split(',') 521 | print(('[*] Using the following nameservers: %s' 522 | % ', '.join(nameservers))) 523 | 524 | print('[*] No parameters were specified. Running in full proxy mode') 525 | 526 | # Launch DNSChef 527 | start_cooking( 528 | interface=options.interface, 529 | nametodns=nametodns, 530 | nameservers=nameservers, 531 | tcp=options.tcp, 532 | ipv6=options.ipv6, 533 | port=options.port, 534 | logfile=options.logfile, 535 | ) 536 | -------------------------------------------------------------------------------- /index.md: -------------------------------------------------------------------------------- 1 | ## Setting up a DNS Firewall on steroids 2 | 3 | The idea is to setup a local Bind recursive DNS server to improve the overall security of a network by filtering known nasty domains. This is done by first configuring RPZ with multiple lists of domains/IPs to block, then using the python script I developed that allows the filtering of unsafe requests based on multiple DNS threat blocking providers. 4 | 5 | In this POC, I'm blocking all kinds of malicious domains and IPs, as well as advertisement domains. Blocking ads not only increase the security of workstations resolving to the server (by stopping potential malvertising), but allows clients to have them blocked without any plugins or additional configuration in the browser. Another advantage of blocking at this level (DNS) is that your whole network can benefit from the filtering without having to configure any kind of proxy filtering on each client. 6 | 7 | DNS Firewall - a.k.a **D**omain **N**ame **S**ervice **R**esponse **P**olicy **Z**ones (DNS RPZ) is a BIND9.8.1+ feature that allows DNS server administrators to filter requests (respond with NXDOMAIN, NODATA, DROP, Local Data, ...) for certain domains, clients, IPs or even per NameServer. [This RFC draft](https://tools.ietf.org/html/draft-ietf-dnsop-dns-rpz-00) describes the technology as: 8 | 9 | >... a method for expressing DNS response policy 10 | >inside a specially constructed DNS zone, and for recursive name 11 | >servers to use such policy to return modified results to DNS clients. 12 | >The modified DNS results can stop access to selected HTTP servers, 13 | >redirect users to "walled gardens", block objectionable email, and 14 | >otherwise defend against attack. These "DNS Firewalls" are widely 15 | >used in fighting Internet crime and abuse. 16 | 17 | While this sounds interesting, most security companies sell these services to organizations as ‘DNS FIREWALLs’ to help them stay secure. I was curious to see how good I could set up my own server to provide the same security as their paid offerings. 18 | 19 | ### Building our blacklists 20 | The goal here is to build a massive list of domains to block for RPZ so the queries have less chances to leave the server. There's already some good projects on Github that help us to do that. For instance,[this project](https://github.com/cbuijs/dns-firewall) provides gigantic (sometimes around 80 mb) RPZ zone files ready to download and use for blocking. 21 | 22 | To get a better idea of what I'm going to be blocking, I used [bind-adblock](https://github.com/Trellmor/bind-adblock/). bind-adblock downloads multiple blacklists and constructs a singular BIND RPZ zone file with the parsed domains. I modified bind-adblock to construct the zone file a bit differently, and to check more closely for malformed domains before writing them to our zone file. The Python script will download and use around 25 OSINT lists for malware and ad blocking (before checking for duplicates). I forked the project so I could add more blacklists and make further adjustments. For simplicity and performance, I constructed only one zone file aggregating all of the domains. The result is a ~40mb file with ~977270 lines. It is indeed a good idea to run the script frequently so our server will have the latest intelligence. You can download bind-block-unwanted [here](https://github.com/NavyTitanium/bind-block-unwanted) 23 | 24 | #### Whitelisting 25 | It is always recommended to have a zone with frequently used domains as a "pass-thru" zone, so queries for those domains will not have to be compared with all blacklists. I used the Alexa top 5000 websites as my whitelist. Taking more than that could include [malicious websites](https://blog.sucuri.net/2011/03/alexa-top-100k-sites-the-malware-blues.html). [This Github page](https://github.com/secure411dotorg/rpzone/tree/master/scripts) contains scripts that allows us to easily download the Alexa top 1 million websites, cut the list to 5000 and then add the proper RPZ syntax so it can be used in a BIND zone file. 26 | 27 | #### Sinkhole domain 28 | I created a local domain ***.sinkhole*** that will resolve to this same server for the sinkholing of blocked domains. In an RPZ file, it's easier to point each domain we want to block to a ***walled garden*** (with CNAME) instead of using a hardcoded IP address (with an A record). This way, if the server change its IP address, or if we want to redirect the traffic elsewhere, we would only need to edit our ***.sinkhole*** zone. 29 | 30 | The blacklist zone will have entries like this in **/var/named/db.rpz.blacklist**: 31 | ```markdown 32 | ... 33 | bad-domain1 IN CNAME drop.sinkhole. 34 | bad-domain2 IN CNAME drop.sinkhole. 35 | ... 36 | ``` 37 | To make it work, we need to create the ***.sinkhole*** zone first in **/var/named/db.sinkhole**: 38 | ```markdown 39 | @ IN SOA sinkhole. root.sinkhole. (2 604800 86400 2419200 604800); 40 | @ IN NS ns1.sinkhole. 41 | @ IN A 192.168.1.220 42 | * A 192.168.1.220 43 | ``` 44 | The zone file will match any request to ***.sinkhole*** to the local server. We then add it to the BIND configuration file **/etc/named.conf**: 45 | ```markdown 46 | zone "sinkhole." { 47 | type master; 48 | file "/var/named/db.sinkhole"; 49 | allow-update { none; }; 50 | allow-transfer { none; }; 51 | allow-query { trusted-acl;}; 52 | }; 53 | ``` 54 | ### The server 55 | 56 | My server is a minimal installation of CentOS 7. 57 | 58 | #### Installing BIND 59 | 60 | For this project, I compiled BIND 9.12. The procedure is well detailed [here](http://linux-sxs.org/internet_serving/bind9.html). 61 | The only difference I made is at the compilation time. To use the DNS Response Policy Service (DNSRPS) feature in version 9.12, we need to add the **enable-dnsrps** flag: 62 | 63 | ```markdown 64 | ./configure --prefix=/usr \--sysconfdir=/etc \--enable-threads \--localstatedir=/var/state \--with-libtool \--enable-dnsrps 65 | ``` 66 | After that we can start the server: 67 | ```markdown 68 | /usr/sbin/named -u named 69 | ``` 70 | At this point, the server is ready to resolve queries using root tld. 71 | 72 | ### Configuring BIND 73 | Since our server will allow recursive queries, we want to make sure it is proper configured so only the intended clients can make use of it. 74 | 75 | We start by adding a trusted ACL to the BIND configuration file ***/etc/named.conf***: 76 | ```markdown 77 | // Set up an ACL named "trusted-acl" that will allow 78 | // legitimate clients to query the server 79 | acl trusted-acl { 80 | 127.0.0.1/32; 192.168.0.0/16; 81 | }; 82 | ``` 83 | Also, we will add a list of bogus IPs that we don't want to serve or hear replies from. This list was taken from the [team-cymru website](http://www.team-cymru.org/bogon-reference.html). I removed **127.0.0.0/8** from the list because I will communicate with the loopback for proxying requests. The range **192.168.0.0/16** is also removed from the list because it is my internal network. 84 | ```markdown 85 | // Blacklisted bogus network range. 86 | acl bogusnets-acl { 87 | 0.0.0.0/8; 10.0.0.0/8; 88 | 100.64.0.0/10; 169.254.0.0/16; 89 | 172.16.0.0/12; 192.0.0.0/24; 90 | 192.0.2.0/24; 198.18.0.0/15; 91 | 198.51.100.0/24; 203.0.113.0/24; 92 | 224.0.0.0/4; 240.0.0.0/4; 93 | }; 94 | ``` 95 | My ***options*** section look like the following in ***/etc/named.conf***: 96 | ```markdown 97 | options { 98 | // Ban bogus networks 99 | blackhole { bogusnets-acl; }; 100 | 101 | // Only allows trusted client to use the service 102 | allow-query { trusted-acl; }; 103 | allow-recursion{ trusted-acl; }; 104 | allow-query-cache { trusted-acl; }; 105 | 106 | // Hiding our version 107 | version "Not available"; 108 | 109 | // Improved performance 110 | minimal-any yes; 111 | minimal-responses yes; 112 | 113 | // Interval scanning network interfaces 114 | interface-interval 0; 115 | 116 | // no NOTIFY messages are sent 117 | notify no; 118 | 119 | // Ban everyone by default (transfer,update,notify) 120 | allow-transfer {none;}; 121 | allow-update {none;}; 122 | allow-notify { none; }; 123 | allow-update-forwarding {none;}; 124 | 125 | // Disabling DNSSEC because OpenDNS doesn't support it :( 126 | dnssec-enable no; 127 | dnssec-validation no; 128 | 129 | // IPv6 130 | listen-on-v6 { any; }; 131 | 132 | // Directory where bind should create files if not explicitly stated 133 | directory "/var/named"; 134 | 135 | // Forward queries 136 | forwarders { 137 | // Using OpenDNS directly for now 138 | 208.67.222.222; 139 | 208.67.220.220; 140 | }; 141 | 142 | // Only forward, don't attempt to answer the query 143 | forward only; 144 | }; 145 | ``` 146 | #### DNSSEC ? 147 | Unfortunately, the provider I picked up, OpenDNS, does not support DNSSEC. This is why I disabled it in the configuration file. 148 | 149 | ### Configuring the RPZ 150 | We now need to add our RPZ to the BIND configuration in the ***options*** section. Here's what the configuration looks like in ***/etc/named.conf***: 151 | ```markdown 152 | response-policy { 153 | # Safe domains I whitelisted. Eg: Domains used to fetch OSINT lists 154 | zone "rpz.safe"; 155 | # Alexa TOP x list 156 | zone "rpz.alexa"; 157 | # OSINT lists aggregated 158 | zone "rpz.blacklist"; 159 | }; 160 | ``` 161 | Then, outside of the ***options*** section: 162 | ```markdown 163 | # Zones definition 164 | 165 | zone "rpz.safe" { 166 | type master; 167 | file "/var/named/db.rpz.safe"; 168 | allow-update { none; }; 169 | allow-transfer { none; }; 170 | allow-query { none; }; 171 | }; 172 | 173 | zone "rpz.alexa" { 174 | type master; 175 | file "/var/named/db.rpz.alexa"; 176 | allow-update { none; }; 177 | allow-transfer { none; }; 178 | allow-query { none; }; 179 | }; 180 | 181 | zone "rpz.blacklist" { 182 | type master; 183 | file "/var/named/db.rpz.blacklist"; 184 | allow-update { none; }; 185 | allow-transfer { none; }; 186 | allow-query { none; }; 187 | }; 188 | ``` 189 | Then, we restart the service with `rndc reload` 190 | ### Setting up our block page 191 | After installing the HTTPD web service on the same server, I created a simple webpage informing the redirected users of the block: 192 | ``` PHP 193 | 194 |

Your query:
' . $escaped_url . '

'; 199 | echo '

Header:

'; 200 | echo ' 207 | ``` 208 | ### Testing new features 209 | Adding RPZ and other features can certainly affect the performance of the server, but performance was not the goal here. That said, an additional level of caching and the blocking of ad requests could also accelerate the loading of certain web pages. 210 | #### DNS RPS 211 | We now have a functioning BIND server supporting DNS RPS. How can we make use of it? 212 | >The DNS Response Policy Service (DNSRPS) API, a mechanism to allow named to use an external response policy provider, is now supported. 213 | 214 | Unfortunately, I quickly realized that only one provider exists for this new technology, and it's implementation is proprietary. The service is called **FastRPZ** and it is from ***Farsight Security***. 215 | I was thinking of making my own API to leverage this new feature, but the DNS RPS API is itself not well documented outside the *.h. I decided to not go forward with this technology, and instead create something else that will allow the use of online intelligence. 216 | 217 | #### Threat blocking DNS providers 218 | I then read about the comparison of different ‘safe’ DNS providers. [This blog post](https://blog.cryptoaustralia.org.au/2017/12/23/best-threat-blocking-dns-providers/) shows the efficiency of multiples DNS providers against known malicious domains. The results are rather clear: some of them are blocking more than others, but they’re relying on different malware intelligence feeds, so they will also be blocking different things. 219 | 220 | So, why couldn’t we use all of them at the same them and then decide if we goes further with the request? 221 | 222 | ## Selectively forwarding DNS using a proxy script 223 | I then made a **Python 3.6** script using **asyncio** to launch parallel DNS requests to those threat blocking providers to further take action on the request. My general assumptions were: 224 | - Google public DNS [does not perform any blocking or filtering of any kind](https://developers.google.com/speed/public-dns/faq). We will then use at as a comparison to subsequent requests. 225 | - Threat blocking DNS providers will either return a sinkhole IP address, NXdomain or nodata if the requested domain is blocked. 226 | - Each domain requested can return multiple A records within different IP range, but they should all belong to the same AS if they aren’t blocked. 227 | 228 | For example, a big network like the Netflix one can return on the first query: 229 | ```markdown 230 | netflix.com. 59 IN A 35.153.114.204 231 | netflix.com. 59 IN A 52.22.105.255 232 | ... 233 | ``` 234 | And on the second query, the same domain can return: 235 | ```markdown 236 | netflix.com. 59 IN A 34.194.194.106 237 | netflix.com. 59 IN A 54.89.0.143 238 | ... 239 | ``` 240 | But fortunately, all of those IP addresses belong to the autonomous system #14618. 241 | 242 | - IP addresses of sinkhole servers are in a different AS than the real IP address of a malicious domain. If they’re both hosted into the same cloud provider, this assumption could be biased, but the odds are really low. 243 | - IP address of the sinkhole servers are not always documented and can change over time. Also, some providers have different sinkhole IPs based on the threat category. So I don’t want to hardcode those values in my script and only rely on those to consider a domain unsafe. 244 | 245 | The idea goes as follow for each requested domain name received by the script: 246 | 247 | 1. Query Google's public DNS to establish a baseline. 248 | - If we received NXdomain or Nodata, the script determines that the domain is unsafe. If not: 249 | - Sort the returned records and put them in a list. Create a MD5 hash of the list. 250 | - Find the AS number of the first record in that list 251 | 252 | Python code: 253 | ``` python 254 | #Establish a baseline with Google Public DNS 255 | hash_baseline = hashlib.md5() 256 | try: 257 | #Lookup the 'A' record(s) 258 | Answers_Google = Google.query(domain, "A") 259 | 260 | #Domain did not resolve 261 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): 262 | return [False, Google.Name] 263 | 264 | # Contain the returned A record(s) 265 | Arecords = [] 266 | for rdata in Answers_Google: 267 | Arecords.append(rdata.address) 268 | 269 | #Looking the ASN of the first A record (sorted) 270 | asn_baseline = asndb.lookup(sorted(Arecords)[0])[0] 271 | 272 | #MD5 Fingerprint of the answer is the sorted list of A record(s) 273 | #Because of the round-robin often used in replies. 274 | #Ex. NS1 returns IP X,Y and NS2 returns IP Y,X 275 | hash_baseline.update(str(sorted(Arecords)).encode('utf-8')) 276 | 277 | return lookup(domain,asn_baseline,hash_baseline) 278 | ``` 279 | 2. Query each chosen DNS threat blocking provider in parallel for the requested domain name. 280 | - For each of the provider’s replies, If we received NXdomain or Nodata, the script return domain unsafe. If not: 281 | - For each of the provider’s replies, store temporary the sorted records in a list and compare its MD5 hash with the baseline. If it differs, the received records were different, so it could be that we received a sinkhole IP address. We will then compare the AS of the first record into the list with the baseline AS (obtained from Google). If the AS differ, we return that the domain is unsafe. If the AS is the same, the domain is considered safe. 282 | - The first response to return a negative reply (unsafe) stops all other requests and the function returns with the result. The domain has already been determined as unsafe by one provider, so there’s no point in waiting for the others to respond. 283 | 284 | Python code: 285 | ``` python 286 | async def check(domain,DnsResolver,asn_baseline,hash_baseline): 287 | try: 288 | #Get the A record for the specified domain with the specified provider 289 | Answers = DnsResolver.query(domain, "A") 290 | 291 | #Domain did not resolve 292 | except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): 293 | return [False, DnsResolver.Name] 294 | 295 | #List of returned IP 296 | Arecords = [] 297 | for rdata in Answers: 298 | Arecords.append(rdata.address) 299 | 300 | #Compare the answer with the baseline to see if record(s) differ 301 | if hashlib.md5(str(sorted(Arecords)).encode('utf-8')).hexdigest() != hash_baseline.hexdigest(): 302 | 303 | #Record(s) differ, checking if the first one is in the same BGP AS 304 | if(asndb.lookup(sorted(Arecords)[0])[0] != asn_baseline): 305 | return [False, DnsResolver.Name] 306 | 307 | #Domain is safe 308 | return [True, DnsResolver.Name] 309 | 310 | async def main(domain,asn_baseline,hash_baseline): 311 | Providers = [Strongarm, NortonConnectSafe, ComodoSecure, Quad9, SafeDNS] 312 | with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: 313 | tasks = [ 314 | asyncio.ensure_future(check(domain, Providers[i],asn_baseline,hash_baseline)) 315 | for i in range(len(Providers)) 316 | ] 317 | 318 | for response,provider in await asyncio.gather(*tasks): 319 | #One DNS provider in the function 'check' returned False, so the domain is unsafe 320 | if response == False: 321 | return [False, provider] 322 | pass 323 | 324 | #Function 'check' never returned False at this point, the domain is safe 325 | return [True, provider] 326 | ``` 327 | 3. If all providers think that the domain is safe, the script goes on and proxy the original request. 328 | 329 | My POC only supports the filtering of "A" records for now. It will proxy without any checks if other record types are requested (AAAA,SRV,TSIG,....). The full code is available on [my GitHub page](https://github.com/NavyTitanium/Dns-online-filter) 330 | 331 | ### Making it useful for BIND 332 | At this point, my simple POC was not able to proxy any DNS requests or spoof any replies. I then looked at some DNS proxy projects that were available on Github. I chose the project [DNSChef](https://github.com/iphelix/dnschef) to integrate my script after converting it from python 2.7 to 3.6. DNSChef is a highly configurable DNS proxy for Penetration Testers and Malware Analysts that allows the spoofing of replies for configured domains. 333 | 334 | I modified most of its features so it will listen locally and forward any DNS queries coming from BIND to OpenDNS (final destination) if they are considered safe. OpenDNS is my real resolver for all unfiltered requests. This provides a last layer of filtering because they are also blocking some malicious domains. All other requests that did not pass the test will receive a spoofed reply with one of the following IPs: 335 | 336 | - Quad9 Sinkhole = **127.0.0.2** 337 | - Strongarm Sinkhole = **127.0.0.3** 338 | - SafeDNS Sinkhole = **127.0.0.4** 339 | - ComodoSecure Sinkhole = **127.0.0.5** 340 | - NortonConnectSafe Sinkhole = **127.0.0.6** 341 | - Google Sinkhole = **127.0.0.7** (when NXdomain is received) 342 | 343 | This way, I will be able to match those local IPs to the corresponding threat blocking providers in my BIND RPZ policies. To do that, we need to match those sinkhole IP addresses starting with **127.0.0.x** with policies: 344 | 345 | ``` 346 | # In the options section of /etc/named.conf 347 | response-policy { 348 | zone "rpz.google"; 349 | zone "rpz.safedns"; 350 | zone "rpz.strongarm"; 351 | zone "rpz.nortonconnectsafe"; 352 | zone "rpz.quad9"; 353 | zone "rpz.comodosecure"; 354 | }; 355 | 356 | # Outside of the options section in /etc/named.conf 357 | zone "rpz.google" { 358 | type master; 359 | file "/var/named/db.rpz.google"; 360 | allow-update { none; }; 361 | allow-transfer { none; }; 362 | allow-query { none; }; 363 | }; 364 | 365 | zone "rpz.safedns" { 366 | type master; 367 | file "/var/named/db.rpz.safedns"; 368 | allow-update { none; }; 369 | allow-transfer { none; }; 370 | allow-query { none; }; 371 | }; 372 | 373 | zone "rpz.strongarm" { 374 | type master; 375 | file "/var/named/db.rpz.strongarm"; 376 | allow-update { none; }; 377 | allow-transfer { none; }; 378 | allow-query { none; }; 379 | }; 380 | 381 | zone "rpz.nortonconnectsafe" { 382 | type master; 383 | file "/var/named/db.rpz.nortonconnectsafe"; 384 | allow-update { none; }; 385 | allow-transfer { none; }; 386 | allow-query { none; }; 387 | }; 388 | 389 | zone "rpz.quad9" { 390 | type master; 391 | file "/var/named/db.rpz.quad9"; 392 | allow-update { none; }; 393 | allow-transfer { none; }; 394 | allow-query { none; }; 395 | }; 396 | 397 | zone "rpz.comodosecure" { 398 | type master; 399 | file "/var/named/db.rpz.comodosecure"; 400 | allow-update { none; }; 401 | allow-transfer { none; }; 402 | allow-query { none; }; 403 | }; 404 | ``` 405 | Each of those zones will contain (in their zone file) an IP Trigger policy. This policy will match the sinkhole IP address belonging to its associated provider. For example, **/var/named/db.rpz.nortonconnectsafe** contains: 406 | ``` 407 | $TTL 800 @ IN SOA nortonconnectsafe.sinkhole. nortonconnectsafe.sinkhole. (201702121 3600 600 86400 600) 408 | @ IN NS LOCALHOST. 409 | 410 | ; IP Trigger Local-Data Action 411 | ; any answer containing IP range 412 | 32.6.0.0.127.rpz-ip CNAME nortonconnectsafedrop.sinkhole. 413 | ``` 414 | So everytime BIND receives an answer from this IP (**127.0.0.6** in this case), it will transfer it to our **walled garden** ***nortonconnectsafedrop.sinkhole*** which is resolving to this server. 415 | 416 | This way, a user browsing to a blocked domain will: 417 | - Generate a log entry in BIND RPZ log file telling which provider has denied the query 418 | - Be redirected to a block page on our server (assuming we have a web server running). 419 | 420 | Finally, if we want BIND to forward queries to the script, we need to configure it as a forwarder, so it will not contact directly the root servers, but instead the DNS proxy python script. To avoid any conflicts, I made the script listen to port 5353 on the same machine, and configure BIND accordingly: 421 | ```markdown 422 | forwarders { 423 | 127.0.0.1 port 5353; 424 | }; 425 | ``` 426 | 427 | Note that this python script is only meant to be a Proof Of Concept and by no means do I guarantee it to bug free. Nonetheless, I have used it personally without any problems or significant performance issues. 428 | 429 | The worst execution time will be achieved when the domain is safe: 430 | Time to query Google DNS + Time of the slowest DNS provider to answer the query. 431 | 432 | ![Requests filter](filtering.png) 433 | 434 | Full scripts and configuration files are on my [GitHub page](https://github.com/NavyTitanium/DNSMasterChef) 435 | 436 | ### References 437 | [BIND9 - Response Policy Zone Configuration](http://www.zytrax.com/books/dns/ch7/rpz.html) 438 | 439 | [BIND 9 Administrator Reference 440 | Manual](https://ftp.isc.org/isc/bind9/9.12.0a1/doc/arm/Bv9ARM.pdf) 441 | 442 | [dnsrpz.info](https://dnsrpz.info) 443 | 444 | [lists.dns-oarc.net](https://lists.dns-oarc.net/pipermail/dns-operations/2017-October/016858.html) 445 | --------------------------------------------------------------------------------