Analysis

When entering the challenge page we get a Win XP style UI generator.

Let’s enter some values and see what happens.

After submitting the input we get the following result.

Here we can see that some changes has been made to our input, both the space in the text from the Window name and Window content inputs are replaced with underscores.

Taking a look at the query parameters we can see a whole bunch of them.

?config[window-name]=Test name&config[window-content]=Test content&config[window-toolbar][0]=min&config[window-toolbar][1]=max&config[window-toolbar][2]=close&config[window-statusbar]=true

Now let’s take a look at the source code to see what’s going on.

In the first part of the body tag we find out that the page is made by using Mithril

<!-- downloaded from https://unpkg.com/mithril@2.0.4/mithril.js -->
<script src="Window%20Maker_files/mithril.js"></script>

Taking a look at the official documentation for Mithril tells us that it’s a JavaScript framework for building Single Page Applications.

Let’s take a look at the script.

(function(){
    const App = {
      view: function() {
        return m("div", {class: "window"}, [
          m(TitleBar),
          m(WindowBody),
          m(StatusBar)
        ])
      }
    }

    const TitleBar = {
      view: function(vnode) {
        const options = vnode.attrs.options || ['min', 'max', 'close']
        const name = vnode.attrs.name || "Window Maker"
        return m("div", {class: "title-bar"}, [
          m("div", {class: "title-bar-text"}, String(name)),
          m("div", {class: "title-bar-controls"}, [
            options.includes('min') && m("button", {'aria-label': 'Minimize'}),
            options.includes('max') && m("button", {'aria-label': 'Maximize'}),
            options.includes('close') && m("button", {'aria-label': 'Close'}),
          ]),
        ])
      }
    }

    const WindowBody = {
      view: function() {
        return m("div", {class: "window-body"}, [
          m("p", [
            "Do you miss these looks and feels? We can help!",
            m("br"),
            "Window Maker is a website to help people build their own UI in 3 minutes!"
          ]),
          m("p", [
            m(InputWindowName),
            m(InputWindowContent),
            m("br"),
            m(InputToolbar),
            m("br"),
            m(InputStatusBar)
          ]),
          m("button", {onclick: function() {
            const windowName = document.querySelector('#win-name').value
            const windowContent = document.querySelector('#win-content').value
            const toolbar = Array.from(document.querySelectorAll('input[type=checkbox]:checked')).map(item => item.value)
            const showStatus = document.querySelector('#radio-yes').checked
            const config = {
              'window-name': windowName,
              'window-content': windowContent,
              'window-toolbar': toolbar,
              'window-statusbar': showStatus
            }
            const qs = m.buildQueryString({
              config
            })
            window.location.search = '?' + qs
          }}, "generate")
        ])
      }
    } 

    const InputWindowName = {
      view: function(vnode) {
        return m("div", {class: "field-row-stacked"}, [
          m("label", {for: 'win-name' }, 'Window name'),
          m("input", {id: 'win-name', type: 'text' }),
        ])
      }
    }

    const InputWindowContent = {
      view: function(vnode) {
        return m("div", {class: "field-row-stacked"}, [
          m("label", {for: 'win-content' }, 'Window content(plaintext only)'),
          m("textarea", {id: 'win-content', rows: '8' }),
        ])
      }
    }

    const InputToolbar = {
      view: function(vnode) {
        return m("div", [
          m("div", {class: "field-row"}, [
            m("label", "Toolbar"),
          ]),
          m(Checkbox, { id: "toolbar-min", value: "min" }),
          m(Checkbox, { id: "toolbar-max", value: "max" }),
          m(Checkbox, { id: "toolbar-close", value: "close" }),
        ])
      }
    }

    const Checkbox = {
      view: function(vnode) {
        return m("div", {class: "field-row"}, [
          m("input", {id: String(vnode.attrs.id), type: 'checkbox', value: String(vnode.attrs.value) }),
          m("label", {for: String(vnode.attrs.id) }, String(vnode.attrs.value)),
        ])
      }
    }

    const InputStatusBar = {
      view: function() {
        return m("div", [
          m("div", {class: "field-row"}, [
            m("label", "Status bar"),
          ]),
          m(RadioButton, { id: "radio-yes", value: "Yes" }),
          m(RadioButton, { id: "radio-no", value: "No" }),
        ])
      }
    }

    const RadioButton = {
      view: function(vnode) {
        return m("div", {class: "field-row"}, [
          m("input", {id: String(vnode.attrs.id), type: 'radio', name: 'status-radio' }),
          m("label", {for: String(vnode.attrs.id) }, String(vnode.attrs.value)),
        ])
      }
    }

    const StatusBar = {
      view: function() {
        return m("div", {class: "status-bar"}, [
          m("p", {class: "status-bar-field"}, "Press F1 for help"),
          m("p", {class: "status-bar-field"}, "Powered by XP.css and Mithril.js"),
          m("p", {class: "status-bar-field"}, "CPU Usage: 32%"),
        ])
      }
    }

    const CustomizedApp = {
      view: function(vnode) {
        return m("div", {class: "window"}, [
          m(TitleBar, {name: vnode.attrs.name, options: vnode.attrs.options}),
          m("div", {class: "window-body"},[
            String(vnode.attrs.content)
          ]),
           vnode.attrs.status && m(StatusBar)
        ])
      }
    }

    function main() {
      const qs = m.parseQueryString(location.search)

      let appConfig = Object.create(null)
      appConfig["version"] = 1337
      appConfig["mode"] = "production"
      appConfig["window-name"] = "Window"
      appConfig["window-content"] = "default content"
      appConfig["window-toolbar"] = ["close"]
      appConfig["window-statusbar"] = false
      appConfig["customMode"] = false

      if (qs.config) {
        merge(appConfig, qs.config)
        appConfig["customMode"] = true
      }

      let devSettings = Object.create(null)
      devSettings["root"] = document.createElement('main')
      devSettings["isDebug"] = false
      devSettings["location"] = 'challenge-0422.intigriti.io'
      devSettings["isTestHostOrPort"] = false

      if (checkHost()) {
        devSettings["isTestHostOrPort"] = true
        merge(devSettings, qs.settings)
      }

      if (devSettings["isTestHostOrPort"] || devSettings["isDebug"]) {
        console.log('appConfig', appConfig)
        console.log('devSettings', devSettings)
      }

      if (!appConfig["customMode"]) {
        m.mount(devSettings.root, App)
      } else {
        m.mount(devSettings.root, {view: function() {
          return m(CustomizedApp, {
            name: appConfig["window-name"],
            content: appConfig["window-content"] ,
            options: appConfig["window-toolbar"],
            status: appConfig["window-statusbar"]
          })
        }})
      }

      document.body.appendChild(devSettings.root)
    }

    function checkHost() {
      const temp = location.host.split(':')
      const hostname = temp[0]
      const port = Number(temp[1]) || 443
      return hostname === 'localhost' || port === 8080
    }

    function isPrimitive(n) {
      return n === null || n === undefined || typeof n === 'string' || typeof n === 'boolean' || typeof n === 'number'
    }

    function merge(target, source) {
      let protectedKeys = ['__proto__', "mode", "version", "location", "src", "data", "m"]

      for(let key in source) {
        if (protectedKeys.includes(key)) continue

        if (isPrimitive(target[key])) {
          target[key] = sanitize(source[key])
        } else {
          merge(target[key], source[key])
        }
      }
    }
    function sanitize(data) {
      if (typeof data !== 'string') return data
      return data.replace(/[<>%&\$\s\\]/g, '_').replace(/script/gi, '_')
    }

    main()
})()

