Sunday, October 31, 2010 CTF - Challenge 9 "bottle" writeup, extracting data from an iodine DNS tunnel

Challenge #9 entitled "bottle" was original and worth its 500 points. We were given the following network capture and instructed to find a message.

Opening the capture in Wireshark reveals a lot of DNS traffic (and 4 ARP requests): it definitely looks like a DNS tunnel.

Overview of the DNS tunnel data

Basically DNS works with client requests (DNS QR) and server responses (DNS RR), so the real data are in QR.qname and RR.rdata respectively.

Using our favourite network packet manipulation tool Scapy, we can quickly have an overview of the DNS tunnel:
p = rdpcap('bottle.cap')
for i in range(8):
  if not p[i].haslayer(DNS):
  if DNSQR in p[i]:
    if DNSRR in p[i] and len(p[i][DNSRR].rdata)>0: # downstream/server
      print "S[%i]: %r" % (i,p[i][DNSRR].rdata)
    else: # upstream/client
      print "C[%i]: %r" % (i,p[i][DNSQR].qname)
C[0]: 'vaaaakardli.pirate.sea.'
S[1]: 'VACKD\x03\xc5\xe9\x01'
C[2]: 'laegpumiplhhpz12ynd1efljwlkjcgwy.pirate.sea.'
S[3]: ''
C[4]: 'yrbi02.pirate.sea.'
S[5]: '\x00\x00\x00\x00\xff\xff\xff\xffUUUU\xaa\xaa\xaa\xaa\x81c\xc8
\xd2\xc7|\xb2\x17_O\xce\xc9I-R!a\xa9q %\xb3\x06s\xe6\xd8D0yPW\xbf'
C[6]: 'zi03aA-Aaahhh-Drink-mal-ein-J\xe4germeister-.pirate.sea.'
S[7]: 'zi03aA-Aaahhh-Drink-mal-ein-J\xe4germeister-.'
View the entire overview here.

Identify DNS tunnel protocol version

Several DNS tunneling softwares exists, the most well-known being iodine we will start with it. Extract the source of latest iodine version 0.6.0-rc1 then read the kindly provided doc/proto_* files to understand how iodine DNS tunneling works. In proto_00000502.txt we read that the first packet to be transmitted sends protocol version:
Client sends:
 - First byte v or V
 - Rest encoded with base32:
 - 4 bytes big endian protocol version
 - CMC
Server replies:
 - 4 chars:
   - VACK (version ok), followed by login challenge
   - VNAK (version differs), followed by server protocol version
   - VFUL (server has no free slots), followed by max users
 - 4 byte value: means login challenge/server protocol version/max users
 - 1 byte userid of the new user, or any byte if not VACK
Looks like it is indeed iodine because we have something like that:
C[0]: 'vaaaakardli.pirate.sea.'
S[1]: 'VACKD\x03\xc5\xe9\x01'
We see that the client sends its version, then the server replies OK and gives the login challenge 'D\x03\xc5\xe9'.

In order to know which protocol version is used, we need to decode "aaaakardli" in base32. However big problem, iodine is using its own base32/64/128 charset and not the one defined in the RFC! In order not to reimplement its base32/64/128 versions and to be quicker, I made a little C program (encoder.c) directly using iodine's base32/64/128 routines that I can then call from python using subprocess module:
from subprocess import Popen,PIPE
def encoder(base,encode="",decode=""): # base=[32,64,128]
  p = Popen(["./encoder", str(base), "e" if len(encode)>0 else "d"], stdin=PIPE, stdout=PIPE)
  p.stdin.write(encode if len(encode)>0 else decode)
  return p.communicate()[0]
So it gives:
>>> "0x%08x" % unpack(">I",encoder(32,decode="aaaakardli")[:4])
Good, it is using protocol version 502, the latest.

Login sequence, break the password!

Next, we should have the login sequence:
Client sends:
 - First byte l or L
 - Rest encoded with base32:
 - 1 byte userid
 - 16 bytes MD5 hash of: (first 32 bytes of password) xor (8 repetitions of login challenge)
 - CMC
Server replies:
 - LNAK means not accepted
 - x.x.x.x-y.y.y.y-mtu-netmask means accepted (server ip, client ip, mtu, netmask bits)
Indeed we have:
C[2]: 'laegpumiplhhpz12ynd1efljwlkjcgwy.pirate.sea.'
S[3]: ''
Client sends the MD5 hash, which gets accepted and the server replies the network info. We can extract the MD5 hash this way:
>>> encoder(32,decode="aegpumiplhhpz12ynd1efljwlkjcgwya")[1:17].encode("hex")

Would be fun to break the password, and maybe it is the flag? Sadly we cannot use our rainbow tables because it is using a challenge (and it is a very good practice), but we can still try to attack it via a dictionary:
from hashlib import md5
from struct import pack,unpack

def xor(a,b):
  return "".join(chr(ord(a[i])^ord(b[i%len(b)])) for i in range(len(a)))

