Recently the Intigriti Twitter account posted a monthly challenge where the goal is to find a Cross-Site Scripting (XSS) vulnerability. These often aren't your average <script>alert(1)</script> payloads but instead involve a bunch of interesting tricks to barely squeeze out an alert. This month was no different with a super nice challenge by @kevin_mizu!

The Challenge

The main challenge page (https://challenge-0124.intigriti.io/challenge) contains a simple form to fill in a username, and then another small form to look for "repo". This is talking about GitHub Repositories, as we can search for "a" and get an AngularJS component for example:

This is the only functionality on the website, so we must take a close look at how this flow works. Luckily the sources are provided with the Get sources here button at the bottom of the page. From there we can download the server-side source code:

Tree

src/
├── app.js
├── package.json
├── repos.json
├── static/
|   ...
│   └── js/
│       ├── axios.min.js
│       └── jquery-3.7.1.min.js
└── views/
    ├── inc/
    │   └── header.ejs
    ├── index.ejs
    └── search.ejs

The code itself is pretty minimal. In app.js we find the / route as well as /search with the following logic:

JavaScript

const repos = require("./repos.json");
...

const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);

app.get("/", (req, res) => {
    if (!req.query.name) {
        res.render("index");
        return;
    }
    res.render("search", {
        name: DOMPurify.sanitize(req.query.name, { SANITIZE_DOM: false }),
        search: req.query.search
    });
});

app.post("/search", (req, res) => {
    name = req.body.q;
    repo = {};

    for (let item of repos.items) {
        if (item.full_name && item.full_name.includes(name)) {
            repo = item
            break;
        }
    }
    res.json(repo);
});

On the / page where we normally put in our name, we find that it expects the ?name= parameter and if it exists, returns the search.ejs page with a sanitized name value. I was initially a bit confused by this use of DOMPurify which is set up using JSDOM("").window above, but this is actually a very standard way of running it on the server-side as can be read in the DOMPurify README.md.
The /search endpoint itself does not look very interesting, as it simply searches the static ./repos.js file for a query string you provide, and always returns JSON. It is used in the search.ejs template from the index page to return repos the user searches for:

HTML

<h2>Hey <%- name %>,<br>Which repo are you looking for?</h2>

<form id="search">
    <input name="q" value="<%= search %>">
</form>

<hr>

<img src="/static/img/loading.gif" class="loading" width="50px" hidden><br>
<img class="avatar" width="35%">
<p id="description"></p>
<iframe id="homepage" hidden></iframe>

<script src="/static/js/axios.min.js"></script>
<script src="/static/js/jquery-3.7.1.min.js"></script>
<script>
    function search(name) {
        $("img.loading").attr("hidden", false);

        axios.post("/search", $("#search").get(0), {
            "headers": { "Content-Type": "application/json" }
        }).then((d) => {
            $("img.loading").attr("hidden", true);
            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
                });
            };
        });
    };

    window.onload = () => {
        const params = new URLSearchParams(location.search);
        if (params.get("search")) search();

        $("#search").submit((e) => {
            e.preventDefault();
            search();
        });
    };
</script>
</body>
</html>

Here too we find a new ?search= parameter that allows you to search automatically right when the page is loaded. This is good to know as it might allow us to automate the XSS later without requiring user interaction.

Both axios.min.js and jquery-3.7.1.min.js are loaded and while we don't know the Axios version directly, jQuery seems up to date. There is some interesting functionality with the template here though.

DOM Clobbering through DOMPurify

In the EJS docs we can find that the <%- syntax is special, and different from the <%= syntax as it is not HTML escaped. This means <%- name %> will insert unescaped HTML and allow the attacker to write arbitrary tags. Normally this would easily lead to a direct XSS by inserting any tag that executes JavaScript like <script>, but in this case, we are limited by the DOMPurify.sanitize(...) from earlier which sanitizes our input before putting it in the template. This process removes any ways of executing JavaScript and in the package.json file we can find that the DOMPurify and JSDOM versions are both up to date, so no bypasses here.

One interesting option, however, that is passed to the .sanitize() call is { SANITIZE_DOM: false }. Looking at the DOMPurify README.md again we find that this is used to "disable DOM Clobbering protection on output", very suspicious! Let's learn how DOM Clobbering works now that we know it might be possible.

PortSwigger has a concise explanation of how DOM Clobbering works, even with some labs to practice. The basics are that you can write special id= attributes on your HTML tags that will make the existing JavaScript code access your element. This might be due to uninitialized variables that can be overwritten like in PortSwigger's example, but it may also come from selectors where you can write your element matching them before the original element that should have been matched:

HTML

