Blog | Ravr

[EN] Intigriti's XSS challenge - 0122

xss

January 16, 2022

XSS Challenge 0122 page

Challenge rules

On the 10th January 2022 Intigriti launched a new XSS challenge, created by @TheRealBrenu. The goal is to find a way to execute arbitrary javascript on the https://challenge-0122-challenge.intigriti.io page.

As we can see on the image above, the solution should comply with the following rules:

  • work on the latest version of Chrome and FireFox,
  • execute alert(document.domain),
  • leverage a cross site scripting vulnerability on https://challenge-0122.intigriti.io/ or the domain of the challenge page,
  • not be self-XSS or related to MiTM attacks.

Let the fun begin! 🙂

Black-box testing

Let’s start with the most important thing - understanding what the application is actually doing. The challenge page says to test our payloads on https://challenge-0122-challenge.intigriti.io/ so let’s go there. challenge 0122 1 challenge 0122 2

If we insert a simple xss payload <img src=1 onerror=alert(1)> into the textarea and send it, we can observe at least two things:

  • alert(1) was not fired,
  • we were redirected to the /result?payload=<img%20src%3D1%20onerror%3Dalert(1)> page with our payload in payload query parameter.

Therefore, we can put our payloads directly in the payload query parameter on /result path so we don’t need to enter it into the textarea on the root page. This will definitely help us at the end with making the xss completely non-interactive. challenge 0122 3

Now, let’s inspect the currently renedered element (right click on that element and then Inspect) and see why our alert(1) was not called. challenge 0122 4

As we can see on the picture above, the <img> tag was sanitized and onerror=alert(1) was removed. Therefore, we know that “something” is cleaning our payload by removing dangerous HTML.

Understanding the code

Now let’s go to the Sources panel in DevTools and look at the real source-code. challenge 0122 5

We can see that the webpage loads main.02a05519.js but this file is minified so it’s really hard to read and understand. challenge 0122 6

If we look closer we will notice that there are also other files in the /static/ directory. It looks like we have access to non-minified version of the main.02a05519.js file! The index.js tells us this is a React app. challenge 0122 7

App.js file renders <Router> element from router.js which defines two routes:

  • index with path: ”/”
  • result with path: “/result”
import { BrowserRouter, Routes, Route } from "react-router-dom"
import I0x1C from "./pages/I0x1C"
import I0x1 from "./pages/I0x1"

const identifiers = {
  I0x1: "UmVzdWx0",
  I0x2: "cGF5bG9hZEZyb21Vcmw=",
  I0x3: "cXVlcnlSZXN1bHQ=",
  I0x4: "bG9jYXRpb24=",
  I0x5: "c2VhcmNo",
  I0x6: "Z2V0",
  I0x7: "cGF5bG9hZA==",
  //...[snip]...
}

export default function Router() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/">
          <Route index element={<I0x1C identifiers={identifiers} />} />
          <Route path="result" element={<I0x1 identifiers={identifiers} />} />
        </Route>
      </Routes>
    </BrowserRouter>
  )
}

Each route renders a different React element, the “index” route renders <I0x1C> and the “result” route <I0x1>. There is also a big object called identifiers containing key-value pair data with values endcoded in base64 format. This object is passed as a identifiers property to both of these elements. Please, remember this object, we will need it later.

According to the analysis in the previous section, we know that our payload is rendered on /result path. The “result” route renders <I0x1> element so let’s go straight to the code of it in /pages/I0x1/index.js:

import { useState } from "react"
import DOMPurify from "dompurify"
import "../../App.css"

