├── _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 '';
9 | foreach ($_SERVER as $h => $v)
10 | if (ereg('HTTP_(.+)', $h, $hp))
11 | echo "- $h = $v
\n";
12 | header('Content-type: text/html');
13 | ?>
14 |
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 '';
201 |
202 | foreach($_SERVER as $h => $v)
203 | if (ereg('HTTP_(.+)', $h, $hp)) echo "- $h = $v
\n";
204 | header('Content-type: text/html');
205 | ?>
206 |
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 | 
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 |
--------------------------------------------------------------------------------