Zeecka zeecka

Le JSON des mers

Hackvens 2022 - Web (56 pts)

Hackvens 2022 - The Boat Blog

Challenge details

EventChallengeCategoryPointsSolves
Hackvens 2022Le JSON des mersWeb927

As a fan of photos of boats at sea, I developed an online gallery to share my passion. I made sure to use secure tokens for the session, so that no one could access the admin section. Feel free to leave a review!

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

TL;DR

The website provided was built in Flask. Sending a message on the Contact page using a nickname allows authentication as well as the generation of a JWT token containing the supplied nickname. The user is then redirected to a voting page whose features are not implemented but which contains an HTML comment disclosing the administrator’s name admin_mouss_adm. It is not possible to create a token in the name of admin_mouss_adm; however, it turns out that it is possible to pollute the token’s parameters during its creation by specifying 2 user attributes. The difficulty of the challenge lies in the rather unrealistic nature of the parameter pollution, since the creation request uses a traditional content-type (and not JSON), resulting in an injection worthy of an SQL payload. You therefore had to supply a nickname like premieruser", "user": "adam_mouss_adm in order to obtain an administrator token. The token generated by default was then blacklisted, but by tweaking the padding of the token’s signature it was possible to bypass this check. Finally, the administration section offered a “Show flag” feature with a form and file=flag.txt parameters, resulting in an error message indicating that the file “flag.txt” is not accessible. This form was vulnerable to Server Side Template Injections (Jinja), and using a traditional payload with subprocess allowed remote command execution and reading the flag.

Le JSON des mers

On 7 October 2022, the Hackvens event organized by Advens took place. Together with several colleagues from Imineti by Niji, we had the opportunity to take part in the challenge (CTF) offered at the end of the event. This article recounts our solution for the “Le JSON des mers” challenge.

Entry point

home.png

The challenge’s website offers several pages. The voting page is inaccessible to an unauthenticated user, and the administration page returns a 401 error:

401admin.png

The contact page, for its part, lets you send a message to the administrator and results in the creation of a JWT token stored in a cookie:

contact.png contact2.png

Analysis of the JWT token indicates that a single user attribute is stored in the Data section. The HS256-format signature is not crackable, and the standard attacks related to JWT signatures do not work.

jwt.png

Submitting the form unlocks the voting page. The latter is not functional but contains an HTML comment disclosing the name of an administrator adam_mouss_adm.

vote.png comment1.png

After many attempts, the idea of performing a JWT attribute pollution came to mind. The idea is therefore to have a second “user” field in our token:

injectjwt.png

Assuming that our nickname is simply concatenated during the generation of a token (as in the case of an SQL injection), we went with the payload Zeecka", "user": "adam_mouss_adm:

payload.png

This strategy pays off, since the application now seems to partially recognize us as an administrator. Likewise, the HTML comment previously discovered is once again available in its entirety.

voteadmin.png commentadmin.png

Bypassing the blacklist

The generated token as well as the one provided in the comment are not functional for accessing the admin section.

blacklist.png

By tweaking the padding of the token’s signature, it is possible to bypass the blacklists. To do this, you simply need to increment or decrement the last character of the signature:

sign1.png sign2.png

Server-side code execution

Once a valid token has been obtained, it is possible to test the “Show flag” form of the administration section. It appears not to work and returns the error message “Désolé vous ne pouvez pas accéder au fichier flag.txt” or “Le fichier $fichier n’existe pas”.

flag.txt.png

Since the application is written in Flask and the file name is displayed, it is possible to attempt a Jinja 2 template injection with the payload {{7*7}}:

49.png

The SSTI vulnerability is confirmed. To finalize our exploitation, we will use the subprocess.Popen module to execute our commands. This technique can be found on the Payload all the things repo.

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

Flag

HACKVENS_{__1n_J$0N_W3_Tru$t__}

Zeecka

Bonus - Source Code

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)