function I0x1({ identifiers }) {
  const [I0x2, _] = useState(() => {
    const I0x3 = new URLSearchParams(
      window[window.atob(identifiers["I0x4"])][window.atob(identifiers["I0x5"])]
    )[window.atob(identifiers["I0x6"])](window.atob(identifiers["I0x7"]))

    if (I0x3) {
      const I0x8 = {}
      I0x8[window.atob(identifiers["I0x9"])] = I0x3

      return I0x8
    }

    const I0x8 = {}
    I0x8[window.atob(identifiers["I0x9"])] = window.atob(identifiers["I0xA"])

    return I0x8
  })

  function I0xB(I0xC) {
    for (const I0xD of I0xC[window.atob(identifiers["I0xE"])]) {
      if (
        window.atob(identifiers["I0x11"]) in
        I0xD[window.atob(identifiers["I0xF"])]
      ) {
        new Function(
          I0xD[window.atob(identifiers["I0x10"])](
            window.atob(identifiers["I0x11"])
          )
        )()
      }

      I0xB(I0xD)
    }
  }

  function I0x12(I0x13) {
    I0x13[window.atob(identifiers["I0x9"])] = DOMPurify[
      window.atob(identifiers["I0x15"])
    ](I0x13[window.atob(identifiers["I0x9"])])

    let I0x14 = document[window.atob(identifiers["I0x16"])](
      window.atob(identifiers["I0x14"])
    )
    I0x14[window.atob(identifiers["I0x17"])] =
      I0x13[window.atob(identifiers["I0x9"])]
    document[window.atob(identifiers["I0x32"])][
      window.atob(identifiers["I0x18"])
    ](I0x14)

    I0x14 = document[window.atob(identifiers["I0x19"])](
      window.atob(identifiers["I0x14"])
    )[0]
    I0xB(I0x14[window.atob(identifiers["I0x1A"])])

    document[window.atob(identifiers["I0x32"])][
      window.atob(identifiers["I0x1B"])
    ](I0x14)

    return I0x13
  }

  return (
    <div className="App">
      <h1>Here is the result!</h1>
      <div id="viewer-container" dangerouslySetInnerHTML={I0x12(I0x2)}></div>
    </div>
  )
}

export default I0x1

Unfortunately, it is not easily readable so we have to deobfuscate it to be able to understand the code. If we look closer we will notice an interesting pattern: window.atob(identifiers["I0x.."]) is used everywhere in this code. window.atob is a function which simply decodes a string of data which has been encoded using Base64 encoding. As we mentioned earlier in this section, the identifiers object is defined in the router.js file and it’s passed as a property to the I0x1 element.

Now let’s open developer console and try to read the real values in this part of the code. First, copy the indentifiers object from router.js and paste it to the developer console - we need it to be in the scope because we will use it in window.atob(identifiers["I0x.."]) in a few moments. Now copy e.g. window.atob(identifiers["I0x4"]) or any similar block: challenge 0122 8

As you can see on the image above, window.atob(identifiers["I0x4"]) == "location", window.atob(identifiers["I0x9"]) == "__html".

Beautifying the code

We could manually replace all of the mentioned blocks in <I0x1> element. Instead, let’s create a script to do this for us automatically:

