├── .gitignore ├── LICENSE ├── README.md ├── dns.conf.example ├── docker-compose.yml ├── example.com.soa ├── fakedns.py ├── tests ├── Dockerfile └── test.py └── whitelist.dns.conf /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Bryan "Crypt0s" Halfpap 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | FakeDns 2 | ======= 3 | Update 4/14/2020 - Python 2 support removed and code swapped to Python3 4 | 5 | Now with round-robin & improved options! 6 | 7 | Bugs: 8 | @crypt0s - Twitter 9 | 10 | bryanhalf@gmail.com - Email 11 | 12 | 13 | A python regular-expression based DNS server! 14 | 15 | USAGE: 16 | ./fakedns.py [-h] -c Config path [-i interface IP address] [--rebind] 17 | 18 | The dns.conf should be set the following way: 19 | 20 | [RECORD TYPE CODE] [python regular expression] [answer] [rebind answer] 21 | 22 | The answer could be a ip address or string `self`, 23 | the `self` syntax sugar will be translated to your current machine's local ip address, such as `192.168.1.100`. 24 | 25 | If a match is not made, the DNS server will attempt to resolve the request using whatever you have your DNS server set to on your local machine and will proxy the request to that server on behalf of the requesting user. 26 | 27 | 28 | Supported Request Types 29 | ======================= 30 | - A 31 | - TXT 32 | - AAAA 33 | - PTR 34 | - SOA 35 | 36 | In-Progress Request Types 37 | ========================= 38 | - MX 39 | - CNAME 40 | 41 | Misc 42 | ==== 43 | - Supports DNS Rebinding 44 | - Supports round-robin 45 | 46 | Round-Robin 47 | =========== 48 | Round-robin rules are implemented. Every time a client requests a matching rule, FakeDNS will serve out the next IP in the list of IP's provided in the rule. 49 | A list of IP's is comma-separated. 50 | 51 | 52 | For example: 53 | 54 | A robin.net 1.2.3.4,1.1.1.1,2.2.2.2 55 | 56 | Is a round-robin rule for robin.net which will serve out responses pointing to 1.2.3.4, 1.1.1.1, and 2.2.2.2, iterating through that order every time a request is made by any client for the robin.net entry. 57 | 58 | *NOTE* : These IP's aren't included as a list to the client in the response - they still only get just one IP in the response (could change that later) 59 | 60 | DNS Rebinding 61 | ============= 62 | FakeDNS supports rebinding rules, which basically means that the server accepts a certain number of requests from a client for a domain until a threshold (default 1 request) and then it changes the IP address to a different one. 63 | 64 | For example: 65 | 66 | A rebind.net 1.1.1.1 10%4.5.6.7 67 | 68 | Means that we have an A record for rebind.net which evaluates to 1.1.1.1 for the first 10 tries. On the 11th request from a client which has already made 10 requests, FakeDNS starts serving out the second ip, 4.5.6.7 69 | 70 | You can use a list of addresses here and FakeDNS will round-robin them for you, just like in the "regular" rule. 71 | 72 | 73 | Testing FakeDNS in Docker 74 | ====== 75 | _(localhost only without extra steps)_ 76 | 77 | I have had a lot of success testing/developing FakeDNS in Docker because it's easier than running it natively on modern Ubuntu installs which have their own DNS services running on port 53 already. 78 | 79 | If you want to try it out, you can do so without much heavy lifting by following these steps: 80 | 81 | Assuming you are **_inside the FakeDns directory_**: `sudo docker run --interactive --tty --volume \`pwd\`:/opt/FakeDns -p 5353:53/udp python:3.8 /opt/FakeDns/fakedns.py -c /opt/FakeDns/dns.conf.example`. And to test you can run `nslookup -port=5353 testrule.test 127.0.0.1` which should return `1.1.1.1` on your first request 82 | 83 | Or, if you'd like to use docker-compose, simply run `docker-compose up` and use the same test as above. -------------------------------------------------------------------------------- /dns.conf.example: -------------------------------------------------------------------------------- 1 | CNAME woot.com asdf.com 2 | A .*reddit.* 8.8.8.8 3 | TXT .* HELLO 4 | AAAA lulz.com 2607:f8b0:4006:807::100e 5 | A .*rebind.* 1.1.1.1 2.2.2.2 6 | A testrule.test 1.1.1.1,2.2.2.2,3.3.3.3 7 | A roundrobin 1.1.1.1 10%2.2.2.2,3.3.3.3,4.4.4.4 8 | # comment 9 | # A sample PTR entry, note the backwards IP here is required. 10 | PTR 1.0.0.127 localhost 11 | SOA example.com example.com.soa # SOA rules have a special syntax: SOA [target domain] [config file path] 12 | # see the example.com.soa file for an example and additional comments 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | FakeDns: 4 | container_name: FakeDns 5 | image: python:3.8 6 | working_dir: /opt/FakeDns/ 7 | command: /opt/FakeDns/fakedns.py -c /opt/FakeDns/dns.conf.example 8 | volumes: 9 | - ./:/opt/FakeDns 10 | ports: 11 | - "53:53/udp" 12 | FakeDns-test: 13 | container_name: FakeDns-test 14 | image: fakedns-test:latest 15 | build: ./tests 16 | depends_on: 17 | - FakeDns -------------------------------------------------------------------------------- /example.com.soa: -------------------------------------------------------------------------------- 1 | [example.com.] ; extra dot required at end. 2 | ; example python ini file comment. 3 | mname=ns1 ; the domain name (.example.com) will be appended to this automatically 4 | rname=mx ; the domain name (.example.com) will be appended to this automatically 5 | serial=random ; this can be random (for a random 32-bit integer) or an integer number 6 | refresh=60 7 | retry=60 8 | expire=60 9 | minimum=60 10 | 11 | ;[ruledomain.tld.] ; the extra dot at the end IS REQUIRED 12 | ;mname -- name server that was original or primary source for this zone 13 | ;rname -- domain name which specified mailbox of person responsible for zone 14 | ;serial-- 32-bit long version number of the zone copy 15 | ;refresh- 32-bit time interval before zone refresh 16 | ;retry -- 32-bit time interval before retrying failed refresh 17 | ;expire-- 32-bit time interval after which the zone is not authoritative 18 | ;minimum- The unsigned 32 bit minimum TTL for any RR from this zone. -------------------------------------------------------------------------------- /fakedns.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Fakedns.py: A regular-expression based DNS MITM Server by Crypt0s.""" 3 | 4 | # This isn't the most elegent way - i could possibly support both versions of python, 5 | # but people should really not use Python 2 anymore. 6 | import sys 7 | vnum = sys.version.split()[0] 8 | if int(vnum[0]) < 3: 9 | print("Python 2 support has been deprecated. Please run FakeDNS using Python3!") 10 | sys.exit(1) 11 | 12 | import binascii 13 | import socket 14 | import re 15 | import sys 16 | import os 17 | import socketserver as SocketServer 18 | import signal 19 | import argparse 20 | import struct 21 | import random 22 | import configparser as ConfigParser 23 | 24 | # inspired from DNSChef 25 | class ThreadedUDPServer(SocketServer.ThreadingMixIn, SocketServer.UDPServer): 26 | def __init__(self, server_address, request_handler): 27 | self.address_family = socket.AF_INET 28 | SocketServer.UDPServer.__init__( 29 | self, server_address, request_handler) 30 | 31 | 32 | class UDPHandler(SocketServer.BaseRequestHandler): 33 | def handle(self): 34 | (data, s) = self.request 35 | respond(data, self.client_address, s) 36 | 37 | 38 | class DNSQuery: 39 | def __init__(self, data): 40 | self.data = data 41 | self.domain = b'' 42 | tipo = (data[2] >> 3) & 15 # Opcode bits 43 | if tipo == 0: # Standard query 44 | ini = 12 45 | lon = data[ini] 46 | while lon != 0: 47 | self.domain += data[ini + 1:ini + lon + 1] + b'.' 48 | ini += lon + 1 # you can implement CNAME and PTR 49 | lon = data[ini] 50 | self.type = data[ini:][1:3] 51 | else: 52 | self.type = data[-4:-2] 53 | 54 | # Because python doesn't have native ENUM in 2.7: 55 | # https://en.wikipedia.org/wiki/List_of_DNS_record_types 56 | TYPE = { 57 | b"\x00\x01": "A", 58 | b"\x00\x1c": "AAAA", 59 | b"\x00\x05": "CNAME", 60 | b"\x00\x0c": "PTR", 61 | b"\x00\x10": "TXT", 62 | b"\x00\x0f": "MX", 63 | b"\x00\x06": "SOA" 64 | } 65 | 66 | # Stolen: 67 | # https://github.com/learningequality/ka-lite/blob/master/python-packages/django/utils/ipv6.py#L209 68 | def _is_shorthand_ip(ip_str): 69 | """Determine if the address is shortened. 70 | Args: 71 | ip_str: A string, the IPv6 address. 72 | Returns: 73 | A boolean, True if the address is shortened. 74 | """ 75 | if ip_str.count('::') == 1: 76 | return True 77 | if any(len(x) < 4 for x in ip_str.split(':')): 78 | return True 79 | return False 80 | 81 | # Stolen: 82 | # https://github.com/learningequality/ka-lite/blob/master/python-packages/django/utils/ipv6.py#L209 83 | def _explode_shorthand_ip_string(ip_str): 84 | """ 85 | Expand a shortened IPv6 address. 86 | Args: 87 | ip_str: A string, the IPv6 address. 88 | Returns: 89 | A string, the expanded IPv6 address. 90 | """ 91 | if not _is_shorthand_ip(ip_str): 92 | # We've already got a longhand ip_str. 93 | return ip_str 94 | 95 | hextet = ip_str.split('::') 96 | 97 | # If there is a ::, we need to expand it with zeroes 98 | # to get to 8 hextets - unless there is a dot in the last hextet, 99 | # meaning we're doing v4-mapping 100 | if '.' in ip_str.split(':')[-1]: 101 | fill_to = 7 102 | else: 103 | fill_to = 8 104 | 105 | if len(hextet) > 1: 106 | sep = len(hextet[0].split(':')) + len(hextet[1].split(':')) 107 | new_ip = hextet[0].split(':') 108 | 109 | for _ in range(fill_to - sep): 110 | new_ip.append('0000') 111 | new_ip += hextet[1].split(':') 112 | 113 | else: 114 | new_ip = ip_str.split(':') 115 | 116 | # Now need to make sure every hextet is 4 lower case characters. 117 | # If a hextet is < 4 characters, we've got missing leading 0's. 118 | ret_ip = [] 119 | for hextet in new_ip: 120 | ret_ip.append(('0' * (4 - len(hextet)) + hextet).lower()) 121 | return ':'.join(ret_ip) 122 | 123 | 124 | def _get_question_section(query): 125 | # Query format is as follows: 12 byte header, question section (comprised 126 | # of arbitrary-length name, 2 byte type, 2 byte class), followed by an 127 | # additional section sometimes. (e.g. OPT record for DNSSEC) 128 | start_idx = 12 129 | end_idx = start_idx 130 | 131 | num_questions = (query.data[4] << 8) | query.data[5] 132 | 133 | while num_questions > 0: 134 | while query.data[end_idx] != 0: 135 | end_idx += query.data[end_idx] + 1 136 | # Include the null byte, type, and class 137 | end_idx += 5 138 | num_questions -= 1 139 | 140 | return query.data[start_idx:end_idx] 141 | 142 | 143 | class DNSFlag: 144 | # qr opcode aa tc rd ra z rcode 145 | # 1 0000 0 0 1 1 000 0000 146 | # accept a series of kwargs to build a proper flags segment. 147 | def __init__(self, 148 | qr=0b1, # query record, 1 if response 149 | opcode=0b0000, # 0 = query, 1 = inverse query, 2 = status request 3-15 unused 150 | aa=0b0, # authoritative answer = 1 151 | tc=0b0, # truncation - 1 if truncated 152 | rd=0b1, # recursion desired? 153 | ra=0b1, # recursion available 154 | z=0b000, # Reserved, must be zero in queries and responsed 155 | rcode=0b0000 # errcode, 0 none, 1 format, 2 server, 3 name, 4 not impl, 5 refused, 6-15 unused 156 | ): 157 | 158 | # pack the elements into an integer 159 | flag_field = qr 160 | flag_field = flag_field << 4 161 | flag_field ^= opcode 162 | flag_field = flag_field << 1 163 | flag_field ^= aa 164 | flag_field = flag_field << 1 165 | flag_field ^= tc 166 | flag_field = flag_field << 1 167 | flag_field ^= rd 168 | flag_field = flag_field << 1 169 | flag_field ^= ra 170 | flag_field = flag_field << 3 171 | flag_field ^= z 172 | flag_field = flag_field << 4 173 | flag_field ^= rcode 174 | 175 | self.flag_field = flag_field 176 | 177 | # return char rep. 178 | def pack(self): 179 | return struct.pack(">H", self.flag_field) 180 | 181 | 182 | class DNSResponse(object): 183 | def __init__(self, query): 184 | self.id = query.data[:2] # Use the ID from the request. 185 | self.flags = DNSFlag(aa=args.authoritative).pack() 186 | self.questions = query.data[4:6] # Number of questions asked... 187 | # Answer RRs (Answer resource records contained in response) 1 for now. 188 | self.rranswers = b"\x00\x01" 189 | self.rrauthority = b"\x00\x00" # Same but for authority 190 | self.rradditional = b"\x00\x00" # Same but for additionals. 191 | # Include the question section 192 | self.query = _get_question_section(query) 193 | # The pointer to the resource record - seems to always be this value. 194 | self.pointer = b"\xc0\x0c" 195 | # This value is set by the subclass and is defined in TYPE dict. 196 | self.type = None 197 | self.dnsclass = b"\x00\x01" # "IN" class. 198 | # TODO: Make this adjustable - 1 is good for noobs/testers 199 | self.ttl = b"\x00\x00\x00\x01" 200 | # Set by subclass because is variable except in A/AAAA records. 201 | self.length = None 202 | self.data = None # Same as above. 203 | 204 | def make_packet(self): 205 | try: 206 | return self.id + self.flags + self.questions + self.rranswers + \ 207 | self.rrauthority + self.rradditional + self.query + \ 208 | self.pointer + self.type + self.dnsclass + self.ttl + \ 209 | self.length + self.data 210 | except Exception as e: #(TypeError, ValueError): 211 | print("[!] - %s" % str(e)) 212 | 213 | # All classes need to set type, length, and data fields of the DNS Response 214 | # Finished 215 | class A(DNSResponse): 216 | def __init__(self, query, record): 217 | super(A, self).__init__(query) 218 | self.type = b"\x00\x01" 219 | self.length = b"\x00\x04" 220 | self.data = self.get_ip(record) 221 | 222 | @staticmethod 223 | def get_ip(dns_record): 224 | ip = dns_record 225 | # Convert to hex 226 | return b''.join(int(x).to_bytes(1, 'little') for x in ip.split('.')) 227 | 228 | # Implemented 229 | class AAAA(DNSResponse): 230 | def __init__(self, query, address): 231 | super(AAAA, self).__init__(query) 232 | self.type = b"\x00\x1c" 233 | self.length = b"\x00\x10" 234 | # Address is already encoded properly for the response at rule-builder 235 | self.data = address 236 | 237 | # Thanks, stackexchange! 238 | # http://stackoverflow.com/questions/16276913/reliably-get-ipv6-address-in-python 239 | def get_ip_6(host, port=0): 240 | # search only for the wanted v6 addresses 241 | result = socket.getaddrinfo(host, port, socket.AF_INET6) 242 | # Will need something that looks like this: 243 | # just returns the first answer and only the address 244 | ip = result[0][4][0] 245 | 246 | # Implemented 247 | class CNAME(DNSResponse): 248 | def __init__(self, query, domain): 249 | super(CNAME, self).__init__(query) 250 | self.type = b"\x00\x05" 251 | 252 | self.data = b"" 253 | for label in domain.split('.'): 254 | self.data += chr(len(label)).encode() + label.encode() 255 | self.data += b"\x00" 256 | 257 | self.length = chr(len(self.data)).encode() 258 | # Must be two bytes. 259 | if len(self.length) < 2: 260 | self.length = b"\x00" + self.length 261 | 262 | # Implemented 263 | class PTR(DNSResponse): 264 | def __init__(self, query, ptr_entry): 265 | super(PTR, self).__init__(query) 266 | if type(ptr_entry) != bytes: 267 | ptr_entry = ptr_entry.encode() 268 | 269 | self.type = b"\x00\x0c" 270 | self.ttl = b"\x00\x00\x00\x00" 271 | ptr_split = ptr_entry.split(b'.') 272 | ptr_entry = b"\x07".join(ptr_split) 273 | 274 | self.data = b"\x09" + ptr_entry + b"\x00" 275 | self.length = chr(len(ptr_entry) + 2) 276 | # Again, must be 2-byte value. 277 | if self.length < "0xff": 278 | self.length = b"\x00" + self.length.encode() 279 | 280 | # Finished 281 | class TXT(DNSResponse): 282 | def __init__(self, query, txt_record): 283 | super(TXT, self).__init__(query) 284 | self.type = b"\x00\x10" 285 | self.data = txt_record.encode() 286 | self.length = chr(len(txt_record) + 1).encode() 287 | # Must be two bytes. This is the better, more python-3 way to calculate length. Swap to this later. 288 | if len(self.length) < 2: 289 | self.length = b"\x00" + self.length 290 | # Then, we have to add the TXT record length field! We utilize the 291 | # length field for this since it is already in the right spot 292 | self.length += chr(len(txt_record)).encode() 293 | 294 | 295 | class MX(DNSResponse): 296 | def __init__(self, query, txt_record): 297 | super(MX, self).__init__(query) 298 | self.type = b"\x00\x0f" 299 | self.data = b"\x00\x01" + self.get_domain(txt_record) + b"\x00" 300 | self.length = chr(len(txt_record) + 4) 301 | if self.length < '\xff': 302 | self.length = "\x00" + self.length 303 | 304 | @staticmethod 305 | def get_domain(dns_record): 306 | domain = dns_record 307 | ret_domain=[] 308 | for x in domain.split('.'): 309 | st = "{:02x}".format(len(x)) 310 | ret_domain.append( st.decode("hex")) 311 | ret_domain.append(x) 312 | return "".join(ret_domain) 313 | 314 | class SOA(DNSResponse): 315 | def __init__(self, query, config_location): 316 | super(SOA, self).__init__(query) 317 | 318 | # TODO: pre-read and cache all the config files for the rules for speed. 319 | config = ConfigParser.ConfigParser(inline_comment_prefixes=";") 320 | config.read(config_location) 321 | 322 | # handle cases where we want the serial to be random 323 | serial = config.get(query.domain.decode(), "serial") 324 | if serial.lower() == "random": 325 | serial = int(random.getrandbits(32)) 326 | else: 327 | # serial is still a str, cast to int. 328 | serial = int(serial) 329 | 330 | self.type = b"\x00\x06" 331 | self.mname = config.get(query.domain.decode(), "mname") # name server that was original or primary source for this zone 332 | self.rname = config.get(query.domain.decode(), "rname") # domain name which specified mailbox of person responsible for zone 333 | self.serial = serial # 32-bit long version number of the zone copy 334 | self.refresh = config.getint(query.domain.decode(), "refresh")# 32-bit time interval before zone refresh 335 | self.retry = config.getint(query.domain.decode(), "retry") # 32-bit time interval before retrying failed refresh 336 | self.expire = config.getint(query.domain.decode(), "expire") # 32-bit time interval after which the zone is not authoritative 337 | self.minimum = config.getint(query.domain.decode(), "minimum")# The unsigned 32 bit minimum TTL for any RR from this zone. 338 | 339 | # convert the config entries into DNS format. Convenient conversion function will be moved up to module later. 340 | def convert(fqdn): 341 | tmp = b"" 342 | for domain in fqdn.split('.'): 343 | tmp += chr(len(domain)).encode() + domain.encode() 344 | tmp += b"\xc0\x0c" 345 | return tmp 346 | 347 | self.data = b"" 348 | 349 | self.mname = convert(self.mname) 350 | self.data += self.mname 351 | 352 | self.rname = convert(self.rname) 353 | self.data += self.rname # already is a bytes object. 354 | 355 | # pack the rest of the structure 356 | self.data += struct.pack('>I', self.serial) 357 | self.data += struct.pack('>I', self.refresh) 358 | self.data += struct.pack('>I', self.retry) 359 | self.data += struct.pack('>I', self.refresh) 360 | self.data += struct.pack('>I', self.minimum) 361 | 362 | # get length of the answers area 363 | self.length = chr(len(self.data)) 364 | 365 | # length is always two bytes - add the extra blank byte if we're not large enough for two bytes. 366 | if self.length < "0xff": 367 | self.length = b"\x00" + self.length.encode() 368 | 369 | 370 | 371 | # And this one is because Python doesn't have Case/Switch 372 | CASE = { 373 | b"\x00\x01": A, 374 | b"\x00\x1c": AAAA, 375 | b"\x00\x05": CNAME, 376 | b"\x00\x0c": PTR, 377 | b"\x00\x10": TXT, 378 | b"\x00\x0f": MX, 379 | b"\x00\x06": SOA, 380 | } 381 | 382 | # Technically this is a subclass of A 383 | class NONEFOUND(DNSResponse): 384 | def __init__(self, query): 385 | super(NONEFOUND, self).__init__(query) 386 | self.type = query.type 387 | self.flags = b"\x81\x83" 388 | self.rranswers = b"\x00\x00" 389 | self.length = b"\x00\x00" 390 | self.data = b"\x00" 391 | print(">> Built NONEFOUND response") 392 | 393 | 394 | class Rule (object): 395 | def __init__(self, rule_type, domain, ips, rebinds, threshold): 396 | self.type = rule_type 397 | self.domain = domain 398 | self.ips = ips 399 | self.rebinds = rebinds 400 | self.rebind_threshold = threshold 401 | 402 | # we need an additional object to track the rebind rules 403 | if self.rebinds is not None: 404 | self.match_history = {} 405 | self.rebinds = self._round_robin(rebinds) 406 | self.ips = self._round_robin(ips) 407 | 408 | def _round_robin(self, ip_list): 409 | """ 410 | Creates a generator over a list modulo list length to equally move between all elements in the list each request 411 | Since we have rules broken out into objects now, we can have this without much overhead. 412 | """ 413 | # check to make sure we don't try to modulo by zero 414 | # if we would, just add the same element to the list again. 415 | if len(ip_list) == 1: 416 | ip_list.append(ip_list[0]) 417 | 418 | # should be fine to continue now. 419 | index = 0 420 | while 1: # never stop iterating - it's OK since we dont always run 421 | yield ip_list[index] 422 | index += 1 423 | index = index % len(ip_list) 424 | 425 | def match(self, req_type, domain, addr): 426 | # assert that the query type and domain match 427 | try: 428 | req_type = TYPE[req_type] 429 | except KeyError: 430 | return None 431 | 432 | try: 433 | assert self.type == req_type 434 | except AssertionError: 435 | return None 436 | 437 | try: 438 | assert self.domain.match(domain.decode()) 439 | except AssertionError: 440 | return None 441 | 442 | # Check to see if we have a rebind rule and if we do, return that addr first 443 | if self.rebinds: 444 | if self.match_history.get(addr) is not None: 445 | 446 | # passed the threshold - start doing a rebind 447 | if self.match_history[addr] >= self.rebind_threshold: 448 | return next(self.rebinds) 449 | 450 | # plus one 451 | else: 452 | self.match_history[addr] += 1 453 | 454 | # add new client to this match history 455 | else: 456 | self.match_history[addr] = 1 457 | 458 | # We didn't trip on any rebind rules (or didnt have any) 459 | # but we're returning a rule-based entry based on the match 460 | return next(self.ips) 461 | 462 | 463 | # Error classes for handling rule issues 464 | class RuleError_BadRegularExpression(Exception): 465 | def __init__(self,lineno): 466 | print("\n!! Malformed Regular Expression on rulefile line #%d\n\n" % lineno) 467 | 468 | 469 | class RuleError_BadRuleType(Exception): 470 | def __init__(self,lineno): 471 | print("\n!! Rule type unsupported on rulefile line #%d\n\n" % lineno) 472 | 473 | 474 | class RuleError_BadFormat(Exception): 475 | def __init__(self,lineno): 476 | print("\n!! Not Enough Parameters for rule on rulefile line #%d\n\n" % lineno) 477 | 478 | 479 | class RuleEngine2: 480 | 481 | # replaces the self keyword, but could be expanded to any keyword replacement 482 | def _replace_self(self, ips): 483 | # Deal with the user putting "self" in a rule (helpful if you don't know your IP) 484 | for ip in ips: 485 | if ip.lower() == 'self': 486 | try: 487 | self_ip = socket.gethostbyname(socket.gethostname()) 488 | except socket.error: 489 | print(">> Could not get your IP address from your " \ 490 | "DNS Server.") 491 | self_ip = '127.0.0.1' 492 | ips[ips.index(ip)] = self_ip 493 | return ips 494 | 495 | 496 | def __init__(self, file_): 497 | """ 498 | Parses the DNS Rulefile, validates the rules, replaces keywords 499 | 500 | """ 501 | 502 | # track DNS requests here 503 | self.match_history = {} 504 | 505 | self.rule_list = [] 506 | 507 | # A lol.com IP1,IP2,IP3,IP4,IP5,IP6 rebind_threshold%Rebind_IP1,Rebind_IP2 508 | with open(file_, 'r') as rulefile: 509 | rules = rulefile.readlines() 510 | lineno = 0 # keep track of line number for errors 511 | 512 | for rule in rules: 513 | 514 | # ignore blank lines or lines starting with hashmark (coments) 515 | if len(rule.strip()) == 0 or rule.lstrip()[0] == "#" or rule == '\n': 516 | # thank you to github user cambid for the comments suggestion 517 | continue 518 | 519 | # Confirm that the rule has at least three columns to it 520 | if len(rule.split()) < 3: 521 | raise RuleError_BadFormat(lineno) 522 | 523 | # break the rule out into its components 524 | s_rule = rule.split() 525 | rule_type = s_rule[0].upper() 526 | domain = s_rule[1] 527 | ips = s_rule[2].split(',') # allow multiple ip's thru commas 528 | 529 | # only try this if the rule is long enough 530 | if len(s_rule) == 4: 531 | rebinds = s_rule[3] 532 | # handle old rule style (maybe someone updated) 533 | if '%' in rebinds: 534 | rebind_threshold,rebinds = rebinds.split('%') 535 | rebinds = rebinds.split(',') 536 | rebind_threshold = int(rebind_threshold) 537 | else: 538 | # in the old days we assumed a rebind thresh of 1 539 | rebind_threshold = 1 540 | else: 541 | rebinds = None 542 | rebind_threshold = None 543 | 544 | # Validate the rule 545 | # make sure we understand this type of response 546 | if rule_type not in TYPE.values(): 547 | raise RuleError_BadRuleType(lineno) 548 | # attempt to parse the regex (if any) in the domain field 549 | try: 550 | domain = re.compile(domain, flags=re.IGNORECASE) 551 | except: 552 | raise RuleError_BadRegularExpression(lineno) 553 | 554 | # replace self in the list of ips and list of rebinds (if any) 555 | ips = self._replace_self(ips) 556 | if rebinds is not None: 557 | rebinds = self._replace_self(rebinds) 558 | 559 | # Deal With Special IPv6 Nonsense 560 | if rule_type.upper() == "AAAA": 561 | tmp_ip_array = [] 562 | for ip in ips: 563 | if ip.lower() == 'none': 564 | tmp_ip_array.append(ip) 565 | continue 566 | if _is_shorthand_ip(ip): 567 | ip = _explode_shorthand_ip_string(ip) 568 | ip = binascii.unhexlify(ip.replace(":", "")) #.decode('hex') 569 | tmp_ip_array.append(ip) 570 | ips = tmp_ip_array 571 | 572 | 573 | # add the validated and parsed rule into our list of rules 574 | self.rule_list.append(Rule(rule_type, domain, ips, rebinds, rebind_threshold)) 575 | 576 | # increment the line number 577 | lineno += 1 578 | 579 | print(">> Parsed %d rules from %s" % (len(self.rule_list),file_)) 580 | 581 | 582 | def match(self, query, addr): 583 | """ 584 | See if the request matches any rules in the rule list by calling the 585 | match function of each rule in the list 586 | 587 | The rule checks two things before it continues so I imagine this is 588 | probably still fast 589 | 590 | """ 591 | for rule in self.rule_list: 592 | result = rule.match(query.type, query.domain, addr) 593 | if result is not None: 594 | response_data = result 595 | 596 | # Return Nonefound if the rule says "none" 597 | if response_data.lower() == 'none': 598 | return NONEFOUND(query).make_packet() 599 | 600 | response = CASE[query.type](query, response_data) 601 | 602 | print(">> Matched Request - " + query.domain.decode()) 603 | return response.make_packet() 604 | 605 | # if we got here, we didn't match. 606 | # Forward a request that we didnt have a rule for to someone else 607 | 608 | # if the user said not to forward requests, and we are here, it's time to send a NONEFOUND 609 | if args.noforward: 610 | print(">> Don't Forward %s" % query.domain.decode()) 611 | return NONEFOUND(query).make_packet() 612 | try: 613 | s = socket.socket(type=socket.SOCK_DGRAM) 614 | s.settimeout(3.0) 615 | addr = ('%s' % (args.dns), 53) 616 | s.sendto(query.data, addr) 617 | data = s.recv(1024) 618 | s.close() 619 | print("Unmatched Request " + query.domain.decode()) 620 | return data 621 | except socket.error as e: 622 | # We shouldn't wind up here but if we do, don't drop the request 623 | # send the client *something* 624 | print(">> Error was handled by sending NONEFOUND") 625 | print(e) 626 | return NONEFOUND(query).make_packet() 627 | 628 | 629 | # Convenience method for threading. 630 | def respond(data, addr, s): 631 | p = DNSQuery(data) 632 | response = rules.match(p, addr[0]) 633 | s.sendto(response, addr) 634 | return response 635 | 636 | # Capture Control-C and handle here 637 | def signal_handler(signal, frame): 638 | print('Exiting...') 639 | sys.exit(0) 640 | 641 | 642 | if __name__ == '__main__': 643 | 644 | parser = argparse.ArgumentParser(description='FakeDNS - A Python DNS Server') 645 | parser.add_argument( 646 | '-c', dest='path', action='store', required=True, 647 | help='Path to configuration file') 648 | parser.add_argument( 649 | '-i', dest='iface', action='store', default='0.0.0.0', required=False, 650 | help='IP address you wish to run FakeDns with - default all') 651 | parser.add_argument( 652 | '-p', dest='port', action='store', default=53, required=False, 653 | help='Port number you wish to run FakeDns') 654 | parser.add_argument( 655 | '--rebind', dest='rebind', action='store_true', required=False, 656 | default=False, help="Enable DNS rebinding attacks - responds with one " 657 | "result the first request, and another result on subsequent requests") 658 | parser.add_argument( 659 | '--dns', dest='dns', action='store', default='8.8.8.8', required=False, 660 | help='IP address of the upstream dns server - default 8.8.8.8' 661 | ) 662 | parser.add_argument( 663 | '--noforward', dest='noforward', action='store_true', default=False, required=False, 664 | help='Sets if FakeDNS should forward any non-matching requests' 665 | ) 666 | 667 | # todo: remove this - it's confusing, and we should be able to set this per-record. Keep for now for quickness. 668 | parser.add_argument( 669 | '--non-authoritative', dest='non_authoritative', action='store_true', default=False, required=False, 670 | help='Sets if FakeDNS should not report as an authority for any matching DNS Queries' 671 | ) 672 | 673 | args = parser.parse_args() 674 | 675 | # if non-authoritative is set to true, it'll cancel out the default authoritative setting 676 | # this is a not-very-coherent way to pull this off but we'll be changing the behavior of FakeDNS soon so it's OK 677 | args.authoritative = True ^ args.non_authoritative 678 | 679 | # Default config file path. 680 | path = args.path 681 | if not os.path.isfile(path): 682 | print('>> Please create a "dns.conf" file or specify a config path: ' \ 683 | './fakedns.py [configfile]') 684 | exit() 685 | 686 | rules = RuleEngine2(path) 687 | rule_list = rules.rule_list 688 | 689 | interface = args.iface 690 | port = args.port 691 | 692 | try: 693 | server = ThreadedUDPServer((interface, int(port)), UDPHandler) 694 | except socket.error: 695 | print(">> Could not start server -- is another program on udp:{0}?".format(port)) 696 | exit(1) 697 | 698 | server.daemon = True 699 | 700 | # Tell python what happens if someone presses ctrl-C 701 | signal.signal(signal.SIGINT, signal_handler) 702 | server.serve_forever() 703 | server_thread.join() 704 | -------------------------------------------------------------------------------- /tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | WORKDIR /opt/FakeDns/tests 4 | COPY ./ /opt/FakeDns/tests 5 | RUN pip install dnspython 6 | CMD python3 -m unittest discover -v /opt/FakeDns/tests -------------------------------------------------------------------------------- /tests/test.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """Test framework for FakeDns""" 4 | 5 | 6 | """Imported Libraries 7 | 8 | unittest - Unit Testing Python Framework 9 | socket - Do one cheap DNS Lookup variant 10 | dns - DNS Query library 11 | """ 12 | import unittest 13 | import socket 14 | import dns.resolver 15 | 16 | 17 | """Global Variables 18 | 19 | """ 20 | 21 | 22 | class DNSTestCase(unittest.TestCase): 23 | """Parent Class to give common function and setUp 24 | """ 25 | 26 | 27 | def _dns_lookup(self, q: str, record_type: str) -> str: 28 | """Does a DNS lookup for us, and returns the string 29 | 30 | :param q: query (ip or hostname) 31 | :type q: str 32 | :param record_type: DNS record type 33 | :type record_type: str 34 | :return: DNS response 35 | :rtype: str 36 | """ 37 | # Ask the DNS server 38 | answer = self.resolver.resolve(q, record_type) 39 | # Make sure we only got one response 40 | if len(answer.rrset) != 1: 41 | raise RuntimeError( 42 | "ERROR: More than one record set return in _dns_lookup(\"{}\", \"{}\")".format(q, record_type) 43 | ) 44 | # Return the value 45 | return answer.rrset[0].to_text() 46 | 47 | 48 | def setUp(self): 49 | """Creates the FakeDns Process 50 | """ 51 | # Determine FakeDns IP address to use that as the name server 52 | self.resolver = dns.resolver.Resolver() 53 | self.resolver.nameservers = [socket.gethostbyname('FakeDns')] # Can't lookup 'FakeDns' via dns.resolver 54 | 55 | 56 | class TestRecordTypes(DNSTestCase): 57 | """Checks the return of specific DNS Requests to ensure all record types are working as intended 58 | """ 59 | 60 | 61 | def tearDown(self): 62 | """Destroys the FakeDns process 63 | """ 64 | del self.resolver 65 | 66 | 67 | def test_ARecord(self): 68 | """Tests A Record 69 | """ 70 | dns_response = self._dns_lookup("test.reddit.com", "A") 71 | self.assertEqual(dns_response, "8.8.8.8") 72 | 73 | 74 | def test_TXTRecord(self): 75 | """Tests TXT Record 76 | """ 77 | dns_response = self._dns_lookup("anyvalue", "TXT") 78 | self.assertEqual(dns_response, "\"HELLO\"") 79 | 80 | 81 | def test_AAAARecord(self): 82 | """Tests AAAA Record 83 | """ 84 | dns_response = self._dns_lookup("lulz.com", "AAAA") 85 | self.assertEqual(dns_response, "2607:f8b0:4006:807::100e") 86 | 87 | 88 | def test_PTRRecord(self): 89 | """Tests PTR Record 90 | """ 91 | dns_response = self._dns_lookup("1.0.0.127", "PTR") 92 | self.assertEqual(dns_response, "localhost.") 93 | 94 | 95 | def test_SOARecord(self): 96 | """Tests SOA Record 97 | """ 98 | dns_response = self._dns_lookup("example.com", "SOA") 99 | self.assertTrue( 100 | dns_response.startswith("ns1.example.com. mx.example.com. ") and dns_response.endswith(" 60 60 60 60") 101 | ) 102 | 103 | 104 | class TestFeatures(DNSTestCase): 105 | """Tests various DNS features implemented in FakeDns 106 | """ 107 | 108 | 109 | def test_Rebinding(self): 110 | """Test DNS rebinding 111 | """ 112 | # We do two rounds because we want to make sure the "wrapping around" feature doesn't break 113 | answers = [self._dns_lookup("testrule.test", "A") for _ in range(6)] 114 | expected_answers = ["1.1.1.1", "2.2.2.2", "3.3.3.3", "1.1.1.1", "2.2.2.2", "3.3.3.3"] 115 | self.assertEqual(answers, expected_answers) 116 | 117 | 118 | def test_RoundRobin(self): 119 | """Test DNS roundrobin 120 | """ 121 | answers = [self._dns_lookup("roundrobin", "A") for _ in range(13)] 122 | expected_answers = ["1.1.1.1"] * 10 + ["2.2.2.2", "3.3.3.3", "4.4.4.4"] 123 | self.assertEqual(answers, expected_answers) 124 | 125 | 126 | if __name__ == "__main__": 127 | unittest.main() 128 | -------------------------------------------------------------------------------- /whitelist.dns.conf: -------------------------------------------------------------------------------- 1 | A ^(?!.*(cnn\.com|google\.com)).* 127.0.0.1 2 | AAAA ^(?!.*(cnn\.com|google\.com)).* ::1 3 | --------------------------------------------------------------------------------