<!-- Injected -->
<p id="example">Injected 1</p>
<p id="example2">Injected 2</p>

<!-- Original -->
<p id="example2">Original</p>

<script>
  let example = window.example || "default";
  console.log(example); // "Injected 1"

  let example2 = document.getElementById("example2");
  console.log(example2); // "Injected 2"
</script>

We write pretty early on in the template document, so what can we potentially overwrite?

JavaScript

function search(name) {
    $("img.loading").attr("hidden", false);

    axios.post("/search", $("#search").get(0), {
        "headers": { "Content-Type": "application/json" }
    }).then((d) => {
        $("img.loading").attr("hidden", true);
        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
            });
        };
    });
};

In this search function from earlier a few elements are accessed by JavaScript using jQuery ($(...)), like img.loading, #search, img.avatar, and #description. Most of these values don't seem too sensitive if overwritten, but the $("#search").get(0) inside of axios.post() caught my eye. It is requesting that search endpoint by passing a <form> into the function, so maybe we can make it do interesting things if this isn't a form element as it expects? or if we give it different <input>'s?

Prototype Pollution in Axios

It would be useful to have the source code of this function to analyze it deeply, and luckily Axios is Open Source on GitHub. Therefore we can easily understand what the code should do if we have the right version of the library. The minified code (/static/js/axios.min.js) doesn't contain an obvious version number like jQuery (it does but I just missed it 😅), but we can compare it to other sources where the developer may have gotten it from.

cdn.jsdelivr.net is a common place to find these JavaScript libraries, so let's take a look at axios at its latest version:

https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js

Downloading both files locally, we can compare them at a byte level to find if there are any differences:

https://www.diffchecker.com/no9vPJgd/

There we find some small differences that confirm this is the right minified version, but just with some changes likely due to a lower version. Let's keep going down in versions until we find one that is equal, which we can do on cdn.jsdelivr.net by changing axios/ to [email protected]/ in the URL. After some iteration, we find a matching version at 1.6.2:

https://cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js

Now that we know it uses a slightly outdated version, we check the Axios Changelog to see if any recent vulnerabilities may help us in this scenario where we have control over one of the arguments.
Quickly the "security: fixed formToJSON prototype pollution vulnerability" in Axios < 1.6.3 jumps out as we have a <form> element that is transformed into JSON data to be sent to the search endpoint. Pull request #6167 has some more information about the bug, and shows what files were changed to mitigate it.