const identifiers = {
  I0x1: "UmVzdWx0",
  I0x2: "cGF5bG9hZEZyb21Vcmw=",
  I0x3: "cXVlcnlSZXN1bHQ=",
  I0x4: "bG9jYXRpb24=",
  I0x5: "c2VhcmNo",
  I0x6: "Z2V0",
  I0x7: "cGF5bG9hZA==",
  I0x8: "cmVzdWx0",
  I0x9: "X19odG1s",
  I0xA: "PGgxIHN0eWxlPSdjb2xvcjogIzAwYmZhNSc+Tm90aGluZyBoZXJlITwvaDE+",
  I0xB: "aGFuZGxlQXR0cmlidXRlcw==",
  I0xC: "ZWxlbWVudA==",
  I0xD: "Y2hpbGQ=",
  I0xE: "Y2hpbGRyZW4=",
  I0xF: "YXR0cmlidXRlcw==",
  I0x10: "Z2V0QXR0cmlidXRl",
  I0x11: "ZGF0YS1kZWJ1Zw==",
  I0x12: "c2FuaXRpemVIVE1M",
  I0x13: "aHRtbE9iag==",
  I0x14: "dGVtcGxhdGU=",
  I0x15: "c2FuaXRpemU=",
  I0x16: "Y3JlYXRlRWxlbWVudA==",
  I0x17: "aW5uZXJIVE1M",
  I0x18: "YXBwZW5kQ2hpbGQ=",
  I0x19: "Z2V0RWxlbWVudHNCeVRhZ05hbWU=",
  I0x1A: "Y29udGVudA==",
  I0x1B: "cmVtb3ZlQ2hpbGQ=",
  I0x1C: "SG9tZQ==",
  I0x1D: "c2V0UGF5bG9hZA==",
  I0x1E: "ZWRpdG9yUmVm",
  I0x1F: "bmF2aWdhdGU=",
  I0x20: "aGFuZGxlU3VibWl0",
  I0x21: "ZXZlbnQ=",
  I0x22: "cHJldmVudERlZmF1bHQ=",
  I0x23: "L3Jlc3VsdD9wYXlsb2FkPQ==",
  I0x24: "dmFsdWU=",
  I0x25: "a2V5",
  I0x26: "VGFi",
  I0x27: "c2hpZnRLZXk=",
  I0x28: "c2V0UmFuZ2VUZXh0",
  I0x29: "ICAgIA==",
  I0x2A: "c2VsZWN0aW9uU3RhcnQ=",
  I0x2B: "ZW5k",
  I0x2C: "bGluZVN0YXJ0",
  I0x2D: "c3RhcnQ=",
  I0x2E: "bGVuZ3Ro",
  I0x2F: "c2xpY2U=",
  I0x30: "c2V0U2VsZWN0aW9uUmFuZ2U=",
  I0x31: "Cg==",
  I0x32: "Ym9keQ==",
  I0x33: "dGFyZ2V0",
  I0x34: "Y3VycmVudA==",
}
let str = `
  import { useState } from "react";
  import DOMPurify from "dompurify";
  import "../../App.css";

  function I0x1({ identifiers }) {
    const [I0x2, _] = useState(() => {
      const I0x3 = new URLSearchParams(
        window[window.atob(identifiers["I0x4"])][window.atob(identifiers["I0x5"])]
      )[window.atob(identifiers["I0x6"])](window.atob(identifiers["I0x7"]));

      if (I0x3) {
        const I0x8 = {};
        I0x8[window.atob(identifiers["I0x9"])] = I0x3;

        return I0x8;
      }

      const I0x8 = {};
      I0x8[window.atob(identifiers["I0x9"])] = window.atob(identifiers["I0xA"]);

      return I0x8;
    });

    function I0xB(I0xC) {
      for (const I0xD of I0xC[window.atob(identifiers["I0xE"])]) {
        if (
          window.atob(identifiers["I0x11"]) in
          I0xD[window.atob(identifiers["I0xF"])]
        ) {
          new Function(
            I0xD[window.atob(identifiers["I0x10"])](
              window.atob(identifiers["I0x11"])
            )
          )();
        }

        I0xB(I0xD);
      }
    }

    function I0x12(I0x13) {
      I0x13[window.atob(identifiers["I0x9"])] = DOMPurify[
        window.atob(identifiers["I0x15"])
      ](I0x13[window.atob(identifiers["I0x9"])]);

      let I0x14 = document[window.atob(identifiers["I0x16"])](
        window.atob(identifiers["I0x14"])
      );
      I0x14[window.atob(identifiers["I0x17"])] =
        I0x13[window.atob(identifiers["I0x9"])];
      document[window.atob(identifiers["I0x32"])][
        window.atob(identifiers["I0x18"])
      ](I0x14);

      I0x14 = document[window.atob(identifiers["I0x19"])](
        window.atob(identifiers["I0x14"])
      )[0];
      I0xB(I0x14[window.atob(identifiers["I0x1A"])]);

      document[window.atob(identifiers["I0x32"])][
        window.atob(identifiers["I0x1B"])
      ](I0x14);

      return I0x13;
    }

    return (
      <div className="App">
        <h1>Here is the result!</h1>
        <div id="viewer-container" dangerouslySetInnerHTML={I0x12(I0x2)}></div>
      </div>
    );
  }

  export default I0x1;
`

const re = /window.atob\(identifiers\["(I0x[0-9A-F]+)"\]\)/gstr = str.replace(re, function (match, group) {  return `"${window.atob(identifiers[group])}"`})
console.log(str)

As you can see, the variable str contains the js code of <I0x1> element, the variable re is a regex that matches all occurrences such as: window.atob(identifiers["I0x4"]), window.atob(identifiers["I0x14"]) etc. challenge 0122 9a

If you copy the above script and paste it into developer console, it will return a much cleaner code:

import { useState } from "react"
import DOMPurify from "dompurify"
import "../../App.css"

function I0x1({ identifiers }) {
  const [I0x2, _] = useState(() => {
    const I0x3 = new URLSearchParams(window["location"]["search"])["get"](
      "payload"
    )

    if (I0x3) {
      const I0x8 = {}
      I0x8["__html"] = I0x3

      return I0x8
    }

    const I0x8 = {}
    I0x8["__html"] = "<h1 style='color: #00bfa5'>Nothing here!</h1>"

    return I0x8
  })

  function I0xB(I0xC) {
    for (const I0xD of I0xC["children"]) {
      if ("data-debug" in I0xD["attributes"]) {
        new Function(I0xD["getAttribute"]("data-debug"))()
      }

      I0xB(I0xD)
    }
  }

  function I0x12(I0x13) {
    I0x13["__html"] = DOMPurify["sanitize"](I0x13["__html"])

    let I0x14 = document["createElement"]("template")
    I0x14["innerHTML"] = I0x13["__html"]
    document["body"]["appendChild"](I0x14)

    I0x14 = document["getElementsByTagName"]("template")[0]
    I0xB(I0x14["content"])

    document["body"]["removeChild"](I0x14)

    return I0x13
  }

  return (
  <div className="App">
    <h1>Here is the result!</h1>
    <div id="viewer-container" dangerouslySetInnerHTML={I0x12(I0x2)}></div>
  </div>
  )
}

