Override
Level 7 web exploitation challenge on Dreamhack.io
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¶ms=' + 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.
In summary, exploitation steps:
- Prevent
loggerEndpointfrom being defined bycommon.jsby preventing its loading, through adding?type=plainto our URL - Define
loggerEndpointby injecting our own tag:<a id="loggerEndpoint" href="/unlock/1">
The last step is to report our post to the admin and win.


