hxpctf17: irrgarten

irrgarten was a challenge for 100 points in this year's hxpctf:

Be ready for some long running in the irrgarten.

One was provided with the following initial command:

dig -t txt -p53535 @35.198.105.104 950ae439-d534-4b0c-8722-9ddcb97a50f6.maze.ctf.link

Which provided the hint "try down.<domain>" in its TXT record. Resolving down.950ae439-d534-4b0c-8722-9ddcb97a50f6.maze.ctf.link yielded a CNAME to another dns name on which down.<domain> yielded yet another domain name.

Next, we tried right.<domain>, left.<domain> and up.<domain> and detected that they yielded results on some domain names as well. It seemed that the DNS records where encoding a maze.

Therefore, we wrote a small script which tried all directions on all nodes using a breadth-first search:

#!/usr/bin/env python3
import re
import sys
from subprocess import check_output
import pickle
from concurrent.futures import ThreadPoolExecutor


RECORD_PATTERN = re.compile(r'IN (?P<record_type>.*?)\s(?P<record_value>.+?)\n')



def load():
    global known
    global queue
    with open('cache6', 'rb') as fdes:
        known, queue = pickle.load(fdes)


def save():
    global known
    global queue
    with open('cache5', 'wb') as fdes:
        pickle.dump([known, queue], fdes)


def resolve(entry: str):
    out = check_output([
        'dig',
        '-t', 'txt' ,
        '-p53535',
        '@35.198.105.104',
        entry
    ]).decode()
    if 'hxp' in out:
        print(out)
    matches = RECORD_PATTERN.finditer(out)
    result = {}
    for match in matches:
        result[match['record_type']] = match['record_value']
    if not result:
        return None
    if set(result.keys()) == {'CNAME'}:
        return result['CNAME']
    print(entry, result)
    save()
    input('aa')
    return result.get('CNAME')


class Node:
    def __init__(self, id: str):
        self.id = id
        self.left = None
        self.right = None
        self.down = None
        self.up = None

    def __str__(self) -> str:
        return str(self.id)

    def __eq__(self, other) -> bool:
        return self.id == other.id

    def __hash__(self) -> int:
        return hash(self.id)



if __name__ == '__main__':
    start = Node('950ae439-d534-4b0c-8722-9ddcb97a50f6.maze.ctf.link')
    known = {start}
    queue = [start]
    load()

    nextsave = 100
    while queue:
        current = queue.pop(0)
        with ThreadPoolExecutor(max_workers=50) as exec:
            to_resolve = ['', 'up.', 'right.', 'down.', 'left.']
            if current.up is not None:
                to_resolve.remove('up.')
            if current.right is not None:
                to_resolve.remove('right.')
            if current.down is not None:
                to_resolve.remove('down.')
            if current.left is not None:
                to_resolve.remove('left.')
            futures = [
                (dir, exec.submit(resolve, '{}{}'.format(dir, current))) for dir in to_resolve
            ]
            for dir, future in futures:
                #new = resolve('{}{}'.format(dir, current))
                new = future.result()
                if new is None:
                    if dir == '':
                        continue
                    setattr(current, dir[:-1], False)
                    continue
                new = Node(new)
                if dir == 'up.':
                    new.down = current
                elif dir == 'right.':
                    new.left = current
                elif dir == 'down.':
                    new.up = current
                elif dir == 'left.':
                    new.right = current
                if new not in known:
                    print(new)
                    print(len(known))
                    known.add(new)
                    queue.append(new)
                    if dir != '':
                        setattr(current, dir[:-1], new)
        nextsave -= 1
        if not nextsave:
            save()
            nextsave = 100
            print('saving')

As some hints were provided using TXT records, we assumed that the flag might be stored in an TXT record as well. To notice such records, the script required user input for every domain name containing anything else than a single CNAME record. After having indexed approximately 35,000 nodes, the flag was indeed presented as a TXT record:

