Post

Override

Level 7 web exploitation challenge on Dreamhack.io

Override

The Challenge

https://dreamhack.io/wargame/challenges/1589

Hopefully this isn’t an SQLi challenge…

Luckily, this isn’t an SQLi challenge. We can create posts via the /write endpoint, which gives us trivial XSS:

Analysis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@blueprint.route('/write', methods=['GET', 'POST'])
def write():
    if isguest():
        return redirect('/login?redirect=' + quote(request.url))

    if request.method == 'POST':
        title = request.form.get('title', '').strip()
        content = request.form.get('content', '').strip()
        secret = request.form.get('secret') == 'on'
        if title and content:
            db = connect_db()
            cur = db.cursor()
            cur.execute('''
                INSERT INTO articles (title, content, secret, user_id)
                VALUES (?, ?, ?, ?)
            ''', (title, clean_html(content), secret, session['user_id']))
            try:
                return redirect(url_for('app.article', article_id=cur.lastrowid))
            finally:
                db.commit()
        return abort(400)
    return render_template('write.html')
1
2
3
4
5
6
7
<div class="mt-3">
  <h1 class="display-7"></h1>
  <article>
    
  </article>
</div>
<hr>

The clean_html function function that comes from lxml_html_clean==0.3.1 is vulnerable, allowing us to create dangerous HTML through <style> comments. This isn’t necessary for the challenge, though.

There’s a really strong CSP on the website that prevents us from running Javascript or injecting styles.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@blueprint.after_request
def add_headers(resp):
    resp.headers['Cache-Control'] = 'no-store'
    resp.headers['Content-Security-Policy'] = \
        "default-src 'none'; " \
        "connect-src 'self'; " \
        "base-uri 'none'; " \
        "img-src data:; " \
        f"style-src 'self' 'nonce-{session['nonce']}'; " \
        f"font-src 'self'; " \
        f"script-src 'nonce-{session['nonce']}'"
    resp.headers['X-Frame-Options'] = 'DENY'
    resp.headers['X-Content-Type-Options'] = 'nosniff'
    return resp

From schema.sql, we need to read the post with an id of 1 to get the flag:

1
INSERT INTO articles (id, title, content, user_id, secret) VALUES (1, 'Secret', '**FLAG**', 'admin', 1);

This post is locked, and we need to become admin to read it. As flask sessions use HttpOnly cookies, we can rule out the possibility of stealing the admin bot’s session. However, if we can get the admin bot to unlock the secret post, we can read it ourselves.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@blueprint.route('/<int:article_id>', methods=['GET'])
def article(article_id):
    row = connect_db().execute('''
        SELECT * FROM articles WHERE id = ?
    ''', (article_id,)).fetchone()
    if not row:
        abort(404)
    if not row['secret']:
        return render_template('article.html', article=row, owned=False)
    if (isguest() or session['user_id'] != row['user_id']) and not isadmin():
        abort(403)
    return render_template('article.html', article=row, owned=True)

@blueprint.route('/unlock/<int:article_id>', methods=['POST'])
def unlock(article_id):
    if isguest():
        abort(403)

    db = connect_db()
    if isadmin():
        db.execute('''
            UPDATE articles
            SET secret = 0
            WHERE id = ?
        ''', (article_id,))
    else:
        db.execute('''
            UPDATE articles
            SET secret = 0
            WHERE id = ? AND user_id = ?
        ''', (article_id, session['user_id']))
    db.commit()
    return redirect(url_for('app.article', article_id=article_id))

@blueprint.route('/report', methods=['POST'])
def report():
    uri = request.args.get('path')
    if not uri:
        abort(400)
    driver = setup_driver()
    driver.set_page_load_timeout(3)
    signin(driver)
    visit(driver, f"http://127.0.0.1:5000/{uri}")
    return '', 204

Doing so isn’t that easy, as there’s CSRF protection on the website:

1
2
3
4
5
6
7
8
9
10
def create_app():
    load_dotenv()
    app = Flask(__name__)
    app.secret_key = os.urandom(32)
    if not os.path.exists('database.db') or app.config['DEBUG']:
        with open('schema.sql', 'r') as f:
            db = connect_db()
            db.executescript(f.read())
    CSRFProtect(app)
    app.register_blueprint(blueprint)

This suspicious function passes our URL query arguments to resources we load in our page. Which means that if we visit /42?a=a, and it loads something like <link rel="stylesheet" href="/style.css">, the request made will be to /style.css?a=a.

1
2
3
4
@blueprint.url_defaults
def keep_context(_, values):
    for k, v in request.args.items():
        values.setdefault(k, v)

On base.html, this is loaded with every template:

1
2
3
4
5
6
7
8
9
10
11
12
<script nonce="" src="" defer></script>
<script nonce="">
    var lang = '';
    var loggedIn = ;
    var csrfToken = "";
</script>
</head>
<body>

<!-- ... -->

<script nonce="" src=""></script>

Here’s the bundler function. Another suspicious functionality here is that we can control the Content-Type of the files being returned.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@blueprint.route('/bundler', methods=['GET'])
def bundler():
    file_type = request.args.get('type')
    if not file_type or not file_type.isalnum():
        file_type = 'javascript'

    files = request.args.getlist('files') or []
    output = ''
    for file in list(filter(bool, files)):
        try:
            with open(f"static/{secure_filename(file)}", 'r') as f:
                output += f.read() + '\n'
        except FileNotFoundError:
            pass
    resp = make_response(output)
    resp.headers['Content-Type'] = f"text/{file_type}" # sus...
    print(resp.headers)
    return resp

Let’s look at common.js and statistics.js:

common.js:

1
2
3
4
5
/**
 * config
 */
var loggerEndpoint = '/logs';
// more code...

statistics.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* global loggerEndpoint, csrfToken */

function sendLog(data) {
  fetch(loggerEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: 'csrf_token=' + csrfToken + '&' + data,
  });
}

window.addEventListener('load', function() {
  sendLog('event=page_view');
});

window.addEventListener('click', function(e) {
  sendLog('event=click&params=' + encodeURIComponent(JSON.stringify({ x: e.clientX, y: e.clientY })));
});

Exploitation

I originally realized that we could exploit clean_html to inject a <meta> tag and redirect the admin bot to our own website. I then spent a lot of time thinking of how we could steal the CSRF token of the admin. However, this was not the correct strategy at all.

We notice that visiting our created post at /42?type=plain makes the /bundler endpoint return text/plain, so jquery.js, bootstrap.js, and common.js can’t run (as they have the wrong Content-Type).

The browser refuses to load the Javascript

statistics.js is loaded without the use of /bundler, so it still runs and throws an error complaining that loggerEndpoint isn’t defined.

Looking at statistics.js, if we can control loggerEndpoint and set it to /1, we can make the admin bot unlock the secret post and win.

This is where DOM Clobbering comes into play: if loggerEndpoint isn’t defined, creating an element like <a id="loggerEndpoint"> takes the place of loggerEndpoint. This still doesn’t do anything in our fetch() call in statistics.js. However, adding a href attribute like this fixes our problems: <a id="loggerEndpoint" href="/unlock/1">. We can now control what loggerEndpoint.toString() returns.

Controlling loggerEndpoint

In summary, exploitation steps:

  1. Prevent loggerEndpoint from being defined by common.js by preventing its loading, through adding ?type=plain to our URL
  2. Define loggerEndpoint by injecting our own tag: <a id="loggerEndpoint" href="/unlock/1">

The last step is to report our post to the admin and win.

Sending the report…

The post got unlocked!

This post is licensed under CC BY 4.0 by the author.