def crack_password(hash, challenge, dic):
  for line in open(dic):
    if hash == md5(xor(line.strip().ljust(32,'\x00'),challenge)).digest().encode('hex'):
      print "\o/ Password: %r" % line.strip()
We try with all the english words:
>>> crack_password("0cfa310f59cefcef9868f642ad365a92", "D\x03\xc5\xe9", "english.txt")
\o/ Password: 'swordfish'
Wow, we are lucky! But dammit, it is not the flag :) it was worth trying anyway.

Analyze DNS tunnel data headers

We go on reading the overview of DNS tunnel data along with the protocol specification:
U[16]: 'sbhi1c.pirate.sea.'
D[17]: 'Base128'
This indicates that the upstream data will be base128 encoded. We will use the C program (encoder.c) and python wrapper previously mentioned to decode it. Obviously, before the base128 decode we have to remove the top domain as well as the inner dots.

About the data, the following is very interesting:
Upstream data header:
   3210 432 10 43 210 4321 0 43210

Downstream data header:
   7 654 3210 765 4321 0

UUUU = Userid
L = Last fragment in packet flag
SS = Upstream packet sequence number
FFFF = Upstream fragment number
DDD = Downstream packet sequence number
GGGG = Downstream fragment number
C = Compression enabled for downstream packet
UDCMC = Upstream Data CMC, 36 steps a-z0-9, case-insensitive

Upstream data packet starts with 1 byte ASCII hex coded user byte; then 3 bytes 
Base32 encoded header; then 1 char data-CMC; then comes the payload data,
encoded with the chosen upstream codec.

Downstream data starts with 2 byte header. Then payload data, which may be

In NULL responses, downstream data is always raw.
To implement header decoding, it is easier to look directly at the code (iodined.c for the server handling client packets, and client.c for the client handling server packets). At the server side, we notice that again it is using a custom base32 decoder (base32_5to8). We are eventually able to decode all the header fields with the following code:
def b32_8to5(a):
  return "abcdefghijklmnopqrstuvwxyz012345".find(a.lower())

