0124: Repo Woes
Writeup for the Intigriti January 2024 challenge 💥
Last updated
Writeup for the Intigriti January 2024 challenge 💥
Last updated
Name | Authors | Category |
---|---|---|
Find a way to execute arbitrary javascript on the iFramed page and win Intigriti swag.
The challenge provides source code adhering to the following structure.
Thankfully, we don't have too much code to analyse in the server-side app.js
file.
The app uses JSDOM and DOMPurify. We should check the versions used; perhaps there are some known vulnerabilities 🔎
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 documentation this parameter will:
disable DOM Clobbering protection on output (default is true, handle with care, minor XSS risks here)
OK, so DOM Clobbering 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
.
What makes it interesting, you ask? Firstly, the import of axios.min.js
- what is it 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:
If the URL contains a search
parameter, it's extracted, and the search function is triggered automatically
An event handler is attached to the #search
form (monitoring for future searches)
When the search()
function executes:
A POST request is made to the /search
endpoint (using the axios
library)
If the search query matches, the relevant repo data will be returned in JSON format
The img.avatar
source will be set to repo.owner.avatar_url
The description
text will be set to repo.description
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 releases shows a recent release (5 days ago, at the time of this challenge release) v1.6.4 that fixed two bugs, both security related.
After some initial research, we determine that the formToJSON prototype pollution vulnerability warrants further exploration (axios is submitting a form, then returning JSON). Let's return to this later when we've formulated our attack plan.
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 underline.
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.
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 payload 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? 🤔
We noted the formToJSON prototype pollution vulnerability in axios 1.6.2
earlier; let's investigate!
First, what is prototype pollution? Portswigger made an article (including a great video) 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:
A prototype pollution source - This is any input that enables you to poison prototype objects with arbitrary properties.
A sink - A JavaScript function or DOM element that enables arbitrary code execution.
An exploitable gadget - This is any property that is passed into a sink without proper filtering or sanitization.
OK, good to know! Next, we view changes and confirm the fix was a single line of code in the formDataToJSON
function.
Reviewing the entire code 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 payload (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 gadgets in jQuery (similar to the 06-23 challenge), but unfortunately, we won't find any documented gadgets that work in this case.
Since nobody found the intended path, the creator deployed a patched version of the challenge. 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 👇
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 payload.
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.
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 process 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.
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 sourceMaps, which negates the need to debug minified JS code. You can find several examples in Community Writeups but this one 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 arrays (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 it 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 🔎
So, how can we get XSS in an iframe? HackTricks suggests using src
or srcdoc
. The src
attribute is already assigned repo.homepage
(which must begin with https://
), so let's try srcdoc
🤞
We submit our URL-encoded payload
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 alert!
Let's also try the uppercase trick; change [srcdoc]
to [Srcdoc]
. It works too!
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 alternative 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.
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 not, we can use our trusty uppercase trick to ensure the existing key won't overwrite our injected prototype.
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.
One payload 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) 🥇
Finally, one payload 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!
Let's look at the 13 solutions that didn't pollute the owner
or homepage
; how did they get around it?
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 below achieved this with a slightly different syntax.
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.
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.
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 writeup, 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 caching.
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.
When Kevin released v2 of the challenge Johan 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 read. Furthermore, the writeup provides a superb breakdown of the gadget hunting process 🧠
We hope you enjoyed this writeup from CryptoCat! Make sure to check back in February for the valentine-themed challenge 💜
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)
DOM Clobbering, XSS, Prototype Pollution