Le JSON des mers

Hackvens 2022 - Web (56 pts).

Hackvens 2022 - Le Blog du bateau

Détails du challenge

Event Challenge Category Points Solves
Hackvens 2022 Le JSON des mers Web 92 7

Fan de photos de bateaux en mer, j’ai développé une gallerie en ligne pour partager ma passion. J’ai bien veillé à utiliser des jetons sécurisés pour la session, pour que personne ne puisse accéder à la partie admin. N’hésite pas à laisser un avis!

https://json-des-mers.hackvens.fr/

TL;DR

Le site web proposé était développé en Flask. L’envoi d’un message sur la page Contact à l’aide d’un pseudo permet l’authentification ainsi que la génération d’un jeton JWT contenant le pseudo renseigné. L’utilisateur est ensuite redirigé vers une page de vote aux fonctionnalités non implémentées mais contenant un commentaire HTML divulguant le nom de l’administrateur admin_mouss_adm. Il n’est pas possible de créer un jeton au nom de admin_mouss_adm, en revanche, il s’avère qu’il est possible de polluer les paramètres du jeton lors de la création de celui-ci en spécifiant 2 attributs user. La difficulté du challenge repose sur l’aspect peu réaliste de la pollution de paramètre puisque la requête de création dispose d’un content-type traditionnel (et non JSON), résultant en une injection digne d’un payload SQL. Il fallait ainsi renseigner un pseudo comme premieruser", "user": "adam_mouss_adm afin de disposer d’un jeton administrateur. Le jeton généré par défaut était ensuite blacklisté, mais en jouant sur le padding de la signature du jeton il était possible de contourner cette vérification. Enfin, la section administration proposait une fonctionnalité “Show flag” avec un formulaire et des paramètres file=flag.txt résultant en un message d’erreur indiquant que le fichier “flag.txt” n’est pas accessible. Ce formulaire était vulnérable aux Server Side Template Injections (Jinja), et l’utilisation d’un payload traditionnel avec subprocess permettait l’exécution de commande distante, et la lecture du flag.

Le JSON des mers

Le 7 octobre 2022 avait lieu l’évènement Hackvens organisé par Advens. Avec plusieurs collègues d’Imineti by Niji nous avons eu l’opportunité de participer au challenge (CTF) proposé en fin d’évènement. Cet article retrace notre solution pour le challenge “Le JSON des mers”.

Point d’entrée

home.png

Le site web du challenge propose plusieurs pages. La page de vote est inaccessible pour utilisateur non authentifié, et la page d’administration renvoi une erreur 401:

401admin.png

La page de contact permet quant à elle d’envoyer un message à l’administrateur et résulte en la création d’un jeton JWT stocké dans un cookie :

contact.png contact2.png

L’analyse du jeton JWT indique qu’un seul attribut user est stocké dans la section Data. La signature au format HS256 n’est pas cassable, et les attaques standard liées aux signatures JWT ne fonctionnent pas.

jwt.png

La soumission du formulaire permet de débloquer la page de vote. Cette dernière n’est pas fonctionnelle mais contient un commentaire HTML divulguant le nom d’un administrateur adam_mouss_adm.

vote.png comment1.png

Après de nombreuses tentatives, l’idée d’effectuer une pollution d’attributs JWT nous est venue en tête. L’idée est donc de disposer d’un deuxième champ “user” dans notre jeton:

injectjwt.png

En partant du principe que notre pseudo est tout simplement concaténé lors de la génération d’un jeton (comme dans le cas d’une injection SQL), nous somme parti avec le payload Zeecka", "user": "adam_mouss_adm:

payload.png

Cette stratégie s’avère payante puisque l’application semble maintenant nous reconnaitre partiellement comme un administrateur. De même, le commentaire HTML précédemment découvert est de nouveau disponible dans son intégré.

voteadmin.png commentadmin.png

Contournement de la blacklist

Le jeton généré ainsi que celui fournis en commentaire ne sont pas fonctionnels pour accéder à la section admin.

