Life Quiz was a web challenge in round 1 of 2024’s openECSC. To solve this challenge, it
was necessary to exploit a race conditions as well as a command injection. Combining the two to get the challenges flag.
This CTF challenge was white-box. E.g. the source was given. In addition to the source code, we also get a URL pointing
us to the live server.
Life Quiz challenge description.
Getting an overview
Upon reviewing the hosted web application, we gain an understanding of what Life Quiz is about. After registering an
account and logging in, we are presented with three options:
Answer quiz questions. When we answer correctly, we get a point. After each attempt, we get a new question.
Request a prize. However, when we do that we are informed that we must first play the game.
Reset points. This resets our points and our current question.
login.php – To perform actions on Life Quiz, we must first register and log in to an account.
quiz.php – On the quizzing page, we can answer questions with one of four options.
prize.php – No prize for us ): To get a prize, we have to answer questions!
reset.php – Resets the current question to the first one and our points to 0.
Looking through the code
Now that we roughly know what this website is about, let’s look through the code to find out what we really need to do
to get the flag! If you want to have a look for yourself, download the challenge source
here.
Directorysrc
db.php
get_prize.php
header.php
index.php
login.php
prize.php
quiz.php
reset.php
trophy.php
docker-compose.yml
Dockerfile
init.sql
trophy.jpg
I like to work backwards from the flag to a way to get at it. The only place the flag is directly referenced is in the
Dockerfile.
As we can see in the highlighted line, during the container build, the flag will be inserted as a text string into
an image /prizes/flag.jpg. While the path /prizes/flag.jpg is never used as is in the code, there is one file that
comes pretty close. Namely get_prize.php.
Looking through the convert documentation, if we could get this code path to execute, we could inject a graphic
primitive (more on that later) to load the flag image into our personal image and retrieve the flag that way. The hurdle
here is acquiring 15 points.
The only code path in the code base incrementing our points counter is located in quiz.php.
However, the correct answers is determined randomly for each request. We also only have 15 tries before we are locked
out of the quizzing system and must reset our progress.
Summing up, the first obstacle is to get a user with 15 points. After that, we can exploit a command injection on the
convert utility to get the contents of /prizes/flag.jpg and with that the flag.
Getting 15 points
Let’s revisit quiz.php! Our chances of guessing correctly 15 times in a row are 0.25 ^ 15 -> 0.000000001. So
guessing the questions until we get lucky 15 times in a row is not feasible. We’ll need to find an alternative approach.
One thing that strikes us is that none of the SQL statements use transactions. Furthermore, the statement updating a
users points is executed after that users question_id is incremented. We might be able to exploit this fact as a
race condition by answering each question multiple times at the same time.
When trying that however, we are faced with an issue: PHP sessions have a lock. At the beginning of quiz.php,
session_start() is called. This PHP standard library function checks the PHPSESSID cookie for the connecting users
current session and initializes corresponding session data if present. If another requests with that PHPSESSID is
currently being run, session_start() will wait until that session has completed before resuming execution. If only we
could bypass the session locking and exploit that race condition. Then we could get ourselves 15 points.
It turns out we can! While the session prevents us from running code of the same session at the same time, neither the
standard library nor the login/registration code prevents us from simply opening multiple sessions for a single user.
We can exploit this fact by creating multiple sessions for one user and answering the users current questions with each
of the sessions all at once. To get a few session cookies, we simply send a few login requests to the login endpoint
and save the PHPSESSID we get back.
With that, we assemble them into a list and use a framework like burp suites turbo intruder to trigger the race
condition.
With each run, we now get one to two points. More than enough to reach the 15 points mark within the 15 guesses.
Exploiting the command injection
Now that we can reliably forge users with 15 or more points, we can reliably execute the image generation code mentioned
above.
The command in this code path runs the utility convert (used for editing images of various formats) to add the name of
the winning user to their personal trophy. Out of the box the command does not give the user the challenges flag!
Since the username is escaped using escapeshellargs(), we can’t easily exfiltrate /prizes/flag.png via netcat.
However, we might be able to inject additional arguments into convert. Indeed, the documentation for convert -draw
states that one of the allowed image primitives - besides text, circle and more - is image.
As per the documentation, we could create a user named something like “image Over 500,500 1000,1000 /prizes/flag.png”,
get that users points to 15 and our trophy would have the flag image embedded into it. One final obstacle remains though.
The user column only allows up to 36 character long usernames. Making that injection as small as possible and taking the
quotes into account, our end result for the command injection payload is this: "image Over 0,0 0,0"/prizes/flag.jpg.
We just need to create a user with that name, attain 15 points and the flag should be ours.
I won't give out the real flag here of course :)
Summing up
All in all, while it did almost cost me my sanity a few times, I really liked this challenge. With just 33 solves out of
more than 1800 participants, it was definitely on the more challenging side. But none of that difficulty was due to
gotchas or guessing. It being a white-box challenge with source code provided was also a big plus for me since I
much prefer white-box over black-box challenges.
Big shout out to the author, Xato, whoever you are :)