;; ANSWER SECTION: down.20b3aaad-20c0-4da5-9ff1-f5c98fe23efc.maze.ctf.link. 3600 IN CNAME 8f3bb677-b8f8-4c48-a4c4-d8451f361d03.maze.ctf.link. 8f3bb677-b8f8-4c48-a4c4-d8451f361d03.maze.ctf.link. 3600 IN TXT "Flag:" "hxp{w3-h0p3-y0u-3nj0y3d-dd051n6-y0ur-dn5-1rr364r73n}"

;; AUTHORITY SECTION: maze.ctf.link. 3600 IN NS ns1.ctf.link.

;; Query time: 20 msec ;; SERVER: 35.198.105.104#53535(35.198.105.104) ;; WHEN: Fri Nov 17 17:32:52 CET 2017 ;; MSG SIZE rcvd: 224

down.20b3aaad-20c0-4da5-9ff1-f5c98fe23efc.maze.ctf.link. {'CNAME': '8f3bb677-b8f8-4c48-a4c4-d8451f361d03.maze.ctf.link.', 'TXT': '"Flag:" "hxp{w3-h0p3-y0u-3nj0y3d-dd051n6-y0ur-dn5-1rr364r73n}"'}

While the script was running, we wrote another script drawing an image out of the graph data collected from the DNS:

#!/usr/bin/env python3
import sys
import pickle
from collections import defaultdict

from PIL import Image

from pwn import Node

#sys.setrecursionlimit(50000)
matrix = defaultdict(dict)
seen = set()

def load():
    global known
    global queue
    with open('cache5', 'rb') as fdes:
        known, queue = pickle.load(fdes)


def traverse(node, x, y):
    if node in seen:
        return
    seen.add(node)
    matrix[x][y] = True

    if node.id and '20b3aaad-20c0-4da5-9ff1-f5c98fe23efc.maze.ctf.link' in node.id:
        # True flag
        matrix[x][y] = 'flag'
    if node.id and '650a98dc-fb9e-4d73-a216-82f7e10e2216.maze.ctf.link' in node.id:
        # False flag
        matrix[x][y] = 'falseflag'

    if node.up:
        traverse(node.up, x, y + 2)
        matrix[x][y + 1] = True
    if node.right:
        traverse(node.right, x + 2, y)
        matrix[x + 1][y] = True
    if node.down:
        traverse(node.down, x, y - 2)
        matrix[x][y - 1] = True
    if node.left:
        traverse(node.left, x - 2, y)
        matrix[x - 1][y] = True

def to_picture():
    lower_bound = min(matrix.keys()), min(min(matrix[i].keys()) for i in matrix.keys())
    upper_bound = max(matrix.keys()), max(max(matrix[i].keys()) for i in matrix.keys())
    width = upper_bound[0] - lower_bound[0] + 2
    height = upper_bound[1] - lower_bound[1] + 2
    print(lower_bound)
    print(upper_bound)
    img = Image.new(
        'RGB',
        (width, height),
        'black') # create a new black image
    pixels = img.load() # create the pixel map
    for row_no, row in matrix.items():
        #row_no = width - row_no
        print(row_no - lower_bound[1])
        for col, col_data in row.items():
            print(row_no - lower_bound[0], col - lower_bound[1])
            pixels[row_no - lower_bound[0], col - lower_bound[1]] = (255, 255, 255)
            if col_data == 'flag':
                pixels[row_no - lower_bound[0], col - lower_bound[1]] = (0, 255, 0)
            elif col_data == 'falseflag':
                pixels[row_no - lower_bound[0], col - lower_bound[1]] = (0, 0, 255)
    pixels[- lower_bound[0], - 1 - lower_bound[1]] = (0, 255, 0)
    pixels[- lower_bound[0], - lower_bound[1]] = (255, 0, 0)
    img.save('foo.png')

if __name__ == '__main__':
    load()
    for node in known:
        if node.id == '950ae439-d534-4b0c-8722-9ddcb97a50f6.maze.ctf.link':
            start = node
            break
    traverse(start, 0, 0)
    print(dict(matrix))
    to_picture()

The actual flag, the false flag (which was sent shortly before the actual flag using a TXT record as well) and the start node are marked in different colors. The drawing is mirrored horizontally.

The image looks like this (the wrong path up from the start node is due to a mistake in the first script version storing None in the set of known nodes):

Irrgarten