export default I0x1

Now let’s rename some variables to make the code even more readable. The final code is as follows:

import { useState } from "react"
import DOMPurify from "dompurify"
import "../../App.css"

function I0x1({ identifiers }) {
  const [state, _] = useState(() => {
    const payloadParam = new URLSearchParams(window["location"]["search"])[
      "get"
    ]("payload")

    if (payloadParam) {
      const state = {}
      state["__html"] = payloadParam

      return state
    }

    const state = {}
    state["__html"] = "<h1 style='color: #00bfa5'>Nothing here!</h1>"

    return state
  })

  function debug(elem) {
    for (const child of elem["children"]) {
      if ("data-debug" in child["attributes"]) {
        new Function(child["getAttribute"]("data-debug"))()
      }

      debug(child)
    }
  }

  function getSecureHtml(state) {
    state["__html"] = DOMPurify["sanitize"](state["__html"])

    let template = document["createElement"]("template")
    template["innerHTML"] = state["__html"]
    document["body"]["appendChild"](template)

    template = document["getElementsByTagName"]("template")[0]
    debug(template["content"])

    document["body"]["removeChild"](template)

    return state
  }

  return (
    <div className="App">
      <h1>Here is the result!</h1>
      <div
        id="viewer-container"
        dangerouslySetInnerHTML={getSecureHtml(state)}
      ></div>
    </div>
  )
}

export default I0x1

Ok, let’s explain what the code is actually doing. The I0x1 function is a React functional component. During initialization it sets up a state (lines 6 - 22 -> useState method). In a nutshell, this function gets payload query parameter from url and saves it into the state["__html"] object which is accessible in the entire component. Do you remember our first observartion when we noticed that the payload query parameter is used to create html? This is the code responsible for that part!

Then, let’s look at the bottom of the I0x1 function, it returns some html (lines 50 - 56). It is called jsx, and it is a syntax extension to JavaScript. If we look closer we will notice it uses dangerouslySetInnerHTML property which as it name suggests, is responsible for setting innerHTML without any kind of santization. This function takes the value returned by getSecureHtml function that firstly sanitizes provided state using DOMPurify library and secondly creates a template html element and inserts the sanitized html into it. Our first thought would be:

If there is a vulnerability in DOMPurify and we provide a particular xss payload, it will bypass the sanitizer and execute our xss

However, DOMPurify is a well-tested library so there is a low probability to find a vulnerability there. Let’s leave that idea alone for now and read the rest of the getSecureHtml function.

function getSecureHtml(state) {
  state["__html"] = DOMPurify["sanitize"](state["__html"])

  let template = document["createElement"]("template")
  template["innerHTML"] = state["__html"]
  document["body"]["appendChild"](template)

  template = document["getElementsByTagName"]("template")[0]
  debug(template["content"])
  document["body"]["removeChild"](template)

  return state
}

In line 9, debug function is called with the content of template element so basically with the payload passed in the payload query parameter. Let’s look at that function:

function debug(elem) {
  for (const child of elem["children"]) {
    if ("data-debug" in child["attributes"]) {
      new Function(child["getAttribute"]("data-debug"))()
    }

    debug(child)
  }
}

As you can see, this function goes through all children of the provided argument and if any child contains data-debug attribute, takes the value of it, creates a new Function object and runs it! Therefore anything passed in data-debug attribute could be run and because data-debug isn’t dangerous html attribute, DOMPurify won’t remove it.

Aha! So there is the vulnerability!

Executing alert

The last thing to do is to just create a html element containg data-debug with a value we want to run, eg. alert(document.domain). Therefore, the final payload is as follows: <a data-debug="alert(document.domain)">XSS</a>.

Now we have to pass it to the url we previously discovered and test it: https://challenge-0122-challenge.intigriti.io/result?payload=<a data-debug="alert(document.domain)">XSS</a>

Chrome: challenge 0122 9

Firefox: challenge 0122 10

Great, our payload works as intended, alert(document.domain) was executed!!! So we don’t need to look for a vulnerability in DOMPurify library 😉

© 2023, Code by ravr & powered by Gatsby