def up_header(p):
  return {
    "userid": int(p[0],16),
    "up_seq": (b32_8to5(p[1]) >> 2) & 7,
    "up_frag": ((b32_8to5(p[1]) & 3) << 2) | ((b32_8to5(p[2]) >> 3) & 3),
    "dn_seq": (b32_8to5(p[2]) & 7),
    "dn_frag": b32_8to5(p[3]) >> 1,
    "lastfrag": b32_8to5(p[3]) & 1

def dn_header(p):
  return {
    "compress": ord(p[0]) >> 7,
    "up_seq": (ord(p[0]) >> 4) & 7,
    "up_frag": ord(p[0]) & 15,
    "dn_seq": (ord(p[1]) >> 1) & 15,
    "dn_frag": (ord(p[1]) >> 5) & 7,
    "lastfrag": ord(p[1]) & 1,
We try it with this small piece of code:
p = rdpcap('bottle.cap')
datasent = False
for i in range(20,100):
  if not p[i].haslayer(DNS):
  if DNSQR in p[i]:
    if DNSRR in p[i] and len(p[i][DNSRR].rdata)>0: # downstream/server
      d = p[i][DNSRR].rdata
      print "D[%i]: %r" % (i,d[:30]+(" [...]" if len(d)>30 else ""))
      if datasent:
        print "       %r" % dn_header(d)
    else: # upstream/client
      d = p[i][DNSQR].qname
      print "U[%i]: %r" % (i,d[:30]+(" [...]" if len(d)>30 else ""))
      if d[0].lower() in "0123456789abcdef":
        print "       %r" % up_header(d)
        datasent = True
        datasent = False
And we can see it works fine for the first data packets:
U[46]: '1eaba82\xca2hb\xbe\xeeY\xd6wgi\xcf\xe2\xde4yp1\xccC\xc8I\xe1 [...]'
       {'userid': 1, 'up_seq': 1, 'lastfrag': 1, 'dn_seq': 0, 'up_frag': 0, 'dn_frag': 0}
D[47]: '\x90!x\xdac`\xe0`pe`\xb0a`p`p`{5\x81KD\x8e\x11\x88\x99\xd9v\xdc [...]'
       {'up_seq': 1, 'lastfrag': 1, 'dn_seq': 0, 'up_frag': 0, 'dn_frag': 1, 'compress': 1}


We are almost ready to extract the DNS tunnel data, the last thing to do is to decompress the data. For upstream packets it is the data after the base128 decode, for downstream packets it is the raw data (because here it is using type NULL DNS queries). Just like for the encoder, I made a small C program (uncompress.c) using the same function uncompress() from the zlib, and a python wrapper:
def uncompress(s):
  p = Popen(["./uncompress"], stdin=PIPE, stdout=PIPE)
  if p.wait() == 0:
    return p.communicate()[0]
    return False

We try it on the first upstream packet p=U[46] (not fragmented as we have seen previously):
>>> # skip header, remove top domain and undotify
>>> d = p[5:-len(".pirate.sea.")].replace(".","")
>>> u = uncompress(encoder(128,decode=d))
>>> u
Oh oh, we recognize the E (0x45) of what could be an IP packet starting at offset 4:
>>> IP(u[4:])
<IP  version=4L ihl=5L tos=0x0 len=60 id=274 flags=DF frag=0L ttl=64 proto=tcp
chksum=0xe97e src= dst= options=[] |<TCP  sport=56220
dport=1720 seq=1551101227 ack=0 dataofs=10L reserved=0L flags=S window=4360
chksum=0xd00d urgptr=0 options=[('MSS', 1090), ('SAckOK', ''),
('Timestamp', (388153, 0)), ('NOP', None), ('WScale', 5)] |>>
Looks like a valid packet! Good, we are able to decode upstream packets.

Same thing for the downstream packet p=D[47] (not fragmented as we have seen previously):
>>> d = p[2:] # skip the two byte header
>>> u = uncompress(d)
>>> u

>>> IP(u[4:])
<IP  version=4L ihl=5L tos=0x0 len=60 id=0 flags=DF frag=0L ttl=64 proto=tcp
chksum=0xea90 src= dst= options=[] |<TCP  sport=1720
dport=56220 seq=4253190920L ack=1551101228 dataofs=10L reserved=0L flags=SA
window=4312 chksum=0x8662 urgptr=0 options=[('MSS', 1090), ('SAckOK', ''),
('Timestamp', (1098029, 388153)), ('NOP', None), ('WScale', 6)] |>>
Looks like a valid packet! Good, we are also able to decode downstream packets.

Extract DNS tunnel packets

Seeing that packets were captured in a LAN, we can reasonably assume packets are in the correct order, thus the only thing that remains is to handle fragmentation and reassembly.

With all the previously defined functions, it gives:
input, output = "bottle.cap", "extracted.cap"
topdomain = ".pirate.sea."
upstream_encoding = 128

p = rdpcap(input)
dn_pkt, up_pkt = '', ''
datasent = False
E = []
for i in range(len(p)):
  if not p[i].haslayer(DNS):
  if DNSQR in p[i]:
    if DNSRR in p[i] and len(p[i][DNSRR].rdata)>0: # downstream/server
      d = p[i][DNSRR].rdata
      if datasent: # real data and no longer codec/fragment checks
        dn_pkt += d[2:]
        if dn_header(d)['lastfrag'] and len(dn_pkt)>0:
          u = uncompress(dn_pkt)
          if not u:
            raise Exception("Error dn_pkt %i: %r" % (i,dn_pkt))
          E += [IP(u[4:])]
          dn_pkt = ''
    else: # upstream/client
      d = p[i][DNSQR].qname
      if d[0].lower() in "0123456789abcdef":
        datasent = True
        up_pkt += d[5:-len(topdomain)].replace(".","")
        if up_header(d)['lastfrag'] and len(up_pkt)>0:
          u = uncompress(encoder(upstream_encoding,decode=up_pkt))
          if not u:
            raise Exception("Error up_pkt %i: %r" % (i,up_pkt))
          E += [IP(u[4:])]
          up_pkt = ''

wrpcap(output, E)
print "Successfully extracted %i packets into %s" % (len(E), output)
The full script is available here.
$ python
Successfully extracted 200 packets
Woohoo! The extracted network capture is available here.


We see nothing interesting in the VoIP/H.323 signalisation: looks like a "root" user is having a conversation with an Answering Machine using product OpenH323 Project OpenAM version 1.1.18 (thanks Wireshark for dissecting all these fields!).

Then we see RTP traffic containing GSM audio. One can easily extract it by using his favourite network packet manipulation tool - guess, Scapy! - by just concatenating all the payloads (we assume here too that there is no disorder):
A = []
for p in rdpcap("extracted.cap"):
  if UDP in p and p[UDP].sport==5000:
    # we see in Wireshark that GSM audio starts at offset 12 of UDP payload
    A += [str(p[UDP].payload)[12:]]

The full script is available here, as well as the resulting audio.gsm file.

Then how do we listen to a raw GSM audio file? For those who worked on Defcon 18 quals forensics 300 (see writeup by team routards) or followed my twitter (@stalkr_), we already know SoX - Sound eXchange, the Swiss Army knife of sound processing programs. Note for debian users: install package sox but also libsox-fmt-all to have GSM format as well.
Then just convert it to any format you can read, for instance audio.wav:
$ sox -t gsm audio.gsm audio.wav

Final challenge: understand the answering machine

In the audio file, you can hear distinctly "Password" then the password, something like "freebewzo chimp". The most difficult challenge for us was to understand what was being said! We went mad trying stuff on the scoreboard and finally sent our audio file to a fluxfingers admin who told us what we were not able to understand correctly: freebooter chimp! :)

Thank you fluxfingers for this awesome CTF with great challenges.
Thanks also to the person who worked with me on this challenge, it was fun ;)
And also congrats to iodine guys because it is a great software!

1 comment: