Author Writeup - Secure Letter Revenge QnqSec CTF 2025 Web Challenge
QnqSec CTF 2025 ~ Secure Letter Revenge Web
Twitter: @L3G4CY5
I’ve authored a Secure Letter and Secure Letter Revenge web challenges on QnQSec CTF 2025. I won’t write up Secure Letter because it’s fairly trivial..
Secure Letter Description:
1 | |
This ^ was the same app, just had an unintended simple xss on /letter?content= endpoint :face_exhaling: . Never believe cursor.
Secure Letter Revenge Description:
1 | |
Solves: 3
https://ctf.qnqsec.team/challenges#secure-letter-revenge-75
Exploit TLDR;
- Abuse iframe’s reparenting with disk cache to escape the sandbox
- Use dom cloberring to clobber a gadget and load your own javascript
- XSS!
Short Overview
The handout files included a backend in server.js which just had some basic routes for Letter App but the server side was irrelevant. There was also bot.js which was a bot that visits the challenge site, sets the flag as cookie then visits the reported url.
The challenge setup immediately indicates that this is indeed an XSS challenge.
Challenge
I will only provide code snippets that are relevant to the exploit.
letter-writer.html ^ is the index page for the website.
1 | |
It loads DOMPurify and letter-writer.js and wires the buttons to the functions.
letter-writer.js (relevant parts):
1 | |
When the letter is updated, input is read from the textbox, then sanitized with DOMPurify. The cookie is also sanitized. DOMPurify here is recent, so no obvious bypass unless you find a 0-day. The iframe’s sandbox is set, and the iframe src is set to an inline data: URL. The updateLetter URL parameter helps automate the exploit later on.
We can see we have html injection immediately, but there is nothing malicious we can do because of DOMPurify…
Dom Clobbering
Let’s analyze briefly the inline script for “analytics”:
1 | |
There is loadAnalytics function that has variables for analytics url, itchecks if there is a window.cfg.analyticsURL value inside the window, if no it will use the defaultAnalyticsURL. This should immediately trigger a dom clobbering bell in your head, but lets continue. It takes this url, creates a script tag and uses the url for src value inside the script tag.
We can also see this weird way of calling the loadAnalytics() function. This is actually an old polyfill-style async scheduling technique — a hack to defer execution asynchronously (similar to process.nextTick or queueMicrotask), often used in early polyfills or async task schedulers before native APIs were available. The “dev” comment should have gave it out :) .
In this small code we can see an easy dom clobbering of the cfg.analyticsURL url being used to retrieve analytics.js .
Also we can see that the cookie is being sanitized and set inside document.uid, this is great for us because inside data: urls the cookies are completely disabled, and also all iframes with data: urls have no access to the window.parent values.
But this would be great if the script at least ran inside the iframe? :eyes:
If we look at the website we get an error when we update the letter:
This error happens because the script inside the preview iframe is trying to execute inside the sandboxed iframe.
Hm, well thats definitely not helpful. At first glance it might look impossible to escape the iframe sandbox… but is it?
Sandbox Escape
Huli’s blogpost on iframe reparenting and bfcache explains a browser quirk we can abuse: when a page is restored from disk cache (not bfcache), some iframe properties (notably src) are restored while others (like sandbox) are not. This can create a mixed state: sandbox from one moment + src from another.
I will explain it in essence:
Using bfcache, doing a top-level navigation, and then going back will return the page to exactly the state you left it in. But if there is no bfcache, the browser uses disk cache and loads what it can from there. We can use this mechanism to exploit the iframe’s sandbox-setting logic in this app and iframe’s reparenting.
With bfcache, the iframe doesn’t change when you go back, obviously. But if we load an iframe with no bfcache, then perform the challenge’s logic to set the sandbox attribute and src,then do a top-level navigation and go back to trigger the cache, we end up with a weird iframe state.
The sandbox state reverts to default (no sandbox, in this case), while the src retains the latest value. This happens because the iframe’s src attribute is stored and in and retrieved from the disk cache, but the sandbox attribute is not. The result is a mixed state: sandbox state 1 + src value state 2 aka in our case no sandbox + our updated letter content.
Wow — maybe we can use this to escape?! But there’s one more issue: how do we trigger the disk cache and disable bfcache?
Reading the docs you’ll find a bfcache update showing that bfcache is now being used even when Cache-Control: no-store is set, except when the page is considered sensitive. A page is treated as sensitive if it involves authentication, cookies, or one of the cases is that if it was opened via window.open() .
(https://web.dev/articles/bfcache#avoid-window-opener)
(https://github.com/fergald/explainer-bfcache-ccns/blob/main/README.md#sensitive-information)
This works in our favor: we can open the page via window.open() to disable bf-cache, DOM-clobber the iframe’s analytics URL, do a top-level navigation away, then go back — the page should load values from the disk cache. 😎
Quick note: headless browsers have bfcache disabled anyway. :D
https://github.com/GoogleChrome/lighthouse/issues/14784
Exploit
Our exploit uses these steps:
- Load the page without bfcache
- Use the html injection to clobber the analytics url value:
1 | |
- Do a top level navigation
- Then head back with
history.back()to trigger the disk cache.
We will host the following exploit:
1 | |
Send the url where you hosted your exploit to the bot, wait a few sec…
and voila the flag is ours!
I hope you enjoyed the challenge, the writeup and learned something new!
Solves:
- 🥇 c4_planter
- 🥈 TCP1P
- 🥉 AdderallOverdose