Open-redirect to XSS

Open redirects are generally treated as a low risk issue, due to the limited impact (more convincing phishing). However in certain cases a simple open redirect vulnerability can lead to reflected XSS, which I’ll talk about in this post.

Redirecting in a browser can happen in two ways:

  1. The browser gets a 30x HTTP response code (e.g. 302 Found) with the destination of the redirect in the Location header
  2. The JavaScript running on a site does the redirect by e.g. window.location.href='https://example.com' or window.location.assign('https://example.com'); or window.location.replace('https://example.com');

If an open redirect vulnerability exist with the second type of redirect, it might be an XSS as well using the javascript: pseudo-protocol. E.g. the following JavaScript code will pop up an alert:

url = "javascript:alert(document.domain)"; // coming from the user in real life
window.location.href= url;

Demo

Another great thing about this is that no redirect happens, so the injected JavaScript executes in the context of the current page (see document.domain from the alert), so it has access to the site the same way a normal XSS has.

Catch 1: Redirect doesn’t happen immediately

Consider the following JavaScript code:

url = ""; // coming from the user

if(!url.startsWith("https://example.com")) {
	window.location.href = "https://example.com";
}

window.location.href= url;

If the url doesn’t start with https://example.com, then we redirect to the main page; otherwise redirect to the url. However the redirect only happens after the JavaScript finished running, so the same attack still works: Demo.

Catch 2: Bypassing hostname checks

Sometimes to prevent open redirect the app checks if the URL points to a trusted hostname. Considering the numerous filter bypass techniques the general recommendation is to use a URL parser and check directly for the hostname, instead of trying to do some regex matching.

For example consider the following php code:

<?php

$url = $_GET["u"];

if(parse_url($url, PHP_URL_HOST) === "example.com" ) {
    echo "<script>window.location.href = '" . htmlspecialchars($url, ENT_QUOTES) . "';</script>";
} else {
    echo "<script>window.location.href = 'https://example.com';</script>";
}

Demo.

This takes the URL from a GET parameter, parses it using the built-in parse_url() function and redirects the user to it if the hostname is example.com. Otherwise the user is sent to the hardcoded main page.

So on first look this doesn’t even look like an open redirect. However parse_url() accepts anything for protocol, as long as the string looks like a URL. So javascript://example.com/path will be parsed into the hostname of example.com. However when this is injected, everything after javascript: is interpreted as JavaScript, namely: //example.com/path, which is entirely a comment in JavaScript. Fortunately we can inject a URL encoded new-line (%0a) to end the comment and add arbitrary JavaScript code:

javascript://example.com/path%0aalert(document.domain)

This affects other URL parsers, for example Golang (based on gobyexample.com):

package main

import (
	"fmt"
	"net/url"
)

func main() {
	s := "javascript://example.com/path%0aalert(document.domain)"
	u, err := url.Parse(s)
	if err != nil {
		panic(err)
	}
	fmt.Println(u.Host)
}

Demo

Or Java (based on this thread):

import java.net.URI;

public class Main
{
	public static void main(String[] args) {
	    try {
    	    URI uri = new URI("javascript://example.com/path%0aalert(document.domain)");
	    	System.out.println(uri.getHost());
	    } catch(Exception e) {
	        System.out.println(e);
	    }
	}
}

Demo