AsisCTF Finals 2025 - Author's Writeup

scoreboard

During the last weekend, ASIS CTF Finals 2025 took place. I authored two challenges that appeared in the competition. Just to share some statistics: the CTF had 773 registered teams, 447 teams solved at least the sanity challenge, and 263 teams solved more than just the sanity. Before diving into the solution write-ups, you can download the challenges here:


Bookmarks

TL;DR Header Injection + CSRF attack - 40 Solves


I’m creating a bookmark site for my friends, do you like it? Feel free to test it out!

intro

The web code for this challenge is quite small and straightforward. It’s just a basic website with login and register functionality. A bookmarking feature is planned for the future but hasn’t been added yet, so there isn’t much to dig into.

dashboard

Let’s take a look at the server-side code, located at src-web/app.py.

@app.after_request
def add_csp_header(response):
    response.headers['Content-Security-Policy'] = "default-src 'none'; style-src 'self';"
    return response

@app.route("/dashboard", methods=['GET'])
def dashboard():
    user_id = session.get("user_id")
    if not user_id:
        return "User not logged", 400

    username = None
    with sqlite3.connect(DB_NAME) as conn:
        cur = conn.execute("SELECT username FROM users WHERE id = ?", (user_id,))
        user = cur.fetchone()
        username = user[0] if user else None

    rendered = render_template("dashboard.html", username=username)
    response = make_response(rendered)
    response.headers['X-User-' + username] = user_id

    # Future logic for saving books of each user
    return response

@app.route("/register", methods=["GET", "POST"])
# ...

@app.route("/login", methods=['GET', 'POST'])
# ...

@app.route("/logout", methods=["GET", "POST"])
# ...

I’ve omitted some parts for clarity. At this point, two things stand out: the Content Security Policy (CSP) is very strict and consistently enforced, and the username is reflected in a custom response header. The username reflected in the template does not allow HTML injection, and even if it did, it would not execute due to the CSP.

HTTP/1.1 200 OK
Server: Werkzeug/3.1.4 Python/3.12.12
Date: Sun, 28 Dec 2025 10:29:49 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 837
X-User-bubu: 25014
Content-Security-Policy: default-src 'none'; style-src 'self';
Vary: Cookie
Connection: close

<!DOCTYPE html>
<html lang="en">
...
</html>

By taking a closer look at the response, and combining these two observations, it becomes easier to come up with an attack idea: using the username reflected in the response header to perform a newline injection. This serves two purposes. First, to cause the CSP to be rendered as raw HTML instead of being interpreted as a directive, and second, to achieve XSS.

Newline injection in the header name, while the header value itself is properly sanitized, was reported to the Flask project in 2021 (ref). However, it was considered as uncommon in real-world scenario, since user-supplied data is rarely used directly in header names.

HTTP/1.1 200 OK
Server: Werkzeug/3.1.4 Python/3.12.12
Date: Sun, 28 Dec 2025 13:03:37 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 873

<img src=x onerror=alert(1)>: 25430
Content-Security-Policy: default-src 'none'; style-src 'self';
Vary: Cookie
Connection: close

<!DOCTYPE html>
<html lang="en">
...
</html>

Now that we have XSS on the site, let’s take a look at the bot code in src-bot/app.py.

def visit_web(url):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context()
        page = context.new_page()

        try:
            # Visit your URL first, to avoid any attack
            print(f"[BOT] Visiting {url}")
            page.goto(url)
            time.sleep(5)

            # Register and log as admin
            print("[BOT] Login & registering")
            page.goto(BOT_VISIT + '/register')
            page.fill("input[name='username']", FLAG)
            page.fill("input[name='password']", "password")
            page.click("input[type='submit']")
            time.sleep(1)
            page.goto(BOT_VISIT + '/login')
            page.fill("input[name='username']", FLAG)
            page.fill("input[name='password']", "password")
            page.click("input[type='submit']")
            time.sleep(1)

            # Do some admin stuff
            print("[BOT] Admin stuff")
            time.sleep(5)
        except Exception as e:
            print(f"[BOT] Failed to visit {url}: {e}")
        print("[BOT] Finished")
        context.close()
        browser.close()

The bot registers and logs in with the flag as the username; however, it first visits our website. This initially makes the challenge seem unsolvable: because the visit happens before registration and login, even a successful CSRF-triggered XSS would not give us access to the flag.

The key insight is that, since the bot uses a headless browser, we can open multiple tabs using window.open. This allows us to keep the XSS payload running in a second tab while the first tab performs the registration and login using the flag as the username.

Because the second tab is never reloaded, the XSS continues executing with the newline-injection user we used on the CSRF attack. Once the bot has logged in with the flag, the XSS can open a new tab pointing to the dashboard and read its contents to check whether the flag is present. This works because both tabs share the same origin, and window.open returns a reference to the newly opened window.

Check the image below for a clearer view of the solution (open it in a new tab for best quality). Alternatively, you can refer to this solution script to test the exploit (link).

solution

You can find the final solver here: link.


W4 Schools

TL;DR CSP frame-ancestors exfiltration - 2 Solves


I’m creating a new website to learn HTML. Sandboxing shouldn’t be too difficult, right?

intro

To better explain the logic behind this challenge, take a look at the following image:

setup

Before thinking about how to break the sandbox, let’s take a look at the bot logic:

FLAG = os.getenv("FLAG", "ctf{REDACTED}").replace("{", "").replace("}", "")
...
def visit_web(code):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        try:
            for FLAG_CHAR in FLAG:
                visit = f"http://{FLAG_CHAR}.{WEB_ORIGIN}/?sandbox=" + quote(code)
                print(f"[BOT] Visiting {visit}")
                page.goto(visit)
                time.sleep(3)
                print(f"[BOT] Visited {visit}")
        except Exception as e:
            print(f"[BOT] Failed to visit {WEB_ORIGIN}: {e}")

setup

The bot visits web.asisctf.com with the flag encoded in the subdomain, character by character (e.g., a.web.asisctf.com). Our injection point is a reported URL under the sandbox domain, and the objective is to exfiltrate those characters from within the sandboxed context. Because of the sandbox directive, our options are quite limited. And since the sandbox domain is a different origin, we can pretty much rule out reading the page content from there.

Finally, the solution for exfiltrating the subdomain is to leverage iframes and the CSP frame-ancestors directive (CSP frame-ancestors spec). From within the sandbox domain, we can include an iframe for each character. Each iframe serves a frame-ancestors policy that applies not only to the sandboxed document, but also propagates up the parent tree to the top-level page.

The key insight is that frame-ancestors does not apply only to the document being framed; it also affects the entire ancestor chain. In this case, that includes the top-level document, which allows us to infer the subdomain characters.

solution

You can find the final solver here: link.

My initial idea for this challenge was to visit the flag as a subdomain, embedding the entire flag at once. However, after reading the specifications (csp spec and host-source definition) and testing browser behavior, I found that wildcards cannot be used with arbitrary characters. As a result, I changed the bot’s logic.

Hope you enjoyed them :)

Thanks for reading!

Other writeups

  • CrewCTF 2025 Author’s writeup: link.
  • The SAS Quals 2025: link.
  • HKCERT 2024 Quals: link.

Others

  • Browser Permissions Blog: link.
  • Permission Hijacking at Scale: link
  • Web-to-App Communication: link.
Alberto Fernandez-de-Retana
Alberto Fernandez-de-Retana

Kaixo! My research interests include web security & privacy. In my free time I love to be pizzaiolo.