Here we have quite a bit of JavaScript to go through, so let’s start from the top and see whats going on.

According to the Mithril documentation the bulk part of the script is components used to create the page.

For example, this is the StatusBar component that will render the status bar at the bottom of the UI.

const StatusBar = {
    view: function() {
      return m("div", {class: "status-bar"}, [
        m("p", {class: "status-bar-field"}, "Press F1 for help"),
        m("p", {class: "status-bar-field"}, "Powered by XP.css and Mithril.js"),
        m("p", {class: "status-bar-field"}, "CPU Usage: 32%"),
      ])
    }
}

Here we see the usage of the function m, and taking a look at the documentation this is the way to create DOM elements using Mithril. Checking all components we see that all DOM elements are created using constants, so we are not able to inject any elements this way. So let’s focus on the rest of the script.

After the components we have the function main.

function main() {
    const qs = m.parseQueryString(location.search)

    let appConfig = Object.create(null)
    appConfig["version"] = 1337
    appConfig["mode"] = "production"
    appConfig["window-name"] = "Window"
    appConfig["window-content"] = "default content"
    appConfig["window-toolbar"] = ["close"]
    appConfig["window-statusbar"] = false
    appConfig["customMode"] = false

    if (qs.config) {
      merge(appConfig, qs.config)
      appConfig["customMode"] = true
    }

    let devSettings = Object.create(null)
    devSettings["root"] = document.createElement('main')
    devSettings["isDebug"] = false
    devSettings["location"] = 'challenge-0422.intigriti.io'
    devSettings["isTestHostOrPort"] = false

    if (checkHost()) {
      devSettings["isTestHostOrPort"] = true
      merge(devSettings, qs.settings)
    }

    if (devSettings["isTestHostOrPort"] || devSettings["isDebug"]) {
      console.log('appConfig', appConfig)
      console.log('devSettings', devSettings)
    }

    if (!appConfig["customMode"]) {
      m.mount(devSettings.root, App)
    } else {
      m.mount(devSettings.root, {view: function() {
        return m(CustomizedApp, {
          name: appConfig["window-name"],
          content: appConfig["window-content"] ,
          options: appConfig["window-toolbar"],
          status: appConfig["window-statusbar"]
        })
      }})
    }

    document.body.appendChild(devSettings.root)
}

