DNS Traceroute with Scapy can
- detect forged MITM answers, as they happen on network routes into China
- find packetfilters that drop queries with EDNS flags
- find packetfilters that drop large responses
This is my first experience with Scapy and even though I haven’t used Python in several years, it certainly is easier to learn than Perl (IMHO, I often confuse myself with the different meanings of %@$#!) and extensible with only a few lines of code for a new payload type. Scapy’s main UI is the Python shell, which allows to inspect, twist and turn packets in more ways than many other utilities. I’m still exploring the vast possibilities of Scapy and Python, so this article will likely change over time.
On-Path DNS forgeries
My primary reason for a DNS traceroute was to get a better view on the chinese DNS filters, which spurred a lot of news reports lately. hping2 (with hand-crafted DNS payload) was not flexible enough. Scapy offers much more options to examine the returned packets.
Here’s a simple traceroute to a chinese DNS server, querying one of the blocked substrings. Because the DNS filters are on the path of the packets, they don’t have to guess the query’s ID or source port, thus randomization won’t help against this kind of poisoning:
>>> ans=sr(IP(dst="dns.baidu.com",ttl=(1,20))/UDP(dport=53)/DNS(qd=DNSQR(qname="facebook.com.baidu.com")),multi=1,timeout=2,inter=1)[0];ans.show()
The forged answers appear only a few hops behind the border routers. The queries are not dropped, though. They reach the destination server and eventually result in the correct response:
[...]
0008 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / ICMP 80.91.252.156 > 10.0.8.222 time-exceeded 0 / IPerror / UDPerror
0009 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / ICMP 213.248.94.126 > 10.0.8.222 time-exceeded 0 / IPerror / UDPerror
0010 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / ICMP 219.158.30.169 > 10.0.8.222 time-exceeded 0 / IPerror / UDPerror
0011 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / UDP / DNS Ans "93.46.8.89"
0012 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / ICMP 219.158.3.201 > 10.0.8.222 time-exceeded 0 / IPerror / UDPerror
0013 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / UDP / DNS Ans "8.7.198.45"
0014 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / ICMP 219.158.4.69 > 10.0.8.222 time-exceeded 0 / IPerror / UDPerror
0015 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / UDP / DNS Ans "46.82.174.68"
0016 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / ICMP 123.126.0.166 > 10.0.8.222 time-exceeded 0 / IPerror / UDPerror
0017 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / UDP / DNS Ans "159.106.121.75"
0018 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / ICMP 202.106.227.166 > 10.0.8.222 time-exceeded 0 / IPerror / UDPerror
0019 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / UDP / DNS Ans "37.61.54.158"
0020 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / ICMP 61.148.156.138 > 10.0.8.222 time-exceeded 0 / IPerror / UDPerror
0021 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / ICMP 202.106.43.66 > 10.0.8.222 time-exceeded 0 / IPerror / UDPerror
0022 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / UDP / DNS Ans "46.82.174.68"
0023 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / ICMP 61.135.165.253 > 10.0.8.222 time-exceeded 0 / IPerror / UDPerror
0024 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / UDP / DNS Ans "243.185.187.39"
0025 IP / UDP / DNS Qry "facebook.com.baidu.com" ==> IP / UDP / DNS Ans
>>> conf.AS_resolver.resolve(ans[9][1][IP].src)
[('213.248.94.126', 1299, 'TELIANET TeliaNet Global Network')]
>>> conf.AS_resolver.resolve(ans[10][1][IP].src)
[('219.158.30.169', 4837, 'CHINA169-BACKBONE CNCGROUP China169 Backbone')]
The last response contains the actual NXDOMAIN answer from dns.baidu.com:
>>> ans[25][1]
<IP version=4L ihl=5L tos=0x20 len=111 id=33106 flags= frag=0L ttl=53 proto=udp chksum=0x3b2 src=202.108.22.220 dst=10.0.8.222 options='' |<UDP sport=domain dport=domain len=91 chksum=0x1eac |<DNS id=0 qr=1L opcode=QUERY aa=1L tc=0L rd=0L ra=0L z=0L rcode=name-error qdcount=1 ancount=0 nscount=1 arcount=0 qd=<DNSQR qname='facebook.com.baidu.com.' qtype=A qclass=IN |> an=0 ns=<DNSRR rrname='baidu.com.' type=SOA rclass=IN ttl=7200 rdata="\x03dns\xc0\x19\x02sa\xc0\x19w\xce\xcb\xd6\x00\x00\x01,\x00\x00\x01,\x00'\x8d\x00\x00\x00\x1c " |> ar=0 |>>>
>>> ans[25][1][DNSRR].show()
###[ DNS Resource Record ]###
rrname= 'baidu.com.'
type= SOA
rclass= IN
ttl= 7200
Thinking point: If a resolver expects a DNSSEC-signed answer, should it ignore unsigned or invalid responses and keep listening for further packets until the “real” answer arrives or a timeout occurs? Current implementations don’t do this. They would accept the forged packet and return a name resolution failure due to invalid signatures.
The DNS interceptors appear on every route:
>>> ans=sr(IP(dst="a.cnnic.cn",ttl=(1,20))/UDP(dport=53)/DNS(qd=DNSQR(qname="xmarks.com")),multi=1,timeout=2,inter=1)[0];ans.show()
[...]
0009 IP / UDP / DNS Qry "xmarks.com" ==> IP / ICMP 203.192.137.174 > 10.42.23.3 time-exceeded 0 / IPerror / UDPerror
0010 IP / UDP / DNS Qry "xmarks.com" ==> IP / ICMP 159.226.254.253 > 10.42.23.3 time-exceeded 0 / IPerror / UDPerror
0011 IP / UDP / DNS Qry "xmarks.com" ==> IP / ICMP 159.226.254.29 > 10.42.23.3 time-exceeded 0 / IPerror / UDPerror
0012 IP / UDP / DNS Qry "xmarks.com" ==> IP / UDP / DNS Ans "37.61.54.158"
[...]
>>> conf.AS_resolver.resolve(ans[9][1][IP].src)
[('203.192.137.174', 10026, 'ANC Asia Netcom Corporation')]
>>> conf.AS_resolver.resolve(ans[10][1][IP].src)
[('159.226.254.253', 7497, 'CSTNET-AS-AP Computer Network Information Center')]
>>> ans=sr(IP(dst="123.123.123.123",ttl=(1,20))/UDP(dport=53)/DNS(qd=DNSQR(qname="twitter.com")),multi=1,timeout=2,inter=1)[0];ans.show()
[...]
0004 IP / UDP / DNS Qry "twitter.com" ==> IP / ICMP 89.221.35.21 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0005 IP / UDP / DNS Qry "twitter.com" ==> IP / ICMP 219.158.33.189 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0006 IP / UDP / DNS Qry "twitter.com" ==> IP / ICMP 219.158.30.241 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0007 IP / UDP / DNS Qry "twitter.com" ==> IP / ICMP 219.158.4.225 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0008 IP / UDP / DNS Qry "twitter.com" ==> IP / UDP / DNS Ans "159.106.121.75"
0009 IP / UDP / DNS Qry "twitter.com" ==> IP / UDP / DNS Ans "37.61.54.158"
[...]
>>> conf.AS_resolver.resolve(ans[4][1][IP].src)
[('89.221.35.21', 6762, 'SEABONE-NET Telecom Italia Sparkle')]
>>> conf.AS_resolver.resolve(ans[5][1][IP].src)
[('219.158.33.189', 4837, 'CHINA169-BACKBONE CNCGROUP China169 Backbone')]
EDNS Flags
Some packet filters drop DNS packets with EDNS flags. Traceroute can help to locate these devices.
Scapy does net yet support EDNS, although I found an old Trac entry without patch. I added a DNSOPTRR object and AD/CD flags, but parsing of large packets still produces warning messages and received OPT records are stored in DNSRR objects, not DNSOPTRR.
Usage is simple. Just add DNSOPTRR records to the query’s additional section:
>>> ans=sr(IP(dst="nsig4.attraktor.org",ttl=(1,10))/UDP(sport=RandShort(),dport=53)/DNS(qd=DNSQR(qname="attraktor.org",qtype="ALL"),ar=DNSOPTRR(edns_flags="DO",edns_bufsize=65535)),multi=1,timeout=2)[0];ans.show()
0000 IP / UDP / DNS Qry "attraktor.org" ==> IP / ICMP / IPerror / UDPerror / DNS Qry "attraktor.org."
0001 IP / UDP / DNS Qry "attraktor.org" ==> IP / ICMP 213.191.84.236 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0002 IP / UDP / DNS Qry "attraktor.org" ==> IP / ICMP 62.109.116.125 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0003 IP / UDP / DNS Qry "attraktor.org" ==> IP / ICMP / IPerror / UDPerror / DNS Qry "attraktor.org." / Padding
0004 IP / UDP / DNS Qry "attraktor.org" ==> IP / ICMP 213.191.66.138 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0005 IP / UDP / DNS Qry "attraktor.org" ==> IP / ICMP 80.81.192.164 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0006 IP / UDP / DNS Qry "attraktor.org" ==> IP / ICMP 213.239.240.200 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0007 IP / UDP / DNS Qry "attraktor.org" ==> IP / ICMP 213.239.244.176 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0008 IP / UDP / DNS Qry "attraktor.org" ==> IP / ICMP / IPerror / UDPerror / DNS Qry "attraktor.org."
0009 IP / UDP / DNS Qry "attraktor.org" ==> IP / UDP 88.198.161.124:domain > 10.42.20.232:50427 / Raw
0010 IP / UDP / DNS Qry "attraktor.org" ==> 88.198.161.124 > 10.42.20.232 udp frag:93 / Raw
0011 IP / UDP / DNS Qry "attraktor.org" ==> 88.198.161.124 > 10.42.20.232 udp frag:185 / Raw
0012 IP / UDP / DNS Qry "attraktor.org" ==> 88.198.161.124 > 10.42.20.232 udp frag:278 / Raw
0013 IP / UDP / DNS Qry "attraktor.org" ==> 88.198.161.124 > 10.42.20.232 udp frag:370 / Raw
Todo: Analyse and display a path with a restrictive packet filter. EDNS blockage is rare but it does exist.
The AD flag is accessible just like any other DNS header flag:
>>>
ans=sr(IP(dst="149.20.64.20")/UDP(sport=RandShort(),dport=53)/DNS(rd=1,qd=DNSQR(qname="isc.org"),ar=DNSOPTRR(edns_flags="DO",edns_bufsize=4096)))[0]
>>> ans[0][1][DNS].ad
1L
Large DNS packets
Note: This part is likely to change over the next days. I’m still exploring the vast possibilites of Scapy.
Answers larger than 512 bytes can pose a problem with packet filters that rely on a rather old definition of DNS. Scapy can help locate these devices on the network.
We can generate large queries by adding long records to the query’s answer section. BIND und PowerDNS answer such questions, Unbound and dnscache ignore it:
>>> ans=sr(IP(dst="85.10.240.248",ttl=(1,10))/UDP(dport=53,sport=RandShort())/DNS(rd=1,id=RandShort(),qd=DNSQR(qname="localhost"),an=DNSRR(rrname="example.com",type="TXT",rdata=("\xff"+"A"*255)*5),ar=DNSOPTRR()))[0];ans.show()
[...]
0000 IP / UDP / DNS Qry "localhost" ==> IP / ICMP 10.42.21.1 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror / Raw
0001 IP / UDP / DNS Qry "localhost" ==> IP / ICMP 213.191.84.236 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0002 IP / UDP / DNS Qry "localhost" ==> IP / ICMP 62.109.116.125 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0003 IP / UDP / DNS Qry "localhost" ==> IP / ICMP 213.191.66.73 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror / Raw
0004 IP / UDP / DNS Qry "localhost" ==> IP / ICMP 213.191.66.138 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0005 IP / UDP / DNS Qry "localhost" ==> IP / ICMP 80.81.192.164 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0006 IP / UDP / DNS Qry "localhost" ==> IP / ICMP 213.239.240.234 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0007 IP / UDP / DNS Qry "localhost" ==> IP / ICMP 213.239.244.68 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror
0008 IP / UDP / DNS Qry "localhost" ==> IP / ICMP 85.10.240.248 > 10.42.20.232 time-exceeded 0 / IPerror / UDPerror / Raw
0009 IP / UDP / DNS Qry "localhost" ==> IP / UDP / DNS Ans "127.0.0.1"
To debug large answer sizes, Scapy offers an extensible “AnsweringMachine” (see code below). I’ll expand it further, so that answer size and traceroute results can be controlled by querying different names, building a remote-controlled diagnostics loop.
>>> am=DNS_debug_am(verbose=1,promisc=0)
>>> am()
$ dig rlen=254.example.com txt @10.0.8.222
[...]
rlen=254.example.com. 0 IN TXT "AAAA[...]"
;; MSG SIZE rcvd: 336
$ dig rlen=65000.example.com txt @10.0.8.222
[...]
;; MSG SIZE rcvd: 65360
High-level functions
Often-used functions can be combinde into higher-level wrappers that provide reasonable defaults for most parameters. I’ll use this section to document the functions and objects I wrote for easier routine tasks.
#! /usr/bin/env python
# Set log level to benefit from Scapy warnings
import logging
logging.getLogger("scapy").setLevel(1)
from scapy.all import *
class DNS_debug_am(AnsweringMachine):
function_name="dns_debug_responder"
filter="udp dst port 53"
def parse_options(self, domain="example.com.", rlen=255, mult=1, bsize=65535):
self.domain=domain
self.rlen=rlen
self.mult=mult
self.bsize=bsize
def is_request(self, req):
return req.haslayer(DNS) and \
req.getlayer(DNS).qr == 0 and \
req.getlayer(DNS).qd.qname.endswith(self.domain)
def make_reply(self, req):
ip = req.getlayer(IP)
dns = req.getlayer(DNS)
resp = IP(dst=ip.src, src=ip.dst)/UDP(dport=ip.sport,sport=ip.dport)
if dns.qd.qtype != 16:
resp /= DNS(id=dns.id, qr=1, rcode="refused")
return resp
args = {"rlen": self.rlen, "mult": 1, "bsize": self.bsize}
for e in [ s.split("=") for s in dns.qd.qname[:dns.qd.qname.rfind("."+self.domain)].split(".") ]:
try:
args.update(dict([e]))
except ValueError:
pass
print args
try:
c = int(args["rlen"]) / 255
r = int(args["rlen"]) % 255
if dns.qd.qname == "stats." + self.domain:
dnsresp = DNS(id=dns.id, qr=1, qd=dns.qd,
an=DNSRR(rrname=dns.qd.qname, type="TXT", rdata="\x05Stats"))
else:
dnsresp = DNS(id=dns.id, qr=1, qd=dns.qd,
an=DNSRR(rrname=dns.qd.qname, type="TXT", rdata=("\xff" + "A"*255)*c + (chr(r) + "A"*r)),
ar=DNSOPTRR(edns_bufsize=int(args["bsize"])))
except ValueError:
return resp/DNS(id=dns.id, qr=1, rcode="server-failure")
else:
return resp/dnsresp
def show_dns_traceroute(trace):
print "%3s %15s %9s %s" % ("TTL", "src IP", "RTT", "pkt summary")
for s,r in trace:
print "%3d %15s %9.3f %s" % (s.ttl, r.src, (r.time-s.time)*100, r.summary())
def _dns_traceroute(target,minttl,maxttl,dport,sport,timeout,dnspkt):
if minttl < 1:
minttl = 1
if maxttl < minttl:
maxttl = minttl
if not dport:
dport=53
# conf.checkIPsrc=0
trace=SndRcvList([])
for i in range(minttl-1, maxttl):
qpkt=IP(dst=target,ttl=i+1,id=RandShort())/\
UDP(sport=RandShort(),dport=dport)/\
dnspkt
if sport:
qpkt[UDP].sport=sport
ans,unans=sr(qpkt, multi=1, timeout=timeout)
trace.extend(ans)
# flatten trace tuples into serialized packet list
pkts=PacketList()
lasttl=0
for s,r in trace:
if s.ttl != lasttl:
pkts.append(s)
lasttl = s.ttl
pkts.append(r)
return trace, pkts
def dns_traceroute(target,minttl,maxttl,qname,qtype="A",qclass="IN",rd=0,dport=53,sport=0,timeout=2):
trace,pkts = _dns_traceroute(target,minttl,maxttl,dport,sport,timeout,\
DNS(rd=rd,qd=DNSQR(qname=qname,qtype=qtype,qclass=qclass),id=RandShort()))
show_dns_traceroute(trace)
return trace,pkts
# trace.make_table( lambda(s,r): (s.dst, s.ttl, (r.src, r.summary()))
#trace,pkts=dns_traceroute("123.123.123.123", 1, 15, "twitter.com")
#trace,pkts=dns_traceroute("123.123.123.123", 1, 15, "twitter.com", "A", "IN", 0, 0, 0, 2)
#trace,pkts=dns_traceroute("85.10.240.250", 7, 11, "twitter.com", "A", "IN", 1, 0, 0)
#trace,pkts=dns_traceroute("85.10.240.250", 7, 8, "twitter.com", "A", "IN", 1, 0, 0)
#trace,pkts=dns_traceroute("85.10.240.250", 1, 3, "twitter.com", "A", "IN", 1, 0, 0, 1)
#wrpcap(\"/tmp/foo.pcap\",pkts)\n"
if __name__ == "__main__":
interact(mydict=globals())