blacklist.png

En jouant sur le padding de la signature du jeton, il est possible de contourner les blacklists. Pour cela, il suffit d’incrémenter ou décrémenter le dernier caractère de la signature:

sign1.png sign2.png

Exécution de code serveur

Une fois la récupération d’un jeton valide, il est possible de tester le formulaire “Show flag” de la section administration. Celui-ci semble ne pas fonctionner et renvoie le message d’erreur “Désolé vous ne pouvez pas accéder au fichier flag.txt” ou bien “Le fichier $fichier n’existe pas”.

flag.txt.png

L’application étant écrite en Flask et le nom du fichier étant affiché, il est possible de tenter une injection de template Jinja 2 avec le payload {{7*7}}:

49.png

La vulnérabilité SSTI est confirmée. Pour finaliser notre exploitation, nous allons utiliser le module subprocess.Popen pour exécuter nos commandes. Cette technique est consultable sur le repo Payload all the things.

mro.png subclass.png popen.png id.png ls.png cat.png

Flag

HACKVENS_{__1n_J$0N_W3_Tru$t__}

Zeecka

Bonus - Code Source

from flask import Flask, render_template, render_template_string, redirect, url_for, make_response, request
from flask_jwt_extended import JWTManager
import jwt
import time
import json

#CONGRATS :)

app = Flask(__name__)

app.config["JWT_SECRET_KEY"] = "eKz7nR61g0fsm1C35eEbEZCLyuJdzGcq3DOiOPMC"
app.config["JWT_ALGORITHM"] = "HS256"
ADM_KEY = "4G2klzstPXfeQM6Ee3zR2jOEGGeE6msjPim9nvIj"

jwt_manager = JWTManager(app)

def jwt_verify(token):
    return(jwt.decode(token, app.config["JWT_SECRET_KEY"], algorithms=['HS256']))

def jwt_verifyAdmin(token):
    try:
        return(jwt.decode(token, ADM_KEY, algorithms=['HS256']))
    except Exception as e:
        raise e

def generateToken(username):
    payload = {'alg':'HS256'}
    print("payload : " + str(payload))
    body = '{"user": "' + str(username) + '", "createdat": "' + str(time.time()) + '"}'
    print("body : " + str(body))
    return(jwt.encode(json.loads(body), app.config["JWT_SECRET_KEY"], algorithm='HS256'))

def generateTokenAdmin():
    payload = {'alg':'HS256'}
    body = '{"user": "adam_mouss_adm", "createdat": "970351200"}'
    return(jwt.encode(json.loads(body), ADM_KEY, algorithm='HS256'))

@app.route('/')
def home():
    return render_template('home.html')