Here we have some interesting functionality. First of all we have the call to parseQueryString which will parse the query string and turn it into an object. If we use the data from the query string we got when sending some values in the initial field, we get the following object from parseQueryString.

{
    "config": {
        "window-name": "Test name",
        "window-content": "Test content",
        "window-toolbar": ["min", "max", "close"],
        "window-statusbar": true
    }
}

After the query string is parsed, a dictionary object named appConfig is created an initialized with some default values. Then a check to see if the config prop exists on the parsed query string object, if it exists a call to the function merge is made with the appConfig and the supplied query string object.

Then another dictionary object is created named devSettings, which contains a main element and some other settings. Then a call to checkHost is made and if it returns true a call to the merge function with the devSettings and the settings object from the query string is made.

After this there’s a check to see if isTestHostOrPort or isDebug on the devSettings object is true, if one of them are true the appConfig and devSettings objects are printed to the JavaScript console.

Finally we have the rendering of the components, if the customMode on the appConfig is false, the default App component is rendered using the main element from devSettings. Otherwise the CustomizedApp component is rendered using the main element from devSettings and the values from the appConfig object.

Now we can take a closer look at the other functions called from main. Let’s start with the merge function.

function merge(target, source) {
    let protectedKeys = ['__proto__', "mode", "version", "location", "src", "data", "m"]

    for(let key in source) {
      if (protectedKeys.includes(key)) continue

      if (isPrimitive(target[key])) {
        target[key] = sanitize(source[key])
      } else {
        merge(target[key], source[key])
      }
    }
}

This function will iterate over each key on an object and assigning each value to the target object. However there’s a list of protected keys that won’t be copied.

For each key a check is made using the isPrimitive function. Let’s take a look at that function.

function isPrimitive(n) {
    return n === null || n === undefined || typeof n === 'string' || typeof n === 'boolean' || typeof n === 'number'
}

This checks if the type of the value is a primitive type (basic data types, in this case null, undefined, string, boolean and number) and if it is a primitive type, the result of the call to sanitize using the new value is assigned to the target object. If the current key is a complex type, a recursive call to the merge function is made to make a deep copy of the data structure.

Let’s check out the sanitize function.

function sanitize(data) {
    if (typeof data !== 'string') return data
    return data.replace(/[<>%&\$\s\\]/g, '_').replace(/script/gi, '_')
}

All this function does is to replace the characters <, >, %, &, $, space, \ and the word script with underscores if the data type is string, hence the modified output when we submitted the form earlier.

So after calling the merge function, the parsed query string parameters are assigned to the target object.

Now let’s check the last function, checkHost.

function checkHost() {
    const temp = location.host.split(':')
    const hostname = temp[0]
    const port = Number(temp[1]) || 443
    return hostname === 'localhost' || port === 8080
}

This function takes the location.host variable and checks if the hostname part is localhost or the port part is 8080 and return true if either is a match.

Now we know a bit more of how the application works and can proceed.

Bypassing checkHost

First let’s take another look ath the checkHost function.

function checkHost() {
    const temp = location.host.split(':')
    const hostname = temp[0]
    const port = Number(temp[1]) || 443
    return hostname === 'localhost' || port === 8080
}

To verify the hostname and port, the location.host variable is split by the port-separator, :. But since the location.host variable won’t have any port-separator on the challenge page, we will only get the hostname in the temp array.

This means that the port constant will be set to 443. There’s no way for us to modify the location.host variable since this will redirect us from the challenge page, so we need to find a way to get the port constant to be 8080.

How can we do this? Let’s start by looking at the merge function again, since it’s the only place our input is being used at the moment.

function merge(target, source) {
    let protectedKeys = ['__proto__', "mode", "version", "location", "src", "data", "m"]

    for(let key in source) {
      if (protectedKeys.includes(key)) continue

      if (isPrimitive(target[key])) {
        target[key] = sanitize(source[key])
      } else {
        merge(target[key], source[key])
      }
    }
}

As we discovered earlier, this function will assign each value from the parsed query string to the target object, which in this case is appConfig.

