hxp CTF 2017: misc350 "inception" writeup

Warning: flag format for this challenge: HXP{…}

Now please inspect. Download:


250 Basepoints + 100 Bonuspoints * min(1, 30 Solves) = 350 Points


The idea of this challenge was to distribute a pcap that contained different layers of encrypted content to show that their typical mode of operation is insecure.


When opening the pcap in your favorite packet analyzer (for example Wireshark) you will find a lot of DNS traffic. With Iodine you are able to tunnel your data through a restricted network that otherwise requires a login within a captive portal. Usually these networks filter traffic like HTTP or SSH but allow passing of DNS traffic.

Open the Iodine dump

Iodine traffic is not encrypted, authentication is implemented by checking an MD5 hash in the beginning of the protocol. The Upstream data is typically encoded with a custom version of base128 and the downstream is typically raw when used with a DNS record of type NULL.

This was actually not completely new in a CTF. Indeed, there are writeups from previous CTFs, for example:

We used a script (derived from the first writeup) in combination with base128_iodine.py from the second writeup to the code the initial packet dump iodine.py.

This gives us another pcap dump containing a lot of HTTP and OpenVPN traffic. The HTTP traffic was not encrypted and contained a lot of inappropriate content.

Extract OpenVPN traffic

OpenVPN can be deployed in two different modes of operation. Typically one should use its TLS Mode in combination with a PKI and a set of certificates (both for client and server). Alternatively, if setting up an entire PKI is too complicated, as in the case where it only is used to decrypt web surfing traffic through an Iodine tunnel, also a static key setup may be used.

In static key mode, the user has to preconfigure a static key on both ends of the VPN Tunnel. Typically those keys contain 2048 bits of randomness.

If we look at the now extracted pcap dump we see that there is no TLS negotiation in the beginning of the OpenVPN connection, which suggests that in fact static key mode is used.

A little bit of googling (here and here) reveals the standard wire format and the used key material.

Wire format
The encrypted packet is formatted as follows:
    HMAC(explicit IV, encrypted envelope)
    Explicit IV
    Encrypted Envelope

The plaintext of the encrypted envelope is formatted as follows:
    64 bit sequence number
    payload data, i.e. IP packet or Ethernet frame

The HMAC and explicit IV are outside of the encrypted envelope.
Key material

The generated key typically has 2048 bits. When used, these bits are divided into 2 key pairs (encryption key, authentication key), one pair for the server and one pair for the client (see option key-direction). Therefore, each key finally consists of 512 bits.

OpenVPN in standard _statickey configuration uses Blowfish (64bit key) for encryption and HMAC_SHA1 (160bit key) for integrity protection. Also in standard configuration only the first key pair is used in both directions.

In order to decrypt the data, the HMAC key is not required, thus leaving a possibility to brute force the encryption on a decent machine.

Extracting the key material

In order to save us from brute forcing the 64bit encryption key, the user had downloaded his static key from the server prior to opening the VPN connection. If we search for the string static key in the pcap we find the following HTTP connection:

Decrypting the contents

With the extracted key and a python script we are able to decrypt the OpenVPN connection.

Extracting the flag

After extracting the OpenVPN traffic, we again see a lot of inappropriate content. But also if we search for the string HXP{ we find the following HTTP traffic, which contains the flag:

HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.5.3
Date: Fri, 17 Nov 2017 18:43:39 GMT
Content-type: text/html
Content-Length: 78
Last-Modified: Fri, 17 Nov 2017 18:06:13 GMT

FLAG: HXP{b0ttle_4g41n?!_i0D1n3_4nd_51T1c_0p3nVpN_n0t_sO_53cur3_u53_W1th_55h}


Iodine Script

::: python
import os, sys
from scapy.all import *
import zlib
import base128_iodine

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,

def uncompress(s):
    if '' == s:
        return False
    return zlib.decompress(s)

def main():
    inp, output = sys.argv[1], sys.argv[2]
    topdomain = ".a.ctf.link."
    upstream_encoding = 128

    p = rdpcap(inp)
    print('Done reading pcap')
    print('Got %i entries' %(len(p)))
    dn_pkt, up_pkt = '', ''
    datasent = False
    E = []
    for i in range(len(p)):
        if i % 1000 == 0:
        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:
                        decoded = base128_iodine.b128decode(up_pkt)
                        if decoded != '':
                            u = uncompress(decoded)
                            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)

if __name__ == '__main__':

OpenVPN Static Key

GET /hxp.key HTTP/1.1
User-Agent: curl/7.56.1
Accept: */*

HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.5.3
Date: Fri, 17 Nov 2017 18:40:03 GMT
Content-type: application/pgp-keys
Content-Length: 636
Last-Modified: Thu, 16 Nov 2017 14:40:11 GMT

# 2048 bit OpenVPN static key
-----BEGIN OpenVPN Static key V1-----
-----END OpenVPN Static key V1-----

OpenVPN Script

::: python
import os, sys
import binascii
from Crypto.Cipher import Blowfish
from scapy.all import *

from hashlib import sha1
import hmac

import pprint

def chunks(l, n):
    n = max(1, n)
    return (l[i:i+n] for i in range(0, len(l), n))

def import_key(filename):
    with open(filename) as f:
        keyfile = f.readlines()
    key = b''
    for line in keyfile:
        if line.startswith('#') or line.startswith('-'):
        key += binascii.unhexlify(line.strip())
    return key

def main():
    if len(sys.argv) < 2:
    inp = sys.argv[1]
    out = sys.argv[2]
    key = list(chunks(import_key('hxp.key'), 64))
    key = [(key[0], key[1]), (key[2], key[3])]
    bf_key = key[0][0][:16]
    hmac_key = key[0][1][:20]
    p = rdpcap(inp)

    E = []
    bs = Blowfish.block_size
    for i in range(len(p)):
        if p[i].haslayer(UDP) and \
            (p[i].getlayer(UDP).sport == 1194 or \
             p[i].getlayer(UDP).dport == 1194):
            mac = p[i].load[:20]
            rest = p[i].load[20:]
            hashed = hmac.new(hmac_key, rest, sha1)
            if mac != hashed.digest():
                print('\o/ MAC passt -- nicht')
            iv = rest[:bs]
            ciphertext = rest[bs:]
            cipher = Blowfish.new(bf_key, Blowfish.MODE_CBC, iv)
            msg = cipher.decrypt(ciphertext)
            last_byte = msg[-1]
            msg = msg[:- (last_byte if type(last_byte) is int \
                                    else ord(last_byte))]
            if len(msg[8:]) < 40:
            E += [IP(msg[8:])]

    wrpcap(out, E)

if '__main__' == __name__: