diff --git a/.gitignore b/.gitignore index d444115..ad89810 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.py[ouc] .vscode/ venv/ + +*.ini diff --git a/README.md b/README.md index 3978e15..9ddd007 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,8 @@ In a few steps I was able to configure my NS provider to set myself up as my own For this examples, let's say my server is named `example.com`. -1. In my DNS Zone, I added a `NS` entry for `dns.example.com`, pointing to `dns.example.com.` (mind the final dot) -1. Then I added a `A` entry for `dns.example.com`, pointing to `my server ip here` +1. I added a `A` entry for `dns.example.com`, pointing to `my server ip here` 1. In the DNS servers configuration, I already had things like `ns1.provider.com`, I added myself as a DNS server: `dns.example.com`, pointing to `my server ip here` -1. For the redirections, I added `dns.example.com` as a type `A`, pointing to `my server ip here` 1. Then, just wait a bit (can be as long as 48 hours) and you're good to go Now I just have to tell my client scripts to use the domain `dns.example.com` to send messages to it and it works like a charm, even when asking Google about it! diff --git a/client.sh b/client.sh index 3240ffc..f3aadfa 100644 --- a/client.sh +++ b/client.sh @@ -1,13 +1,23 @@ #!/usr/bin/env bash -# send message -stripped_b32=`echo $1 | base32 | tr -d =` -crafted_domain="${stripped_b32}.dns.12f.pl" -answer=`dig @12f.pl $crafted_domain TXT` +if [[ $# != 2 ]]; then + echo "Usage:" + echo " $0 hostname message" + exit 1 +fi + +StartDate=$(date -u +"%s.%N") + +# create message, remove padding +stripped_b32=$(echo "$2" | base32 | tr -d =) +# create domain +crafted_domain="${stripped_b32}.$1" +# make the DNS query and retrieve the answers +answer=$(dig "$crafted_domain" TXT) # decode answer -message=`echo $answer | grep -A 1 ";; ANSWER SECTION:" | tail -n 1 | egrep -o "\".+\"" | cut -c 2- | rev | cut -c 2- | rev` -length=$((4 - $(expr length "$message") % 4)) -# add padding accordingly +message=$(echo "$answer" | grep -A 1 ";; ANSWER SECTION:" | tail -n 1 | grep -E -o "\".+\"" | cut -c 2- | rev | cut -c 2- | rev) +length=$((4 - $(echo -n "$message" | wc -c) % 4)) +# add padding back accordingly case "$length" in "1") message="${message}=" @@ -21,7 +31,10 @@ case "$length" in *) ;; esac +# decode +decoded=$(echo "$message" | base64 -d) -decoded=`echo $message | base64 -d` - -echo "Received: $decoded" \ No newline at end of file +FinalDate=$(date -u +"%s.%N") +elapsed=$(date -u -d "0 $FinalDate sec - $StartDate sec" +"%S.%N") +echo "Received in $elapsed seconds" +echo "$decoded" diff --git a/config.ini.example b/config.ini.example new file mode 100644 index 0000000..254044d --- /dev/null +++ b/config.ini.example @@ -0,0 +1,16 @@ +[server] +interface= +root=example.com +domain=dns.example.com +host_ip= + +[packets] +ttl=1 + +[example.com] +ip= +ttl=3600 + +[nice.example.com] +ip= +ttl=3600 diff --git a/src/chatserver.py b/src/chatserver.py index 6b8dafb..6ef1201 100644 --- a/src/chatserver.py +++ b/src/chatserver.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 -import sys import time +from typing import List -from server import Server -from utils import get_ip_from_hostname +from server import main class Message: @@ -15,14 +14,12 @@ def __init__(self, author: str, content: str): self.seen_by = [] -class ChatServer(Server): - def __init__(self, interface: str, domain: str, host_ip: str): - super().__init__(interface, domain, host_ip) - +class ChatServer: + def __init__(self): self.messages = [] self.users = {} - def on_query(self, message: str, src_ip: str) -> str: + def __call__(self, message: str, src_ip: str, domains: List[str]) -> str: message = message.strip() if src_ip not in self.users: @@ -30,14 +27,15 @@ def on_query(self, message: str, src_ip: str) -> str: self.users[src_ip] = str(len(self.users)) # check for commands - if len(message) > 1 and message[0] != '/': + if len(message) > 1 and message[0] != "/": self.messages.append(Message(self.users[src_ip], message)) self.messages[-1].seen_by.append(self.users[src_ip]) return "/ok" elif message == "/consult": # get the user unread messages list history = [] - for msg in self.messages: + # get the last 5 messages in reverse order + for msg in self.messages[:-6:-1]: if self.users[src_ip] not in msg.seen_by: history.append(msg) # mark the message as seen @@ -45,20 +43,12 @@ def on_query(self, message: str, src_ip: str) -> str: # create the unread message list output = "" - for msg in history: + # append message in ascending order + for msg in history[::-1]: output += f"@{msg.author} [{msg.timestamp}]: {msg.content}\n" return output return "/error" if __name__ == "__main__": - if len(sys.argv) < 3: - print("Usage: %s interface hostname" % sys.argv[0]) - sys.exit(-1) - - ip = get_ip_from_hostname(sys.argv[2]) - if ip is None: - sys.exit(-1) - - server = ChatServer(sys.argv[1], sys.argv[2], ip) - server.run() + main(chat=ChatServer()) diff --git a/src/client.py b/src/client.py index c7f486c..1a771ed 100644 --- a/src/client.py +++ b/src/client.py @@ -14,7 +14,6 @@ logger = None -import base64 class Client: def __init__(self, domain: str, ip: str, verbosity: int = 0): diff --git a/src/packet.py b/src/packet.py index d8bebbf..c7143e3 100644 --- a/src/packet.py +++ b/src/packet.py @@ -4,20 +4,59 @@ from functools import reduce from random import randint -from scapy.layers.dns import DNS, DNSQR, DNSRR +from scapy.layers.dns import DNS, DNSQR, DNSRR, dnstypes from scapy.layers.inet import IP, UDP from utils import DNSHeaders +def build_tos( + precedence: int, lowdelay: bool, throughput: bool, reliability: bool, lowcost: bool +) -> int: + """Building IP Type of Service value + + Args: + precedence (int): intended to denote the importance or priority of the datagram + 0b1000 -- minimize delay + 0b0100 -- maximize throughput + 0b0010 -- maximize reliability + 0b0001 -- minimize monetary cost + 0b0000 -- normal service + lowdelay (bool): low (True), normal (False) + throughput (bool): high (True) or low (False) + reliability (bool): high (True) or normal (False) + lowcost (bool): minimize memory cost (True) + + Returns: + int: type of service as describe in the RFC 1349 and 791 + """ + return ( + (lowcost << 1) + + (reliability << 2) + + (throughput << 3) + + (lowdelay << 4) + + (max(min(precedence, 0b111), 0b000) << 5) + ) + + class Packet: @staticmethod def build_query(layer: dict, domain: str) -> object: - pkt = IP(dst=layer["dst"], tos=0x28) + """Build a DNS query packet + + Args: + layer (dict): dict of the different layer properties and values + domain (str): the domain the packet is from + + Returns: + object: a Packet object + """ + pkt = IP(dst=layer["dst"], tos=build_tos(1, 0, 1, 0, 0)) pkt /= UDP(sport=randint(0, 2 ** 16 - 1), dport=53) pkt /= DNS( + # random transaction id id=randint(0, 2 ** 16 - 1), - rd=0, # no recursion desired + rd=1, # recursion desired qr=DNSHeaders.QR.Query, # requests must be of type TXT otherwise our answers (of type TXT) # don't get transmitted if recursion occured @@ -28,6 +67,15 @@ def build_query(layer: dict, domain: str) -> object: @staticmethod def build_reply(layer: dict, domain: str) -> object: + """Build a DNS reply packet + + Args: + layer (dict): dict of the different layer properties and values + domain (str): the domain the packet is from + + Returns: + object: a Packet object + """ pkt = IP(dst=layer["dst"], src=layer["src"]) pkt /= UDP(dport=layer["dport"], sport=53) pkt /= DNS( @@ -44,14 +92,15 @@ def __init__(self, pkt: IP, domain: str): self._pkt = pkt self._domain = domain - def is_valid_dnsquery(self) -> bool: + def is_valid_dnsquery(self, qtype: str, domain: str = "") -> bool: def check_qname(q: str) -> str: - return q[len(q) - len(self._domain) - 2 : len(q) - 2] + return q.endswith(f"{domain if domain else self._domain}.") return ( DNS in self._pkt and self._pkt[DNS].opcode == DNSHeaders.OpCode.StdQuery and self._pkt[DNS].qr == DNSHeaders.QR.Query + and dnstypes[self._pkt[DNSQR].qtype] == qtype and check_qname(self._pkt[DNSQR].qname.decode("utf-8")) and self._pkt[DNS].ancount == 0 ) diff --git a/src/server.py b/src/server.py index 17a2530..5e71939 100644 --- a/src/server.py +++ b/src/server.py @@ -1,11 +1,14 @@ #!/usr/bin/env python3 import binascii +import os import socket import sys import threading +from configparser import ConfigParser +from typing import List -from scapy.layers.dns import DNS, DNSQR, DNSRR +from scapy.layers.dns import DNS, DNSQR, DNSRR, dnstypes from scapy.layers.inet import IP, UDP from scapy.sendrecv import send, sniff @@ -15,40 +18,95 @@ def socket_server(ip: str): + # bind UDP socket to port 53 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.bind((ip, 53)) + # and read until the end of the world while True: s.recvfrom(1024) s.close() class Server: - def __init__(self, interface: str, domain: str, host_ip: str): - self.interface = interface - self.host_ip = host_ip + @staticmethod + def from_file(filename: str): + if not os.path.exists(filename): + raise FileNotFoundError(filename) + + config = ConfigParser() + config.read(filename) + + return Server( + config["server"]["interface"], + config["server"]["domain"], + config["server"]["host_ip"], + config, + ) + + def __init__(self, iface: str, domain: str, ip: str, config: ConfigParser = None): + self.interface = iface + self.host_ip = ip self.domain = domain + self.config = config + + # subdomain => function(msg, ip, domains) + self.subservers = {} + self.logger = init_logger() - def on_query(self, message: str, src_ip: str) -> str: - return "test" + def register(self, **subservers): + self.subservers.update(subservers) - def dns_responder(self, pkt: IP): - packet = Packet(pkt, self.domain) + def on_query(self, message: str, src_ip: str, domains: List[str]) -> str: + if domains and self.subservers.get(domains[0]): + return self.subservers[domains[0]](message, src_ip, domains) + return "test" - if packet.is_valid_dnsquery(): - self.logger.info("got a packet from %s:%i", packet.src, packet.sport) + def _make_message(self, qname: str, content: str) -> DNSRR: + return DNSRR( + rrname=qname, + rdata=Content.encode(content), + type=DNSAnswer.Type.Text, + ttl=int(self.config["packets"]["ttl"]) if self.config else 60, + ) - subdomain = packet.subdomain_from_qname.split('.')[0] - self.logger.debug("subdomain: %s", subdomain) + def _make_txt(self, packet: Packet) -> Packet: + try: + subdomain, *domains = packet.subdomain_from_qname.split(".") + domains = domains[::-1] + data = Domain.decode(subdomain) + except binascii.Error: + # couldn't decode, drop the packet and do nothing + logger.debug("Couldn't decode subdomain in %s", packet.qname) + return + + return Packet.build_reply( + { + "src": self.host_ip, + "dst": packet.src, + "dport": packet.sport, + "dns": { + "id": packet.id, + "question": packet.question, + "messages": [ + self._make_message( + packet.qname, self.on_query(data, packet.src, domains) + ), + ], + }, + }, + self.domain, + ) - try: - data = Domain.decode(subdomain) - except binascii.Error: - # couldn't decode, drop the packet and do nothing - return + def _make_a(self, packet: Packet) -> Packet: + if self.config is None: + return - # keep destination - answer = Packet.build_reply( + # if we receive a DNS A query for a subdomain, answer it with an ip from + # the configuration file + qname = packet.qname[:-1] # remove final '.' + if qname in self.config.sections(): + return Packet.build_reply( { "src": self.host_ip, "dst": packet.src, @@ -56,13 +114,12 @@ def dns_responder(self, pkt: IP): "dns": { "id": packet.id, "question": packet.question, - # TODO ensure that we're under the 500 bytes limit "messages": [ DNSRR( rrname=packet.qname, - rdata=Content.encode(self.on_query(data, packet.src)), - type=DNSAnswer.Type.Text, - ttl=60, # a minute long + rdata=self.config[qname]["ip"], + type=DNSAnswer.Type.HostAddr, + ttl=int(self.config[qname]["ttl"]), ), ], }, @@ -70,34 +127,69 @@ def dns_responder(self, pkt: IP): self.domain, ) - self.logger.debug("Answering %s", answer.dns.summary()) + def _dns_responder(self, pkt: IP): + packet = Packet(pkt, self.domain) + answer = None + + self.logger.info( + "[DNS %s] Source %s:%i - on %s", + dnstypes[packet.question.qtype], + packet.src, + packet.sport, + packet.qname, + ) + + # reject every packet which isn't a DNS A/TXT query + if packet.is_valid_dnsquery( + "A", self.config["server"]["root"] if self.config else "" + ): + answer = self._make_a(packet) + elif packet.is_valid_dnsquery("TXT"): + answer = self._make_txt(packet) + + if answer is not None: send(answer.packet, verbose=0, iface=self.interface) def run(self): # bind a UDP socket server on port 53, otherwise we'll have # ICMP type 3 error as a client, because the port will be seen # as unreachable (nothing being binded on it) - t = threading.Thread(target=socket_server, args=(self.host_ip, )) + t = threading.Thread(target=socket_server, args=(self.host_ip,)) t.start() - self.logger.info(f"DNS responder started on {self.host_ip}:53") + self.logger.info(f"DNS sniffer started on {self.host_ip}:53") sniff( filter=f"udp port 53 and ip dst {self.host_ip}", - prn=self.dns_responder, + prn=self._dns_responder, iface=self.interface, ) t.join() -if __name__ == "__main__": - if len(sys.argv) < 3: +def main(**subservers): + if len(sys.argv) != 2 and len(sys.argv) != 3: + print(sys.argv) print("Usage: %s interface hostname" % sys.argv[0]) + print(" %s config_file.ini" % sys.argv[0]) sys.exit(-1) - ip = get_ip_from_hostname(sys.argv[2]) - if ip is None: - sys.exit(-1) + server = None - server = Server(sys.argv[1], sys.argv[2], ip) - server.run() + if len(sys.argv) == 3: + ip = get_ip_from_hostname(sys.argv[2]) + if ip is None: + print("Couldn't resolve IP from hostname, consider using a config file") + sys.exit(-1) + + server = Server(sys.argv[1], sys.argv[2], ip) + elif len(sys.argv) == 2: + server = Server.from_file(sys.argv[1]) + + if server is not None: + server.register(**subservers) + server.run() + + +if __name__ == "__main__": + main()