Dante CTF Writeups - Forensics

Writeups of the Dante CTF 2023 - Forensics

 

FORENSICS

Dirty Checkerboard

🏆 Challenge Description

I bought a new chessboard but every time I use it I have this feeling… Like it’s dirty or something.


The attached image indeed had a square of “dirt” in the lower left corner of the square with coordinates B2:

the “dirty” spot

Since the image was grayscale, those “digital” pixels in an “analogue” picture could be easily decoded as 1-byte values with a couple of lines of code:

from PIL import Image

offset = (359, 2031)

img = Image.open("DirtyCheckerboard.bmp")
for w in range(0, 15):
    for h in range(0, 10):
        coords = (offset[0] + w, offset[1] + h)
        print(chr(img.getpixel(coords)), end="")

🏁 DANTE{ch3ck_0ut_abmagick}  

 

Do You Know GIF?

🏆 Challenge Description

Ah, Dante! He appears in poems, videogames… He wrote about a lot of people but few have something meaningful to say about him nowadays.


Here you have two options:

  1. Hope in a reverse image search, find the original GIF, and compare the hexdump of the two.
  2. Use stringsor the more sophisticatedexiftool`.

The easiest way for me to solve it is to use exiftool :

$ exiftool -a dante.gif | grep Comment

Comment: Hey look, a comment!
Comment: These comments sure do look useful
Comment: I wonder what else I could do with them?
Comment: 44414e54457b673166355f --> DANTE{g1f5_
Comment: 3472335f6d3464335f6279 --> 4r3_m4d3_by
Comment: 5f626c30636b357d --> _bl0ck5}
Comment: At the edges of the map lies the void

Decoding those three blocks from hex to ASCII would have given you the flag.
It’s also possible to complete this challenge using a simple parser:

import re
import binascii

comment_block_marker = b'0021fe'  # "00" is the end of a previous block and "21 FE" is the start of a comment block

with open("dante.gif", "rb") as f:
    # Read the GIF as literal hex (remember that 1 hex byte = 2 ASCII chars here)
    hexdata = binascii.hexlify(f.read())

    # Find all comment blocks
    for match in re.finditer(comment_block_marker, hexdata):
        # Find the (ASCII hex string) offset of the comment length byte
        length_offset = match.start() + len(comment_block_marker)
        # The actual comment starts at the next hex byte (two ASCII chars)
        comment_offset = length_offset + 2

        # Parse the comment length byte (the next two ASCII chars = 1 hex byte) and double its value since we are reading ASCII offsets, not hex bytes
        comment_length = int(hexdata[length_offset:comment_offset], 16) * 2

        # Extract the comment data itself
        comment = hexdata[comment_offset:comment_offset + comment_length]
        decoded_comment = bytearray.fromhex(comment.decode("ascii")).decode()

        # If the comment's contents look like hex, decode them again
        if re.match(r'^[a-z0-9]+$', decoded_comment):
            decoded_comment += f" --> {bytearray.fromhex(decoded_comment).decode()}"

        # Decode and print the comment
        print(f'offset {match.start()} length {comment_length}:\t"{decoded_comment}"')
offset 337940 length 40:	"Hey look, a comment!"
offset 2672040 length 68:	"These comments sure do look useful"
offset 9736542 length 80:	"I wonder what else I could do with them?"
offset 15786384 length 44:	"44414e54457b673166355f --> DANTE{g1f5_"
offset 17808476 length 44:	"3472335f6d3464335f6279 --> 4r3_m4d3_by"
offset 21718496 length 32:	"5f626c30636b357d --> _bl0ck5}"
offset 26133830 length 74:	"At the edges of the map lies the void"

🏁 DANTE{g1f5_4r3_m4d3_by_bl0ck5}

 

 

Imago Qualitatis

🏆 Challenge Description

A wondrous electromagnetic wave was captured by a metal-stick-handed devil. “But.. What? No, not this way. Maybe, if I turn around like this… Aha!”


If a player dared to download and decompress the ~800MB archive a file named gqrx_20230421_133330_433000000_1800000_fc.raw would have appeared.

The first word in the filename suggested that it had something to do with Gqrx SDR, “an open-source software-defined radio receiver (SDR) powered by the GNU Radio and the Qt graphical toolkit” created by Alexandru Csete (OZ9AEC ham radio callsign). The file was indeed a raw radio signal capture represented as IQ data, a way to store a signal’s characteristics way more accurate than just sampling its amplitude at predefined intervals.

Opening the file in Gqrx and playing it back (here’s a simple tutorial) actually revealed the flag.

gqrx waterfall showing a portion of the flag

🏁 DANTE{n3w_w4v35_0ld_5ch00l}

 

 

Who Can Haz Flag

🏆 Challenge Description

A little spirit spied on this mortal transmission. He noticed that the human was after something, but what was it?

Among the TLS-encrypted noise a little less than 30 ARP requests stood out. The peculiar thing about them was that they were all probes/requests for the same CIDR: 102.108.103.0/24. The last octet of the requested address was the only thing that changed between those packets, and decoding it as an ASCII character gave out the characters of the flag.

Here’s an example of an extraction script:

from ipaddress import ip_address, ip_network
from scapy.all import Ether, ARP, rdpcap


def filter_packet(pkt):
    return \
        ARP in pkt and \
        ip_address(pkt[ARP].pdst) in ip_network('102.108.103.0/24')


packets = rdpcap("WhoCanHazFlag.pcapng")
arps = [p for p in packets if filter_packet(p)]

flag = []
for pkt in arps:
    ip_dst = pkt[ARP].pdst
    ip_last_octet = int(ip_dst.split('.')[3])
    flag_char = chr(ip_last_octet)
    print(f"{pkt.summary()} ==> {ip_dst} --> {ip_last_octet} = '{flag_char}'")

    flag.append(flag_char)
print('\n' + ''.join(flag))

Ether / ARP who has 102.108.103.68 says 255.255.255.0 ==> 102.108.103.68 --> 68 = 'D'
Ether / ARP who has 102.108.103.65 says 255.255.255.0 ==> 102.108.103.65 --> 65 = 'A'
Ether / ARP who has 102.108.103.78 says 255.255.255.0 ==> 102.108.103.78 --> 78 = 'N'
Ether / ARP who has 102.108.103.84 says 255.255.255.0 ==> 102.108.103.84 --> 84 = 'T'
Ether / ARP who has 102.108.103.69 says 255.255.255.0 ==> 102.108.103.69 --> 69 = 'E'
Ether / ARP who has 102.108.103.123 says 255.255.255.0 ==> 102.108.103.123 --> 123 = '{'
[...]
Ether / ARP who has 102.108.103.125 says 255.255.255.0 ==> 102.108.103.125 --> 125 = '}'

🏁 DANTE{wh0_h4s_fl4g_ju5t_45k}

 

 

Routes Mark The Spot

🏆 Challenge Description

Aha, the little spirit says that the human became more ingenious! What a weird way to transmit something, though.


Like the previous forensics challenge, among the TLS-encrypted noise some widely spaced IPv6 packets stood out. Their payload seemed random or somehow encoded, but, in truth, they all matched the same format: [A-Za-z0-9]{64,128}:FLAG_CHAR:[A-Za-z0-9]{64,128}.

Thus by filtering the packets in that IPv6 “conversation” and extracting the characters between the colons, something that vaguely resembled a flag could be extracted:

n_nD71}n3{_mlmb4_cEysAg54434lN_hnT

The final step was indeed to reorder the packets basing on their flow label field, the only other difference that existed between them. Here’s an example of a an extraction script that does that:

import re
from scapy.all import Ether, IPv6, rdpcap


def filter_packet(pkt):
    return \
        IPv6 in pkt and \
        pkt[IPv6].src == "526c:54da:4326:f2fa:eb05:8f48:5bd8:e856" and \
        pkt[IPv6].dst == "7fa1:f44b:d702:3f7a:35db:de1d:1576:2799"


packets = rdpcap("RoutesMarkTheSpot.pcapng")
ipv6 = [p for p in packets if filter_packet(p)]
ipv6.sort(key=lambda pkt: pkt[IPv6].fl)  # Sort by Flow Label

flag = []
for pkt in ipv6:
    flow_label = pkt[IPv6].fl
    payload = pkt[IPv6].payload.load.decode('ascii')
    flag_char = re.search(':(.*):', payload).group(1)
    print(f"{pkt.summary()} ==> {flow_label} --> {payload} = '{flag_char}'")

    flag.append(flag_char)
print('\n' + ''.join(flag))
Ether / [...] > [...] / Raw ==> 0 --> niEmoDOq9oRAvpi5fY4UndN1ofA1I5GVi4eHjuxLCzEuIoxG2LgW4YOohBlFVPQHKfK6rq13Grcyx6x9ZYtrawcyFbvJ8:D:R0CgOtT1UkbJaR6OIJ5KW2bmHHMKcQm8hB2ZEW15Y0ZV7umS5IwGiMaImomOORDGqzRBggvyPN = 'D'
Ether / [...] > [...] / Raw ==> 1 --> 67ZvMEolTtKmTSOZldsxTGqI6oiXr2Y2zPsJhkhGgXSnEdEDZlcNZmBS0w3AgnSrM9vpYXPi0BlPsZyY:A:UYd7TVQ1Zh6yofJJXo35GrSq6qgfH5NG9E87v8M3eSnT4JruZTbHCbZ0qNaggvsFTs9k5vtUhgVq44u51dtvdCGJuwso9aIDeuYccGen6Opn8q1UrYk = 'A'
[...]

🏁 DANTE{l4b3l5_c4n_m34n_m4ny_7h1ngs}