0124: Repo Woes

Writeup for the Intigriti January 2024 challenge πŸ’₯

Name
Authors
Category

Video Walkthrough

DOM Clobbering, CSPP (axios) and XSS - Unintended Solutions to January '24 Challengearrow-up-right

Challenge Description

Find a way to execute arbitrary javascript on the iFramed page and win Intigriti swag.

Source Code Review

The challenge provides source code adhering to the following structure.

β”œβ”€β”€ docker-compose.yaml
β”œβ”€β”€ Dockerfile
└── src
    β”œβ”€β”€ app.js
    β”œβ”€β”€ package.json
    β”œβ”€β”€ repos.json
    β”œβ”€β”€ static
    β”‚Β Β  β”œβ”€β”€ css
    β”‚Β Β  β”‚Β Β  └── main.css
    β”‚Β Β  β”œβ”€β”€ img
    β”‚Β Β  β”‚Β Β  β”œβ”€β”€ loading.gif
    β”‚Β Β  β”‚Β Β  └── pattern.svg
    β”‚Β Β  └── js
    β”‚Β Β      β”œβ”€β”€ axios.min.js
    β”‚Β Β      └── jquery-3.7.1.min.js
    └── views
        β”œβ”€β”€ inc
        β”‚Β Β  └── header.ejs
        β”œβ”€β”€ index.ejs
        └── search.ejs

app.js

Thankfully, we don't have too much code to analyse in the server-side app.js file.

The app uses JSDOMarrow-up-right and DOMPurifyarrow-up-right. We should check the versions used; perhaps there are some known vulnerabilities πŸ”Ž

package.json

Probably not. The version numbers are all prefixed with a ^ meaning use this version or above. Therefore, any future updates that patch the vulnerable library would also break the challenge. If one of the libraries were intentionally vulnerable, we'd expect to see a fixed version number beside it.

Returning to app.js, there are two endpoints. The first is /, which accepts GET requests. If there's no name parameter, it will render index. Otherwise, it will render search.

Something that stands out here is that the DOMPurify sanitize function is only used on one of the two query parameters (name but not search). Furthermore, the option { SANITIZE_DOM: false } is supplied.

According to the documentationarrow-up-right this parameter will:

disable DOM Clobbering protection on output (default is true, handle with care, minor XSS risks here)

OK, so DOM Clobberingarrow-up-right has been explicitly allowed for this parameter πŸ““βœ (update: the creator pointed out that disabling this option specifically allows us to overwrite document and HTMLFormElement attributes).

The second endpoint is /search, which accepts POST requests. It takes a q parameter that becomes name and refers to a "repo name". The function will essentially search a list of repos for a name that matches. If it finds one, it will return the result as a JSON object.

The repos.json file contains 30 JSON objects split across ~3000 lines. Each object represents a repository and has many properties defining many properties about the repo and its owner.

The last potentially interesting file is the client-side search.ejs.

search.ejs

What makes it interesting, you ask? Firstly, the import of axios.min.js - what is itarrow-up-right and why is it included here? Secondly, this is where most of our search functionality is located! Where there's functionality, there's bugs.. maybe? 🧐

Let's break down the code. When the page loads:

  1. If the URL contains a search parameter, it's extracted, and the search function is triggered automatically

  2. An event handler is attached to the #search form (monitoring for future searches)

When the search() function executes:

  1. A POST request is made to the /search endpoint (using the axios library)

  2. If the search query matches, the relevant repo data will be returned in JSON format

  3. The img.avatar source will be set to repo.owner.avatar_url

  4. The description text will be set to repo.description

  5. If the repo homepage starts with https:// the homepage source will be set to repo.homepage and hidden will be false

Now, back to the axios.min.js. We want to check for any known vulnerabilities, but because the JS is minified, there's no mention of the version anywhere in the project.

Therefore, I opted to load the challenge page, open devtools (F12), switch to the debugger and search for "version" in the minified axios file.

There are two references, the second of which stands out.

I set a breakpoint here (line 2277) and refresh the page. The breakpoint triggers and reveals the version 1.6.2.

axios releasesarrow-up-right shows a recent release (5 days ago, at the time of this challenge release) v1.6.4arrow-up-right that fixed two bugs, both security related.

After some initial research, we determine that the formToJSON prototype pollution vulnerabilityarrow-up-right warrants further exploration (axios is submitting a form, then returning JSON). Let's return to this later when we've formulated our attack plan.

HTML Injection

Now we know what's going on in the code, let's visualise the site functionality.

Visiting https://challenge-0124.intigriti.io/challenge?name=cat presents the search page with the message Hey cat, which repo are you looking for?

cat is already in bold, so let's test for HTML injection by adding an underlinearrow-up-right.

It works! We have HTML injection βœ…

Now, If I type cat in the repo search box, an alert pops up: Not found!

If I type a name from the repos.json file, e.g. facebook (or even fb), it displays the repo image and loads an iframe containing the repo information.

DOM Clobbering

We have HTML injection, but what about the "DOM clobbering risk" mentioned in the DOMPurify docs? What is DOM clobbering anyway?

a technique in which you inject HTML into a page to manipulate the Document Object Model (DOM) and ultimately change the behaviour of JavaScript on the page

Let's return to search.ejs and focus on this line of code momentarily.

We can ask chatGPT for a breakdown πŸ‘€

  • axios.post("/search", $("#search").get(0), { ... }):

    • axios.post: Initiates a POST request using the Axios library.

    • "/search": The URL or endpoint to which the POST request is sent.

    • $("#search").get(0): The data payload of the request. In this case, it takes the form element with the id 'search' and gets its first element (equivalent to the native JavaScript document.getElementById('search')). This is usually done to serialize the form data for submission.

    • { "headers": { "Content-Type": "application/json" } }: An optional configuration object that includes headers for the request. In this case, it sets the "Content-Type" header to "application/json", indicating that the payload being sent is in JSON format.

So $("#search").get(0) is taking the form element with the id 'search' and getting its first element? It sounds like a nice target for clobbering! If we can inject our own search form before the existing form on the page, it will be processed in the axios request instead of the intended one πŸ’‘

Providing we remember to URL-encode the payloadarrow-up-right here, it will work! The page loads with two search forms: our injected one and then the original, intended one.

We can verify the new behaviour via the console.

jQuery returns our injected form.

Now, what can we do with our clobbered search form? πŸ€”

Client-side Prototype Pollution (CSPP)

We noted the formToJSON prototype pollution vulnerabilityarrow-up-right in axios 1.6.2 earlier; let's investigate!

First, what is prototype pollution? Portswigger made an articlearrow-up-right (including a great videoarrow-up-right) on client-side prototype pollution (CSSP) but in short:

Prototype pollution is a vulnerability that occurs when you merge an object with a user-controlled JSON object. It can also occur as a result of an object generated from query/hash parameters, when the merge operation does not sanitize the keys.

Successful exploitation of prototype pollution requires the following key components:

OK, good to know! Next, we view changesarrow-up-right and confirm the fix was a single line of code in the formDataToJSON function.

Reviewing the entire codearrow-up-right for the function it's clear that the fix will prevent any paths with the name __proto__ from being processed by returning true as soon as they are found.

The developers also updated their test cases, giving us greater insight into how the attack would look.

We know from search.ejs that axios.post() will send a POST request to /search with an "application/json" header, then return the response in JSON. Therefore, if our input is processed by the FormDataToJSON() function, we could potentially exploit the CSPP in the name field.

Let's test our theory with a benign payloadarrow-up-right (note, you can also use the dot syntax, e.g. __proto__.cat).

Now, if we type Object.prototype.cat, Object.cat or simply cat in the developer tools console, it will display is the best. This is because the prototype has been polluted, so all objects will inherit our injected property 😈

From here, we might look for useful script gadgetsarrow-up-right in jQuery (similar to the 06-23 challengearrow-up-right), but unfortunately, we won't find any documented gadgets that work in this case.

Unintended Solutions

Since nobody found the intended path, the creator deployed a patched version of the challengearrow-up-right. At the end, we'll discuss the author's intended solution later and link another unintended solution that also worked on the patched version 🀯 First, lets evaluate at the 37 unintended solutions we received for the original challenge.

The unintended solutions are possible due to the following code snippet in search.ejs (removed in the patched version).

To exploit this vulnerable code, we must satisfy some conditions πŸ‘‡

Polluting the repo owner

Here's the first one. If the repo.owner isn't set, the function will return, and we can never reach the vulnerable code.

Therefore, we pollute the prototype to include an owner property. Since repo is an object, it will inherit the property making repo.owner == cat. Remember you might need to URL encode the payloadarrow-up-right.

If you want to visualise this process, set a breakpoint at the if statement. When the execution pauses, swap to the console, enterrepo.owner and confirm that the value is cat, as expected.

Polluting the repo homepage

Here's the next condition. Set up another breakpoint and refresh the page; you should see that repo.homepage is undefined. If we want the code inside this if statement to execute, we must set a homepage and ensure it begins with https://.

We already saw we can pollute the repo.owner, so let's repeat the processarrow-up-right for repo.homepage.

It works! When the breakpoint triggers, we check the console and see that repo.homepage is set to https://crypto.cat (yes, I wish I owned this domain πŸ˜’).

The src of the homepage element is now set to https://crypto.cat. Scrolling up to the top of search.ejs, we can confirm that homepage refers to a hidden iframe.

If only we could set repo.homepage to javascript:alert(document.domain), we would be finished already. Unfortunately, there doesn't appear to be any way to get around the repo.homepage.startsWith("https://") condition.

jQuery exception

Additionally, our new payload triggers the following exception in jQuery.

Interestingly, it's complaining about cat, which was the value of owner. However, it only does so when the homepage is also set, indicating that the code which sets the iframe attributes is to blame.

Many players studied jQuery to understand the underlying cause of this error, a process made significantly easier through the use of sourceMapsarrow-up-right, which negates the need to debug minified JS code. You can find several examples in Community Writeups but this onearrow-up-right is nice πŸ‘Œ

TLDR; the loop inside attr will crash when trying to process strings. That includes our owner and homepage prototypes. In fact, homepage must be a string in order to meet this requirement.

We can verify this by changing the prototypes to arraysarrow-up-right (for (i in key) is valid for an array).

Now we get a new error because homepage needs to be a string 😺

Actually, there's another way to get around this error. If there are any uppercase characters in the attribute name, it will be converted using toLowerCase, which will change the execution flow in such a way that jQuery will skip the i in [] check will. We test itarrow-up-right and confirm there are no errors!

Of the 37 submissions, 24 polluted the owner and homepage. Soon, we'll see how the remaining 13 solutions bypassed these requirements (without abusing the jQuery caching, as intended).

For the solutions that did set the owner and homepage, it's around this point that they begin to diverge πŸ”Ž

XSS

So, how can we get XSS in an iframe? HackTricksarrow-up-right suggests using src or srcdoc. The src attribute is already assigned repo.homepage (which must begin with https://), so let's try srcdoc 🀞

srcdoc

We submit our URL-encoded payloadarrow-up-right

We don't get an alert 😿 But wait, what about that array trick we saw earlier? Let's change [srcdoc] to [srcdoc][]. Now we get an alertarrow-up-right!

Let's also try the uppercase trick; change [srcdoc] to [Srcdoc]. It works tooarrow-up-right!

The order is important here. srcdoc must be specified in the URL before the owner and homepage. A community member suggested the reasoning behind this - since owner and homepage are strings, they would fail somewhere in jQuery, halting execution. So long as srcdoc is processed first, it doesn't matter if the remaining properties fail (we only need them at the beginning of our attack to reach the vulnerable code).

A popular alternativearrow-up-right to the array payload was to pollute the srcdoc twice. The underlying logic is the same, i.e. declaring srcdoc twice creates a srcdoc[] array containing both values.

src

Earlier, we said it would be great if we could just set the iframe src to javascript:alert(document.domain). Well, we can't because the existing keys already define it. However, since HTML attributes are case-insensitive and JS are notarrow-up-right, we can use our trusty uppercasearrow-up-right trick to ensure the existing key won't overwrite our injected prototype.

onload

A fairly straightforward alternative - you can pollute onload to set some JS to execute when the page loads! You'll still need to meet the previous conditions (pollute owner + homepage and use uppercase/array), e.g.arrow-up-right

ontransitionend

One payloadarrow-up-right created and <svg> element with a transition style, then polluted ontransitionend with our XSS payload.

This one gets bonus points for being incredibly annoying (alert pops recurrently) πŸ₯‡

onerror

Finally, one payloadarrow-up-right polluted onerror with the XSS payload. To throw an error, it pollutes href with an invalid value, e.g. in the example below, it will try [and fail] to load https://challenge-0124.intigriti.io/x

These last two payloads negated the need for an array/uppercase property because the polluted values weren't already defined!

Unintended - without polluting owner/homepage

Let's look at the 13 solutions that didn't pollute the owner or homepage; how did they get around it?

Clobbered 'q'

when q is set, it gets a real result from the server that satisfies the owner and homepage checks, so there's no need to pollute these values. Note that we still need to use the array/uppercase trick. The example belowarrow-up-right achieved this with a slightly different syntax.

baseURL (attacker domain)

The baseURL can be polluted to a URL owned by the attacker, ensuring the POST /search will be directed there instead.

The attacker.domain should deliver a JSON object representing a repository. This example can be found in the repos.json file.

Player Submission Analysis

Here's a table that breaks down all the [unintended] payloads we received. You can mix and match, i.e. pick one row from each column, to construct your attack πŸ€“ The values in brackets represent the number of players that utilised that technique.

Initial approach
CSPP gadget
jQuery attr bypass

Pollute 'owner' and 'homepage' (24)

srcdoc (21)

array (18)

Pollute 'baseURL' (3)

src (1)

uppercase (16)

Clobber 'q' (10)

onload (13)

other (3)

ontransitionend (1)

onerror (1)

Community Writeups

Intended Solution (patched challenge)

As mentioned earlier, the creator deployed a patched version of the challenge for a week after the event to give players the time to find the intended solution. I won't document it in detail here because this post is already quite long. Besides, I couldn't explain it better than Kevin, so why duplicate the effort πŸ€·β€β™‚οΈ I would therefore encourage you to check out the creators official writeuparrow-up-right, but here's a quick TLDR;

The intended solution polluted the baseURL, a technique we observed in some earlier payloads. One slight difference is that the unintended solutions used baseURL to deliver a repo JSON object from an attacker domain. In contrast, the official solution sets the value to data:,{}#, allowing the response data to be controlled directly. The main difference in the approach, however, is the CSPP gadget used; the expectation was to abuse jQuery selector cachingarrow-up-right.

To achieve this, it is first necessary to clobber document.namespaceURI so that jQuery thinks the document is non-HTML and falls back to our targeted select function. Next, the selector is polluted with a previously cached img.loading selector (to avoid crashes) and relative selectors are polluted to apply some custom rules, ensuring the selector will match everything. Finally, we set the src attribute to our XSS payload, which will apply to each DOM element, including the iframe. Here's the constructed payload for visualisation.

Unintended Solution (patched challenge)

When Kevin released v2 of the challenge Johanarrow-up-right mentioned he had found an unintended solution that also worked on the patched version! The approach is a little complicated to summarise here, so I would encourage you to give it a readarrow-up-right. Furthermore, the writeup provides a superb breakdown of the gadget hunting process 🧠

Conclusion

We hope you enjoyed this writeup from CryptoCatarrow-up-right! Make sure to check back in February for the valentine-themed challengearrow-up-right πŸ’œ

Last updated