Diff

  function formDataToJSON(formData) {
    function buildPath(path, value, target, index) {
      let name = path[index++];
+ 
+     if (name === '__proto__') return true;
+ 
      const isNumericKey = Number.isFinite(+name);
      const isLast = index >= path.length;
      name = !name && utils.isArray(target) ? target.length : name;
...

+   it('should resist prototype pollution CVE', () => {
+     const formData = new FormData();
+ 
+     formData.append('foo[0]', '1');
+     formData.append('foo[1]', '2');
+     formData.append('__proto__.x', 'hack');
+     formData.append('constructor.prototype.y', 'value');

Cool! It added a check for the '__proto__' string as well as some tests that showcase how it could have been triggered. It seems that the name= attributes in form data are deserialized into an object which sets properties to the value= attribute. This is a textbook example of Prototype Pollution, where some special properties like .__proto__ or .prototype, when set on objects, can affect all other objects of that type instead of just that instance. By setting for example .__proto__.x = 1337, this x property will be set on all other objects as a fallback if it does not have an x attribute itself. For example:

JavaScript

let obj = {};
obj.__proto__.x = 1337;
// Prototype is now "polluted"
let newObj = {};
console.log(newObj.x);  // 1337

This vulnerability can be exploited by a common JavaScript pattern of checking if a property is set on an object, before using it. Using this we can make the application think the developer set some properties while they actually come from the prototype!

We'll make a quick proof-of-concept of this theory to confirm we can actually pollute the prototype. As mentioned before, we can use the clobbered $("#search") inside of the axios request to overwrite the expected form with one of our own. Now we just need to create form data with a key like __proto__.x and some value, which can be easily done with an input:

HTML

<form id="search">
    <input name="__proto__.x" value="1337">
</form>

As we had HTML Injection in the ?name= parameter, we get a URL like the following:
https://challenge-0124.intigriti.io/challenge?name=%3Cform%20id=%22search%22%3E%3Cinput%20name=%22__proto__.x%22%20value=%221337%22%3E%3C/form%3E&search=1

Then in the DevTools Console, we can run ({}).x which will create a new empty object, and its .x key returns '1337'!

Finding Gadgets: Request handler

Patterns to look out for now include any optional properties of objects, like checking if() something exists or having a || "default" value. It should be noted that we don't have much code left, as the prototype pollution only happens while making the Axios request. It may be possible to affect some Axios internals right after polluting the prototype within the same function call, or the .then() handler code as it runs after the request is completed. In our case, the handler already has such a pattern:

JavaScript

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

If the d.data is empty (eg. no search results), the non-existent .owner property will fall back to its prototype if set. This bypasses the "Not found!" message and allows us to continue further. Afterward, some more usages of properties on the repo variable aren't set because of the empty object but can be controlled using our prototype pollution. It sets an image source, and some text descriptions which don't seem too interesting, but the last part takes the $("#homepage") element which is an <iframe>, and sets the src= attribute on it. A little-known fact is that using "javascript:" protocol in an iframe's source, will trigger the JavaScript on the same origin causing XSS.

Not so fast though, because a .startsWith("https://") prevents this vector directly. There is no such check on the $("img.avatar") element from before, but an <img> tag does not have this dangerous behavior that the iframe has, and we won't be able to clobber it either because it always has to stay an image tag.

To make sure our ideas work, let's try to make it return an arbitrary result that we craft, using the prototype pollution. We need to set the .owner property to anything to bypass the check, and can try to set .homepage to iframe a regular page like https://example.com:

HTML

<form id="search">
    <input name="__proto__.owner" value="j0r1an">
    <input name="__proto__.homepage" value="https://example.com">
</form>

https://challenge-0124.intigriti.io/challenge?name=%3Cform%20id=%22search%22%3E%3Cinput%20name=%22__proto__.owner%22%20value=%22j0r1an%22%3E%3Cinput%20name=%22__proto__.homepage%22%20value=%22https://example.com%22%3E%3C/form%3E%3E&search=1

Awesome this works! In theory, this URL could point to our website to mess with the window.top property or something, but it seems hard to exploit further even with the new functionality. What more is there to explore?

Debugging minimized JavaScript libraries

This is the part that took me the longest to figure out. Eventually, I decided to just exhaust all my options with the Prototype Pollution gadgets, by following every single bit of code that happens after the pollution. From inside Axios, to the handler, and inside of jQuery calls. All to find any pollutable properties that may be useful in exploitation and get me closer to XSS.

If we directly set a breakpoint in some of the JavaScript code inside of DevTools, we can follow it but all code of Axios and jQuery will be heavily minimized to reduce filesize. This makes the functions very annoying to debug, even with GitHub source code on the side. Luckily, developers have also realized that minimizing assets makes them hard to debug, and Sourcemaps were invented. These can be loaded by the DevTools to transform minimized code back into source code, even into multiple files they were bundled from. That allows you to step through what would be minimized code while actually seeing the source code, right there on the real website.

Normally these can be found by appending ".map" to an asset path, like "/static/js/axios.min.js" to "/static/js/axios.min.js.map". Unfortunately, this URL is not found on the challenge site. Fortunately, we are looking at common libraries that can be found in other places together with their Sourcemaps. Our equivalent Axios file found on cdn.jsdelivr.net has a map available on its URL:

https://cdn.jsdelivr.net/npm/[email protected]/dist/axios.min.js.map

For jQuery, it takes a little more searching but on the Downloading jQuery page we can find a "map file" for jQuery 3.7.1:

https://code.jquery.com/jquery-3.7.1.min.map

Now that we have these URLs, we need to tell DevTools to load them because it doesn't find them by itself. Chrome can manually load sourcemaps by right-clicking code inside the Sources tab, where we can paste one of the URLs we found:

This should now quickly transform the minimized file into source code, and allow setting breakpoints. On the left side of the Page tab, you should see npm/[email protected]/lib under the CDN as well with all different source files in a folder structure. This makes it much easier to debug and follow calls into these libraries.

Note: After refreshing the page, these Sourcemaps are unloaded and you should right-click + paste the URL again, so keep them somewhere close

Breakpoints can now be set in the HTML <script> tags by opening the URL inside of the Sources tab. Keep in mind that changing the URL also clears the breakpoints in that path, so set them again or keep the URL the same. The workflow for debugging a minimized library is now:

  1. Visit the URL once
  2. In the Sources tab, find the current URL and set a breakpoint in the HTML JavaScript code
  3. Refresh the page
  4. When the breakpoint triggers, step into the library code you want to analyze
  5. Right-click and choose "Add source map..." with the URL to the corresponding .map file
  6. Now you should be debugging the library with all symbols and comments

Finding Gadgets: jQuery

With this workflow, we can follow calls into jQuery to see if it is possible to set any dangerous properties. My first idea was to look at the unprotected $("img.avatar").attr("src", ...) call and check if I can somehow make it target an <iframe> element somewhere on the page with the help of Prototype Pollution. That would allow us to set a "javascript:" URL and trigger XSS, so let's jump in by setting a breakpoint as explained above:

... I won't bore you with the details, but it turns out that by following the calls, $("img.avatar") unconditionally calls document.querySelector("img.avatar") which simply cannot return anything other than an image tag. This is a dead end.

There are more jQuery calls though, we also have the .attr() and .text() calls, which we can follow in the same way. Let's look at the $("#homepage").attr({...}) in detail:

JavaScript

attr: function( name, value ) {
  return access( this, jQuery.attr, name, value, arguments.length > 1 );
},

JavaScript

var access = function( elems, fn, key, value, chainable, emptyGet, raw ) {
  var i = 0,
    len = elems.length,
    bulk = key == null;

  // Sets many values
  if ( toType( key ) === "object" ) {
    chainable = true;
    for ( i in key ) {
      access( elems, fn, i, key[ i ], true, emptyGet, raw );
    }

  // Sets one value
  } else if ( value !== undefined ) {
    ...

    if ( fn ) {
      for ( ; i < len; i++ ) {
        fn(
          elems[ i ], key, raw ?
            value :
            value.call( elems[ i ], i, fn( elems[ i ], key ) )
        );
      }
    }
  }

The first call is simply a wrapper over the access() function, passing jQuery.attr as fn and the "name" and value as "key" and value. In this case, the first name argument is the object with attributes like "src" and "hidden", which is checked in the if ( toType( key ) === "object" ) { line.
This will return true for the object passed in, as many values will be set at once as opposed to only one. Using for ( i in key ) { they go through every key of the object, and set them individually as if they were passed on their own to the access() function.

One important thing to note here is that for ( i in key ) { doesn't just loop over the object's direct properties, but also the prototype's! It means our prototype pollution can influence this loop and add arbitrary keys and values to this iteration:

JavaScript

let obj = {};
obj.__proto__.x = 1337;

let newObj = {a: 1, b: 2};
for (i in newObj) {
  console.log(i);  // a, b, x
}

We can potentially add any attribute to the <iframe id=homepage> element now because later on it calls the jQuery.attr function on each key/value pair:

JavaScript

attr: function( elem, name, value ) {
  var ret, hooks, nType = elem.nodeType;

  // Don't get/set attributes on text, comment and attribute nodes
  if ( nType === 3 || nType === 8 || nType === 2 ) {
    return;
  }
  ...

  if ( value !== undefined ) {
    ...

    elem.setAttribute( name, value + "" );
    return value;
  }
...
},

The elem.setAttribute() is our sink and if we can set the src= attribute of this iframe to "javascript:alert(document.domain)", for example, it would load the URL and execute the JavaScript. We can try to pollute the src key with the technique from before, but we run into the problem that src: is already defined by the existing keys, and our fallback value won't be used.

We need to get a bit more clever. You might know that HTML attributes are case-insensitive, while JavaScript properties are case-sensitive. Using this knowledge, we don't need to pollute the src key specifically, it may just as well be SRC with different casing! JavaScript properties and the prototype will see it as a different value, so it won't be overwritten by the existing key.
This finding also means all other polluted attributes we make will become HTML attributes automatically, and some give errors on the elem.setAttribute() call. We just have to be careful to set the SRC pollution first before any erroneous ones:

HTML

<form id="search">
    <input name="__proto__.SRC" value="javascript:alert(document.domain)">
    <input name="__proto__.owner" value="j0r1an">
    <input name="__proto__.homepage" value="https://example.com">
</form>

And that already results in our final payload and URL:
https://challenge-0124.intigriti.io/challenge?name=%3Cform%20id=%22search%22%3E%3Cinput%20name=%22__proto__.SRC%22%20value=%22javascript:alert(document.domain)%22%3E%3Cinput%20name=%22__proto__.owner%22%20value=%22j0r1an%22%3E%3Cinput%20name=%22__proto__.homepage%22%20value=%22https://example.com%22%3E%3C/form%3E&search=1

Visiting the above URL triggers that beautiful alert() we all know and love.

Conclusion

From this nice challenge, we learned a few different things. First, you might've learned a bit about the EJS templating language and its escaping rules, and that the <%- syntax is insecure. By abusing the { SANITIZE_DOM: false } we were able to clobber the DOM to make javascript access a form under our control and found an outdated Axios library to get Prototype Pollution. From here we abused two different gadgets to first access some jQuery functions and then used manual Sourcemaps to find and exploit the final .attr() jQuery function to trigger XSS.