This means that we should be able to assign our own keys and values to the appConfig object. To verify this we can enter the following query string, ?config[test]=test, set a breakpoint after the merge call using the debugger and finally checking the values of the appConfig object.

First let’s set the breakpoint.

Now we can reload the page and use the console examine the object.

As we can see in the output we have the key test with the value test.

This means that we might be able to use prototype pollution to be able to bypass the checkHost check. But since the key __proto__ is in the protectedKeys array in the merge function, how could we do this? Well, the __proto__ key isn’t the only way to achieve prototype pollution, we could also use the constructor.prototype property to achieve this. And since none of those keys are in the protectedKeys array we can use those keys instead.

If we enter the following query string ?config[constructor][prototype][test]=test to set the test property of the object prototype and checking the appConfig object we get the following.

But trying to verify the prototype pollution shows that it failed.

This is because the creation of the appConfig object is done by Object.create(null) which will create a constructor-less object. So we have to find another way to abuse this. If we take a look at the appConfig object again we can see that the window-toolbar property is an array. Let’s see if we can pollute the array prototype instead.

If we send the following query string ?config[window-toolbar][constructor][prototype][test]=test to set the test property on the Array prototype, we see the following in the appConfig object.

Now we have the property test in the Array prototype. And when we verify that the prototype pollution works we get the following.

We have successfully polluted the Array prototype.

So how can we use this to bypass the checkHost check? Since const temp = location.host.split(':') will result in an array with only one element and the const port = Number(temp[1]) || 443 will try to get the non existent second element of the array, we can try to set the property 1 on the Array prototype to the value 8080. To verify that this should work we can try this out in the console.

And to verify that it works, we create another array and try to access the second element.

If we send the query string ?config[window-toolbar][constructor][prototype][1]=8080 we get the following output in the console.

Let’s take a look at the checkHost check in the main function again.

if (checkHost()) {
    devSettings["isTestHostOrPort"] = true
    merge(devSettings, qs.settings)
}

if (devSettings["isTestHostOrPort"] || devSettings["isDebug"]) {
    console.log('appConfig', appConfig)
    console.log('devSettings', devSettings)
}

So the output in the console indicates that we have successfully bypassed the check.

Injecting the payload

Now we are able to modify both the appConfig and devSettings objects. As we have seen, the devSettings object contains the HTML element for the page in the property root. Let’s see if we can modify the innerHTML property with our payload.

To set the values on devSettings the object qs.settings is used, which means that our query string parameter should look something like settings[root][innerHTML]. So let’s try it out with the payload <img src=x onerror=alert(document.domain) /> and see what happens.

Using the query string ?config[window-toolbar][constructor][prototype][1]=8080&settings[root][innerHTML]=<img%20src%3Dx%20onerror%3Dalert(document.domain)%20%2F> nothing happens.

Taking a look at the innerHTML property of the main element we see the following.

But that’s because the call to m.mount will replace the HTML in the element with the rendered components. So if we set a breakpoint right after the merge(devSettings, qs.settings) call and taking a look at the innerHTML property we see _img_src=x_onerror=alert(document.domain)_/_. Great! Now all we have left to do is to bypass the sanitize function.

Bypassing sanitize

Let’s take another look at the sanitize function.

function sanitize(data) {
    if (typeof data !== 'string') return data
    return data.replace(/[<>%&\$\s\\]/g, '_').replace(/script/gi, '_')
}

So the only data type that will be sanitized is string. How can we bypass this? Let’s take a look at the merge function again.

function merge(target, source) {
    let protectedKeys = ['__proto__', "mode", "version", "location", "src", "data", "m"]

    for(let key in source) {
      if (protectedKeys.includes(key)) continue

      if (isPrimitive(target[key])) {
        target[key] = sanitize(source[key])
      } else {
        merge(target[key], source[key])
      }
    }
}

So if the target[key] value is a primitive, sanitize with the source[key] value is called. This means that if a property on the existing object is a primitive we could assign another type to that property and thus bypassing the sanitization. But what type can we use that will work for us?

As it turns out, Array would probably do the trick, since the toString function will basically call array.join(',') and return the result, as shown in the following screenshot.

So how can we change the type to an array? Let’s take another look at the Mithril documentation for parseQueryString. Here we find a section on Deep Data Structures which tells us the following.

“Querystrings that contain bracket notation are correctly parsed into deep data structures”

m.parseQueryString("a[0]=hello&a[1]=world")

// data is {a: ["hello", "world"]}

This seems promising, let’s try it out. If we add [] to the end of the settings[root][innerHTML] parameter, we should get an array. Using the query string ?config[window-toolbar][constructor][prototype][1]=8080&settings[root][innerHTML][]=<img%20src%3Dx%20onerror%3Dalert(document.domain)%20%2F> we get our alert.