Created
August 25, 2014 22:12
-
-
Save jiva/f72b5e283f29e7823215 to your computer and use it in GitHub Desktop.
DNS Client written for VERT interview - by jiva
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
# DNS client for VERT interview | |
# by jiva | |
# | |
# CHALLENGE NOTES | |
# - Build a DNS Client (Send a request / Recv a response) [dns_client.py] | |
# - MUST allow 'A' type queries. | |
# - MUST display the answer portion of the response | |
# - MUST use python's socket library | |
# - SHOULD be able to perform a zone transfer (AXFR) | |
# - BONUS: Handle the following queries: NS, PTR, A, AAAA, SRV, SPF, TXT. | |
# | |
# REFERENCES | |
# https://www.ietf.org/rfc/rfc1035.txt | |
# http://en.wikipedia.org/wiki/Domain_Name_System#DNS_message_format | |
# http://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml | |
# http://www.zytrax.com/books/dns/ch15/ | |
# | |
# TIME TO IMPLEMENT THINGS | |
# -Bare DNS client (just A records): 2.5 hours. Spent most of the time re-learning DNS, testing as I was coding, some debugging. | |
# -Adding support for other records: about 20-30 minutes. Most of the lifting was done when implementing the bare DNS client for A records. | |
# -AXFR took me about 2 hours to figure out. I struggled with figuring out why the DNS server wouldn't accept my DNS request over TCP. | |
# After much debugging, I saw that, preceding the raw DNS request sent over TCP, I had to send a 2-byte length in order for the server to send back any responses. | |
# I also noticed that the first two bytes of the DNS response was a length. | |
# Still unsure as to where this is stated in the specification. | |
# -I made the AXFR request from my machine on UGA's network - I had to be on the network in order for the DNS server to allow the zone transfer | |
# | |
# OTHER NOTES | |
# -Ref 3 contains sample outputs for all the various supported query types | |
# -Forgive some of the hacky code - if this was something besides a PoC, it would have been refactored. | |
import socket | |
import sys | |
import struct | |
DNS_SERVER = '8.8.8.8' | |
# DNS_SERVER = '128.192.1.9' | |
PORT = 53 | |
SUPPORTED_QTYPES = ['a', 'aaaa', 'spf', 'srv', 'ptr', 'ns', 'txt'] | |
rr_types = {1: 'A', 2: 'NS', 12: 'PTR', 28: 'AAAA', 33: 'SRV', 99: 'SPF', 16: 'TXT', 6: 'SOA', 252: 'AXFR', 15: 'MX', 17: 'RP'} | |
# inv_rr_types = {v:k for k,v in rr_types.items()} # python 2.7+ | |
inv_rr_types = dict((v,k) for k, v in rr_types.iteritems()) | |
def get_pointer_offset(_qname): | |
"""Returns the offset when qname is a pointer (first 2 bits are 1)""" | |
bits = bin(ord('\xc0'))[2:].zfill(8) + bin(ord('\x0c'))[2:].zfill(8) | |
offset = int(bits[2:], 2) | |
return offset | |
def raw2ip(_rawip): | |
"""Converts raw bytes into a readable IP address""" | |
return '.'.join([str(ord(octet)) for octet in _rawip]) | |
def label_maker(_domain): | |
"""Creates and returns a domain name represented as a sequence of labels""" | |
# Split domain on '.' delimiter | |
sp = _domain.split('.') | |
# Create length octet appended by the corresponding label (and a terminating null byte) | |
return ''.join(map(lambda x: struct.pack('!B',len(x))+x, sp)) + '\x00' | |
def unlabel_maker(_label): | |
"""Takes a domain name represented as a sequence of labels and returns a human readable domain | |
Very ghetto, sorry. | |
Won't take into account non-alpha-numeric characters. | |
Also I'm doing some weird slicing. | |
Should suffice for now. | |
""" | |
return ''.join([c if c.isalnum() else '.' for c in _label])[1:-1] | |
def create_dns_request(_domain, _qtype): | |
"""Creates and returns the raw DNS request""" | |
# Construct DNS message header (Ref 1) | |
header = '' | |
## Used for matching queries with replies (16 bits) | |
id = 'FJ' | |
## 0th bit is 0 if query, 1 if response. | |
## 7th bit is 1 if recursion desired. | |
## Rest of the bits don't really matter for this implementation. | |
qr_row = '0000000100000000' | |
qr_row = struct.pack('!H', int(qr_row, 2)) | |
## "an unsigned 16 bit integer specifying the number of entries in the question section." | |
qdcount = struct.pack('!H', 1) | |
## ancount, nscount, arcount - not applicable to the request but still needs to be accounted for | |
ancount = struct.pack('!H', 0) | |
nscount = struct.pack('!H', 0) | |
arcount = struct.pack('!H', 0) | |
header = id + qr_row + qdcount + ancount + nscount + arcount | |
# Construct the question section (Ref 2) | |
question = '' | |
## qname == domain name as seq of labels | |
qname = label_maker(_domain) | |
## qtype, lookup in inv_rr_types | |
qtype = struct.pack('!H', inv_rr_types[_qtype.upper()]) | |
## qclass is IN for Internet (1) | |
qclass = '\x00\x01' | |
question = qname + qtype + qclass | |
return header + question | |
def parse_dns_request(_dns_response, _istcp): | |
"""Parse and print DNS response | |
This should be printed outside of this function, but should be OK for the purposes for this implementation. | |
boolean _istcp was added after a majority of the implementation, it is kind of hacky. | |
Basically, I noticed that DNS over TCP has length fields that I need to account for. | |
""" | |
# Parse header fields | |
offset = 0 | |
length = 0 | |
if _istcp: | |
length = _dns_response[offset:offset+2] | |
offset += 2 | |
id = _dns_response[offset:offset+2] | |
offset += 2 | |
qr_row = _dns_response[offset:offset+2] | |
offset += 2 | |
qdcount = struct.unpack('!H', _dns_response[offset:offset+2])[0] | |
offset += 2 | |
ancount = struct.unpack('!H', _dns_response[offset:offset+2])[0] | |
offset += 2 | |
nscount = struct.unpack('!H', _dns_response[offset:offset+2])[0] | |
offset += 2 | |
arcount = struct.unpack('!H', _dns_response[offset:offset+2])[0] | |
offset += 2 | |
# Keep track of offset into the response thus far | |
# offset = 12 | |
# Iterate and parse questions | |
for i in xrange(min(qdcount,20)): | |
print '[*] Question', i+1, 'out of', qdcount | |
qname_start = offset | |
qname_end = _dns_response.find('\x00', offset) + 1 # Finds the lowest index of '\x00', which is the end of our current qname | |
print '\tQname: ', unlabel_maker(_dns_response[qname_start:qname_end]) | |
offset = qname_end | |
qtype = _dns_response[offset:offset+2] | |
offset += 2 | |
qclass = _dns_response[offset:offset+2] | |
offset += 2 | |
# Iterate and parse answers | |
for i in xrange(min(ancount,20)): | |
print '[*] Answer', i+1, 'out of', ancount | |
# Name can be a pointer to a label, or a label. | |
# First two bits will be 1 if it's a pointer | |
if bin(ord(_dns_response[offset]))[2:].zfill(8)[:2] == '11': | |
aname = _dns_response[offset:offset+2] | |
offset += 2 | |
aname_start = get_pointer_offset(aname) | |
aname_end = _dns_response.find('\x00', aname_start)+1 | |
print '\tAname: ', repr(aname), "( it's a pointer to", unlabel_maker(_dns_response[aname_start:aname_end]), ")" | |
atype = _dns_response[offset:offset+2] | |
offset += 2 | |
print '\tAtype: ', rr_types[struct.unpack('!H', atype)[0]] | |
aclass = _dns_response[offset:offset+2] | |
offset += 2 | |
# BUG: TTL displaying non-sensical and out-of-order values. | |
# Verified in wireshark as well. Unsure of the issue. | |
attl = _dns_response[offset:offset+4] | |
offset += 4 | |
print '\tAttl: ', struct.unpack('!I', attl)[0] | |
rdlendth = _dns_response[offset:offset+2] | |
rdlendth = struct.unpack('!H', rdlendth)[0] | |
offset += 2 | |
# Prettify if IPv4 | |
if rr_types[struct.unpack('!H', atype)[0]] == 'A': | |
print '\tRData: ', raw2ip(_dns_response[offset:offset+rdlendth]) | |
else: | |
print '\tRData (raw): ', repr(_dns_response[offset:offset+rdlendth]) | |
offset += rdlendth | |
else: | |
print '[*] NAME IS NOT A POINTER... NEED TO IMPLEMENT' | |
# Iterate nscount | |
for i in xrange(min(nscount,20)): | |
print '[*] Authoritive response', i+1, 'out of', nscount | |
# Name can be a pointer to a label, or a label. | |
# First two bits will be 1 if it's a pointer | |
if bin(ord(_dns_response[offset]))[2:].zfill(8)[:2] == '11': | |
aname = _dns_response[offset:offset+2] | |
offset += 2 | |
aname_start = get_pointer_offset(aname) | |
aname_end = _dns_response.find('\x00', aname_start)+1 | |
print '\tAname: ', repr(aname), "( it's a pointer to", unlabel_maker(_dns_response[aname_start:aname_end]), ")" | |
atype = _dns_response[offset:offset+2] | |
offset += 2 | |
print '\tAtype: ', rr_types[struct.unpack('!H', atype)[0]] | |
aclass = _dns_response[offset:offset+2] | |
offset += 2 | |
# BUG: TTL displaying non-sensical and out-of-order values. | |
# Verified in wireshark as well. Unsure of the issue. | |
attl = _dns_response[offset:offset+4] | |
offset += 4 | |
print '\tAttl: ', struct.unpack('!I', attl)[0] | |
rdlendth = _dns_response[offset:offset+2] | |
rdlendth = struct.unpack('!H', rdlendth)[0] | |
offset += 2 | |
# Prettify if IPv4 | |
if rr_types[struct.unpack('!H', atype)[0]] == 'A': | |
print '\tRData: ', raw2ip(_dns_response[offset:offset+rdlendth]) | |
else: | |
print '\tRData (raw): ', repr(_dns_response[offset:offset+rdlendth]) | |
offset += rdlendth | |
# TODO: iterate and parse arcount | |
for i in xrange(min(arcount,20)): | |
print '[*] Additional response', i+1, 'out of', arcount | |
print '\t(not yet implemented)' | |
def main(): | |
"""Main function""" | |
# Check for args | |
if len(sys.argv) != 3: | |
print 'Usage:' | |
print '\t./dns_client.py [domain] [type]' | |
print '\tEx: ./dns_client.py jiva.io A' | |
sys.exit(1) | |
domain = sys.argv[1] | |
qtype = sys.argv[2].lower() | |
# Make sure we support whatever query type the user is requesting | |
if qtype in SUPPORTED_QTYPES: | |
# Create DNS request | |
dns_request = create_dns_request(domain, qtype) | |
# Create UDP socket | |
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | |
# Send DNS request | |
sock.sendto(dns_request, (DNS_SERVER, PORT)) | |
# Recv DNS response | |
# sock.bind((DNS_SERVER, PORT)) | |
data, addr = sock.recvfrom(2048) | |
# Parse and print DNS response | |
parse_dns_request(data, False) | |
elif qtype == 'axfr': # Hack, I should have implemented this above, but would require some refactoring. This should suffice as an example. | |
# Create DNS request | |
dns_request = create_dns_request(domain, qtype) | |
# Create TCP socket | |
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
sock.connect((DNS_SERVER, PORT)) | |
# Send DNS request | |
# By debugging in Wireshark for a while, I noticed that, when sending an AXFR request over TCP, | |
# A 2 byte length field was required (still trying to find this in the spec). | |
dns_request_length = struct.pack('!H', len(dns_request)) | |
sock.sendall(dns_request_length + dns_request) | |
# Recv DNS response | |
data = sock.recv(8192) | |
# Close socket | |
sock.close() | |
# Parse and print DNS response | |
parse_dns_request(data, True) | |
else: | |
print qtype, 'not supported.' | |
# Boilerplate | |
if __name__ == '__main__': | |
main() | |
''' | |
Ref 1: DNS message header | |
The header contains the following fields: | |
1 1 1 1 1 1 | |
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
| ID | | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
|QR| Opcode |AA|TC|RD|RA| Z | RCODE | | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
| QDCOUNT | | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
| ANCOUNT | | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
| NSCOUNT | | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
| ARCOUNT | | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
Ref 2: Question section DNS_message_format | |
The question section is used to carry the "question" in most queries, | |
i.e., the parameters that define what is being asked. The section | |
contains QDCOUNT (usually 1) entries, each of the following format: | |
1 1 1 1 1 1 | |
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
| | | |
/ QNAME / | |
/ / | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
| QTYPE | | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
| QCLASS | | |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ | |
Ref 3: Sample outputs | |
##### | |
# A # | |
##### | |
jiva@cb0x:~/Desktop/vert$ python dns_client.py jiva.io A | |
[*] Question 1 out of 1 | |
Qname: jiva.io | |
[*] Answer 1 out of 1 | |
Aname: '\xc0\x0c' ( it's a pointer to jiva.io ) | |
Atype: A | |
Attl: 299 | |
RData: 204.232.175.78 | |
######## | |
# AAAA # | |
######## | |
jiva@cb0x:~/Desktop/vert$ python dns_client.py google.com AAAA | |
[*] Question 1 out of 1 | |
Qname: google.com | |
[*] Answer 1 out of 1 | |
Aname: '\xc0\x0c' ( it's a pointer to google.com ) | |
Atype: AAAA | |
Attl: 195 | |
RData (raw): '&\x07\xf8\xb0@\x02\x0c\x06\x00\x00\x00\x00\x00\x00\x00f' | |
###### | |
# NS # | |
###### | |
jiva@cb0x:~/Desktop/vert$ python dns_client.py google.com NS | |
[*] Question 1 out of 1 | |
Qname: google.com | |
[*] Answer 1 out of 4 | |
Aname: '\xc0\x0c' ( it's a pointer to google.com ) | |
Atype: NS | |
Attl: 21599 | |
RData (raw): '\x03ns4\xc0\x0c' | |
[*] Answer 2 out of 4 | |
Aname: '\xc0\x0c' ( it's a pointer to google.com ) | |
Atype: NS | |
Attl: 21599 | |
RData (raw): '\x03ns3\xc0\x0c' | |
[*] Answer 3 out of 4 | |
Aname: '\xc0\x0c' ( it's a pointer to google.com ) | |
Atype: NS | |
Attl: 21599 | |
RData (raw): '\x03ns2\xc0\x0c' | |
[*] Answer 4 out of 4 | |
Aname: '\xc0\x0c' ( it's a pointer to google.com ) | |
Atype: NS | |
Attl: 21599 | |
RData (raw): '\x03ns1\xc0\x0c' | |
####### | |
# PTR # | |
####### | |
jiva@cb0x:~/Desktop/vert$ python dns_client.py jiva.io PTR | |
[*] Question 1 out of 1 | |
Qname: jiva.io | |
[*] Authoritive response 1 out of 1 | |
Aname: '\xc0\x0c' ( it's a pointer to jiva.io ) | |
Atype: SOA | |
Attl: 1799 | |
RData (raw): "\x04dave\x02ns\ncloudflare\x03com\x00\x03dns\xc0-x&\\\xb1\x00\x00'\x10\x00\x00\t`\x00\t:\x80\x00\x00\x0e\x10"3 | |
####### | |
# TXT # | |
####### | |
jiva@cb0x:~/Desktop/vert$ python dns_client.py jiva.io TXT | |
[*] Question 1 out of 1 | |
Qname: jiva.io | |
[*] Answer 1 out of 2 | |
Aname: '\xc0\x0c' ( it's a pointer to jiva.io ) | |
Atype: TXT | |
Attl: 299 | |
RData (raw): 'Dgoogle-site-verification=EjXoF3FENLMLEdh3943f_KuAtYTTADxPqL5C42u0NdU' | |
[*] Answer 2 out of 2 | |
Aname: '\xc0\x0c' ( it's a pointer to jiva.io ) | |
Atype: TXT | |
Attl: 3599 | |
RData (raw): '#v=spf1 include:_spf.google.com ~all' | |
####### | |
# SPF # | |
####### | |
jiva@cb0x:~/Desktop/vert$ python dns_client.py tripwire.com SPF | |
[*] Question 1 out of 1 | |
Qname: tripwire.com | |
[*] Answer 1 out of 1 | |
Aname: '\xc0\x0c' ( it's a pointer to tripwire.com ) | |
Atype: SPF | |
Attl: 21599 | |
RData (raw): '\xc5v=spf1 include:spf.messaging.microsoft.com include:mktomail.com include:mailgun.org ip4:76.247.119.150 ip4:76.247.119.164 ip4:174.47.84.215 ip4:69.80.198.8 ip4:64.112.227.240 ip4:69.80.198.126 -all' | |
####### | |
# SRV # | |
####### | |
jiva@cb0x:~/Desktop/vert$ python dns_client.py _sip._tls.franklin.uga.edu SRV | |
[*] Question 1 out of 1 | |
Qname: .sip..tls.franklin.uga.edu | |
[*] Answer 1 out of 1 | |
Aname: '\xc0\x0c' ( it's a pointer to .sip..tls.franklin.uga.edu ) | |
Atype: SRV | |
Attl: 86400 | |
RData (raw): '\x00d\x00\x01\x01\xbb\x06sipdir\x06online\x04lync\x03com\x00' | |
[*] Authoritive response 1 out of 3 | |
Aname: '\xc0\x16' ( it's a pointer to .sip..tls.franklin.uga.edu ) | |
Atype: NS | |
Attl: 86400 | |
RData (raw): '\x04dns3\xc0\x1f' | |
[*] Authoritive response 2 out of 3 | |
Aname: '\xc0\x16' ( it's a pointer to .sip..tls.franklin.uga.edu ) | |
Atype: NS | |
Attl: 86400 | |
RData (raw): '\x04dns2\xc0\x1f' | |
[*] Authoritive response 3 out of 3 | |
Aname: '\xc0\x16' ( it's a pointer to .sip..tls.franklin.uga.edu ) | |
Atype: NS | |
Attl: 86400 | |
RData (raw): '\x04dns4\xc0\x1f' | |
[*] Additional response 1 out of 3 | |
(not yet implemented) | |
[*] Additional response 2 out of 3 | |
(not yet implemented) | |
[*] Additional response 3 out of 3 | |
(not yet implemented) | |
######## | |
# AXFR # | |
######## | |
jiva@blackb0x:~/vert$ python dns_client.py cs.uga.edu axfr | |
[*] Question 1 out of 1 | |
Qname: cs.uga.edu | |
[*] Answer 1 out of 271 | |
Aname: '\xc0\x0c' ( it's a pointer to ) | |
Atype: SOA | |
Attl: 86400 | |
RData (raw): '\x04dns1\xc0\x0f\nhostmaster\x04toor\x02cc\xc0\x0f\x18\x04W_\x00\x00p\x80\x00\x00\x1c \x00\t:\x80\x00\x01Q\x80' | |
[*] Answer 2 out of 271 | |
Aname: '\xc0\x0c' ( it's a pointer to ) | |
Atype: NS | |
Attl: 86400 | |
RData (raw): '\x03ns1\x03nis\xc0\x0c' | |
[*] Answer 3 out of 271 | |
Aname: '\xc0\x0c' ( it's a pointer to ) | |
Atype: NS | |
Attl: 86400 | |
RData (raw): '\x03ns2\xc0h' | |
[*] Answer 4 out of 271 | |
Aname: '\xc0\x0c' ( it's a pointer to ) | |
Atype: NS | |
Attl: 86400 | |
RData (raw): '\x04dns2\xc0\x0f' | |
[*] Answer 5 out of 271 | |
Aname: '\xc0\x0c' ( it's a pointer to ) | |
Atype: NS | |
Attl: 86400 | |
RData (raw): '\x04dns3\xc0\x0f' | |
[*] Answer 6 out of 271 | |
Aname: '\xc0\x0c' ( it's a pointer to ) | |
Atype: NS | |
Attl: 86400 | |
RData (raw): '\x04dns4\xc0\x0f' | |
[*] Answer 7 out of 271 | |
Aname: '\xc0\x0c' ( it's a pointer to ) | |
Atype: A | |
Attl: 86400 | |
RData: 128.192.16.25 | |
[*] Answer 8 out of 271 | |
Aname: '\xc0\x0c' ( it's a pointer to ) | |
Atype: MX | |
Attl: 86400 | |
RData (raw): '\x00\x00\x04nike\xc0\x0c' | |
[*] Answer 9 out of 271 | |
Aname: '\xc0\x0c' ( it's a pointer to ) | |
Atype: RP | |
Attl: 86400 | |
RData (raw): '\x06ken@cs\x03uga\x03edu\x00\x07kpowell\x02cs\x03uga\x03edu\x00' | |
''' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment