Sanity
Writeup for Sanity (Web) - Amateurs CTF (2023) 💜
Video Walkthrough
Description
check out this pastebin! its a great way to store pieces of your sanity between ctfs.
Recon
We visit the challenge URL: https://sanity.amt.rs and find two textboxes (title
and paste
).
Trying some basic XSS payloads is fruitless but the source is included, so let's inspect it for vulnerabilities!
index.js
sanes.ejs
Attack Plan
Let's breakdown our attack plan, in reverse order.
Our ultimate goal is to steal the admin's cookie 🍪
We can see from the code in index.js
, they will access the challenge domain and set a cookie containing the flag. Next, they will visit our note.
So how can we trigger the XSS? If we look at line 50 in sane.ejs
, we'll see that a sanitizer
will sanitize our payload unless debug.sanitize
is false.
This brings us onto the next problem; sanitize
is set to true
by default in the class declaration on line 20.
The [constant] debug
object is instantiated on line 38, where it is also set to true
.
According to this line of code, if extension
is defined (not null) then a new debug
object will be created which has properties from both the Debug(true)
instance and the extension
object.
If extension
is null then the object will instead have the properties from Debug(true)
and an additional property: report: true
.
So, if we could control extension
, we could potentially use Prototype Pollution to pollute the prototype of all objects to contain a property: sanitize: false
.
We look through the code to find where extension is assigned, there's a function at line 31.
Extension is set to null! It then checks for window.debug.extension
and if exists, makes a HTTP request to that URL and sets extension to contain the response, which is expected to be JSON data.
If we try and submit some benign text and check with devtools (F12 🚔) , the console shows the following.
This brings us on to our final (technically first) vulnerability; DOM Clobbering
DOM clobbering is a technique in which you inject HTML into a page to manipulate the DOM and ultimately change the behaviour of JavaScript on the page. DOM clobbering is particularly useful in cases where XSS is not possible, but you can control some HTML on a page where the attributes
id
orname
are whitelisted by the HTML filter. The most common form of DOM clobbering uses an anchor element to overwrite a global variable, which is then used by the application in an unsafe way, such as generating a dynamic script URL.
We have a script on line 15 (right after the sanitizer instantiation)
It's calling setHTML
on our input, so we can inject HTML. There is a sanitizer active (with default configuration) but it only "strips out XSS-relevant input by default"
so the sanitized setHTML leaves us a lot of room to accomplish our goal.
More resources on DOM Clobbering here:
Solution
We've established the attack plan:
CLOBBER
: we need to clobber the DOM so thatwindow.debug.extension
contains a URL.POLLUTE
: the URL should deliver a JSON object, which pollutes__proto__
withsanitizer: false
.INJECT
: withdebug.sanitize
, we can inject an XSS payload into thepaste
field. It will be injected into the page HTML, without sanitization.
Once we've successfully chained these vulns, we can send the report to the admin and wait for our cookie 🚩
Browser issue detour
I got stuck for a little while trying to put this attack together, until I remembered the recent Intigriti challenge that used Sanitizer()
. In this challenge, players were advised to test on Google Chrome, whereas I am using Firefox 🦊
I thought; no worries, just like in that video writeup, we can manually enable Sanitizer by going to about:config
and setting dom.security.sanitizer.enabled
to true
.
Once that was enabled, HTML injection worked! Submitting the following payload as the title
and paste
values, produced bold output 🔥
Unfortunately, I got stuck again at the next step and switching to Chrome solved it (you might want to do the same, if you're having issues).
Part 1: DOM Clobbering
We review some of the earlier documentation, or use this awesome DOMC Payload Generator
We can try the various payloads and each time check the value of window.debug.extension
in the console.
Perfect! So we know that
will actually be
Side note: The official solution from the challenge author used a different technique. By specifying the data
value, they were able to assign the desired JSON object directly.
If, like me, you were using a SimpleHTTPServer
with python, exposed via ngrok
then you'll notice you didn't receive a request.
Checking the console again, you'll see why.
Access to fetch at
https://ATTACKER_SERVER/
from originhttps://sanity.amt.rs
has been blocked byCORS
policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
There's various ways we can add this header, e.g. launching ngrok
with --request-header-add "Access-Control-Allow-Origin: *"
.
In this case, I used a nodejs
app (exposed via ngrok
).
Now, when we refresh the page, our server gets a hit! However, when we check the report, there is no longer a report link 🤔
If we review the code again, the missing report link situation should be quite obvious.
So, if extension exists, the debug
object will be assigned it's properties (else, report: true
). That has happened, but the JSON object sent by our server is currently {}
.
Therefore, the properties of debug
simply mirror the Debug
class, the debug.report
condition returns false and the report link is never created.
Since we control the properties of extension, let's change the line in our server code to the following.
Now, when we restart the server, our report link is generated! We add a simple XSS to the paste field (<script>
won't work with innerHTML
though, these tags only load when the page loads).
The alert doesn't trigger, so let's move on to the next stage!
Part 2: Prototype Pollution
The idea here is to pollute __proto__
with the desired key:value
pairs so that every object will inherit them. Change the line to the following.
When I check the debugger again, sanitize
is still not false. I really thought this worked for me yesterday, using the same payloads 🤷♂️
Regardless, the alert is popped! Since sanitize
doesn't seem to matter, let's try without it.
Yep, it works! I tried a few variations to disable the sanitizer but didn't get there.
I check with ChatGPT 🤓
The # before sanitize signifies that sanitize is a private class field, a feature introduced in JavaScript with the ECMAScript 2019 (ES10) specification. Private class fields are denoted by the # symbol followed by the field name, and they are only accessible within the class in which they are defined
private class fields are not accessible outside the class where they are defined. They are not part of the prototype chain and cannot be modified or accessed from external code. This means that the
#sanitize
field in theDebug
class is not susceptible to prototype pollution.
OK, I guess I can't change it? 🤔 But.. if debug.sanitize
is always true, why did the challenge dev add an if statement here at all? Guess I'll have to check smarter peoples writeups 😁
edit: After speaking with the challenge creator and playing around with the challenge again, it turns out any form of prototype pollution is enough, e.g. sending an empty object will work just fine. Presumably this overwrites the existing object, making sanitize
undefined so that the if condition doesn't trigger 🤷♂️
Part 3: XSS
You can probably find many payloads to extract the flag, but I went with this one.
Upon loading, the page will try to display the image x
. Since that's not a valid img src
, it will trigger an error and execute some JS to redirect the victim to the attacker server (us) with their cookie attached as a GET parameter.
If we submit the report to the admin and check our ngrok
output (make sure to do this on the web UI), we'll find our flag 🔥
Flag: amateursCTF{s@nit1zer_ap1_pr3tty_go0d_but_not_p3rf3ct}
Side note: I actually forgot to submit the flag, so our team did not solve the challenge 🙃
Last updated