@app.route('/admin',methods=['GET','POST'])
def admin():
    if request.method == 'GET':
        if request.cookies.get('token') is not None :
            token = request.cookies.get('token')
            decoded = jwt_verifyAdmin(token)
            username = decoded['user']
            if token == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRhbV9tb3Vzc19hZG0iLCJjcmVhdGVkYXQiOiI5NzAzNTEyMDAifQ.x-TwOUeNr3ZhX83czsx92sR5eGsNDUqAdTdf2ingzTI":
               return render_template('401.html', message="Des pirates ont volé mon token, j'ai dû le blacklister par mesure de sécurité")
            elif token[-1] == "=" :
               return render_template('401.html', message="Serais-ce toi le hacker ? Je t'enverrai par le fond!") 
            elif token[-1] != "=" and "=" in token :
               return render_template('401.html', message="Serais-ce toi le hacker ? Je t'enverrai par le fond!") 
            elif username == "adam_mouss_adm" :
                return render_template('admin.html')
        else:
            return render_template('401.html', message="Seul l'administrateur du site peut accéder à cette page.")
    elif request.method == 'POST':
        if request.cookies.get('token') is not None :
            token = request.cookies.get('token')
            decoded = jwt_verifyAdmin(token)
            username = decoded['user']
            if token == "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRhbV9tb3Vzc19hZG0iLCJjcmVhdGVkYXQiOiI5NzAzNTEyMDAifQ.x-TwOUeNr3ZhX83czsx92sR5eGsNDUqAdTdf2ingzTI":
               return render_template('401.html', message="Des pirates ont volé mon token, j'ai dû le blacklister par mesure de sécurité")
            elif token[-1] == "=" :
               return render_template('401.html', message="Serais-ce toi le hacker ? Je t'enverrai par le fond!") 
            elif token[-1] != "=" and "=" in token :
               return render_template('401.html', message="Serais-ce toi le hacker ? Je t'enverrai par le fond!") 
            elif username == "adam_mouss_adm" :
                if request.form['file'] is not None :
                    file = request.form['file']
                    if file == "flag.txt" :
                        return render_template('admin.html',file="flag.txt")
                    else :
                        response = '''
                        {% extends 'index.html' %}
        {% block content %}
                <header class="bg-dark py-5">
                    <div class="container px-4 px-lg-5 my-5">
                        <div class="text-center text-white">
                            <h1 class="display-4 fw-bolder">Oh non ma gallerie prend l'eau :(</h1>
                            <div class="text-center text-white">
                            <h5>Le fichier ''' + file + ''' n'existe pas</h5>
                            </div>
                        </div>
                    </div>
                </header>
        {% endblock %}'''
                        return render_template_string(response)
                else :
                    return render_template('admin.html')
        else:
            return render_template('401.html', message="Seul l'administrateur du site peut accéder à cette page.")

@app.route('/about')
def about():
    return render_template('about.html')

@app.route('/votes',methods=['GET'])
def votes():
    if request.method == 'GET':
        if request.cookies.get('token') is not None :
            token = request.cookies.get('token')
            decoded = jwt_verify(token)
            username = decoded['user']
            return render_template('votes.html', username=username)
        else:
            return render_template('401.html', message='Vous devez laisser un avis avant de pouvoir voter.')

@app.route('/contact',methods=['GET','POST'])
def contact():
    if request.method == 'GET':
        return render_template('contact.html')
    if request.method == 'POST':
        if request.form['username'] is not None and request.form['message'] is not None:
            username = request.form['username']
            if username == "adam_mouss_adm" :
                return render_template('contact.html', token_response='NOK1')
            else:
                try:
                    token = generateToken(username)
                    print(token)
                    res = make_response(render_template('contact.html', token_response='OK'))
                    res.set_cookie('token', token, secure=False, httponly=True, samesite="Lax")
                    return res
                except Exception as e:
                    if "Expecting ',' delimiter" in str(e) or "Expecting ':' delimiter" in str(e) :
                        return render_template('contact.html', token_response='NOK2')
                    else:
                        print(e)
                        return render_template('contact.html', token_response='NOK3')
                
        else:
            return render_template('contact.html', token_response="Votre nom/message est vide :(")
        
@app.errorhandler(404)
def page_not_found(e):
    return redirect(url_for('home'))

@app.errorhandler(jwt.exceptions.InvalidSignatureError)
def InvalidSignatureError(e):
    return render_template('401.html', message="Signature du token invalide")

@app.errorhandler(jwt.exceptions.InvalidTokenError)
def InvalidTokenError(e):
    return render_template('401.html', message="Token invalide")

@app.errorhandler(jwt.exceptions.DecodeError)
def DecodeError(e):
    return render_template('401.html', message="Token invalide")

@app.errorhandler(jwt.exceptions.InvalidKeyError)
def InvalidKeyError(e):
    return render_template('401.html', message="Token invalide")

@app.errorhandler(jwt.exceptions.InvalidAlgorithmError)
def InvalidAlgorithmError(e):
    return render_template('401.html', message="Algorithme non autorisé")

@app.errorhandler(jwt.exceptions.MissingRequiredClaimError)
def MissingRequiredClaimError(e):
    return render_template('401.html', message="Token invalide")

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=80, debug=False)