Skip to content

Instantly share code, notes, and snippets.

@jiva
Created August 25, 2014 22:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jiva/f72b5e283f29e7823215 to your computer and use it in GitHub Desktop.
Save jiva/f72b5e283f29e7823215 to your computer and use it in GitHub Desktop.
DNS Client written for VERT interview - by jiva
#!/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