# CInsects CTF 2022 - commercialtimetracker

commercialtimetracker is a Python service for tracking work times, based on gevent. It contained a custom, hash-based authentication method that was flawed, allowing attackers to recover the authentication secret.

This service was part of the CInsects CTF 2022. We exploited this pretty early in the CTF - actually we were sitting on a working exploit for over an hour, until checkers and flag submission we up. Unfortunately the service was broken for many teams, so we didn’t get many flags out of this exploit.

# Service Overview

The service employed a custom, line-based protocol. The server starts a connection by sending CTT EHLO, clients respond with CTT EHLO, then the connection is accepting commands. The following commands are possible:

• REGISTER <identifier> <shared_secret> <nickname>: Create a new account
• LOGIN <identifier>: Login with an existing account
• LIST : Return a list of existing user identifiers
• TIME <date> <project> <hours>: Log time you have worked - “project” were the flags
• TIMES: List all times you have worked
• LOGOUT: Return to unauthenticated state
• BYE: Close connection

Data was stored in pickle’d files, named by the user’s identifier, but serialization was not exploitable (details at the end).

The interesting part was the login implementation, which was a challenge-response scheme with proof-of-work included: On registration, server and client share a large number, the shared secret. On login, the server generates two numbers. The client then searches for two other numbers that make hash(<secret>-<challenge>-<n>) start with 0000 (hex). The client responds with the two numbers and the full hashes for verification.

The exact implementation was:

# Vulnerability

The challenges are generated based on the secret $s$ and two random primes $r_1$ and $r_2$. Note that abs is patched to sympy.nextprime. Prime size was (at most) 32 bits, secret size was a bit larger, and there is no modulo arithmetic - python’s numbers are of arbitrary size. Then challenges $c_1$ and $c_2$ were generated:

Only $(c_1, c_2)$ was sent to the client. But from these challenges, we can recover the shared secret $s$. After receiving one challenge, we recompute $c_0 = c_1 - c_2$. We factorize $c_0$, which is simple because it’s less than 100bits. From these factors, one is $r_1$, the remaining ones are from $s$. We could now guess which factor was $r_1$, or request a second challenge: factors from $s$ also divide the second $c_0'$, only $r_1$ does not divide $c_0'$. After having recovered $s$ which is the product of the remaining factors, we can restart the authentication and create a valid response.

# Exploit

Our exploit literally follows the vulnerability description. We first get a list of all user identifiers with the LIST command. For each user, we request two challenges. We factorize the first one, and use the second one to test the factors. We used the first StackOverflow result for a factorization algorithm, which was not perfect, but good enough. Because we didn’t have checker traffic on our service when writing the exploit, we did not know the size of the gameserver’s secrets, so we prepared a second factorization function based on yafu. After having cracked a secret, we log in and retrieve all times, which contain the flags.

# Fixes

The fix was simple: the secret is not necessary for challenge generation, the algorithm works independent of the chosen challenge numbers. We can replace it by some constant. In our case we patched line 36 to challenge1 = 0x1337 * rand.

# An Unexploitable Vulnerability?

Data was stored in pickle’d files, named by the user’s identifier. The read and write logic was almost vulnerable: First, the code to unpickle was in theory vulnerable to a deserialization attack:

pickle.secure_dump = pickle.dump
pickle.UnpicklingMayBeInsecure = pickle.UnpicklingError

# ...

try:
except pickle.UnpicklingMayBeInsecure:
return None


The code was pretending to use secure versions of pickle, but the redefinitions at the top of the file redefined them to the insecure variants. To get a deserialization attack working, one would need attacker-provided input. We could store files on disk using another service (securestorage), but would then need a path traversal attack. The path of the input file was checked like this (key is attacker-provided):

path = (DATABASE / key).resolve().absolute()
assert '/' not in path.relative_to(DATABASE).as_posix()
assert '.' not in path.as_posix()


If one would start a path traversal attack against this code (key='../../xyz'), resolve() would re-build the path without ../, so the '.' not in check is trivially bypassed. However, the relative_to method raises an exception if the new path is not relative to the database path, making the vulnerability unexploitable.

We’re not sure if this was an intended, but broken vulnerability, or if this was just trolling.

# Repairing the service

Unfortunately, the service was broken in multiple ways on the vulnbox. While the organizers claimed to have fixes deployed at some point, the service was still not working. Luckily, it was installed on the one (out of three) vulnboxes where we had access to, so we patched it ourselves. Three things needed attention:

1. The service was not initialized. While a init.sh script was available, it was never called.
2. The service was lacking dependencies, sympy was not installed. Unfortunately, the vulnbox had no internet access, so we couldn’t use pip to simply install sympy. We solved this by downloading Debian’s packaged sympy and dependencies, move it to the vulnbox over ssh, and using dkpg to install it.
3. The device which contained the database folder was behaving strange: when new files should be created, it responded with no space, although the device was almost empty. Editing existing files was still possible, even if these files got larger (assuming one would install a text editor). We fixed this by moving the database folder to a different partition.

We also published these patches on IRC in the hope to get some exploitable targets up, but at this point, many teams were already inactive. Having only a few targets to exploit, our attack did not give as many flags as desired.

We published this patch script in IRC:

# run locally, replace "vulnbox" with your SSH config name
wget 'http://ftp.de.debian.org/debian/pool/main/m/mpmath/python3-mpmath_1.2.1-1_all.deb'
wget 'http://ftp.de.debian.org/debian/pool/main/s/sympy/python3-sympy_1.7.1-3_all.deb'
scp python3-mpmath_1.2.1-1_all.deb python3-sympy_1.7.1-3_all.deb vulnbox:~/
ssh vulnbox 'dpkg -i ~/*.deb && /media/static/commercialtimetracker/init.sh && systemctl restart run@commercialtimetracker'
# run on vulnbox as root
cp /media/static/commercialtimetracker/server.py ~/server.py
sed "s|DATABASE = Path('_data').absolute()|DATABASE = Path('/home/commercialtimetracker/_data').absolute()|" ~/server.py > /media/static/commercialtimetracker/server.py
mkdir /home/commercialtimetracker/_data
chown commercialtimetracker /home/commercialtimetracker/_data
systemctl restart run@commercialtimetracker


# Summary

Finally, this was a small but interesting service with a beginner-friendly vulnerability, for which we had an exploit rather fast.