This post was initially posted at NUS Greyhats.

Overview

This post will cover some details behind the recent Grafana vulnerability (CVE-2021-43798), which is a directory traversal bug allowing unauthenticated attackers to read files on the target server filesystem. This post will also discuss some real world scenario and attack surface of the Grafana.

Brief Analysis on the Root Cause

The detailed analysis can be found at the author’s blog here, I will only briefly cover it.

All API routes were defined in pkg/api/api.go , some require authentication like below:

1
2
3
4
r.Get("/plugins", reqSignedIn, hs.Index)
r.Get("/plugins/:id/", reqSignedIn, hs.Index)
r.Get("/plugins/:id/edit", reqSignedIn, hs.Index) // deprecated
r.Get("/plugins/:id/page/:page", reqSignedIn, hs.Index)

While some does not require signed in, like below:

1
2
// expose plugin file system assets
r.Get("/public/plugins/:pluginId/*", hs.getPluginAssets)

For the route at /public/plugins/:pluginId/*, it is handled by hs.getPluginAssets, which is defined in pkg/api/plugins.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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)

the rest are Omitted

Line 5 is retrieving /public/plugins/(.*) as the pluginId, then pass to line 12 filepath.Clean to do sanitization, and concatenate in line 13, finally passed to os.Open in line 23 to read the contents.

The most interesting part is the comment at line 20:

// It’s safe to ignore gosec warning G304 since we already clean the requested file path and subsequently

If we check the document on the usage of filepath.Clean:

image-20211218092233145

Point 4 is worthy to take note.

replace “/..” by “/“ at the beginning of a path

What if the path does not start with /..? we can try it out:

image-20211218092157371

It seems that filepath.Clean is not working as what the developers expect it to do, which leads to directory traversal and subsequently arbitrary file read.

It can be replicated in the docker environment as shown below (take note that the plugin should exist, otherwise you will get Plugin not found error. Luckily, Grafana has come with some default plugins. In the screenshot below, I am using Grafana’s welcome plugin):

image-20211218092120386

Nginx Reverse Proxy Bypass

On the day this vulnerability is getting hot amongst the security researchers (around 7th Dec, 2021), the vulnerability author posted a tweet as below:

image-20211218001042889

But one day later, he retweeted:

image-20211217234000126

So does Nginx help? we can set up the environment and try:

image-20211218092029240

We are getting a 400 bad request.

The reason is simple: Nginx will do path normalization before it forwards the request to the backend. If the normalized URI is requesting beyond the web root directory, it will simply returns 400 bad request.

In the request above, /public/plugins/welcome/../../../../../../../../../../etc/passwd will be normalized into /../../../../../../../etc/passwd, hence, a 400 bad request is returned.

But does that mean Nginx will protect Grafana against this kind of path traversal attack? May not be. It depends on how you configure the proxy_pass entry. Before I cover that, here is an example:

image-20211218091931664

We can see that our path traversal still succeed and read the content of the sdk.ts, which is located two directories above the welcome plugin directory.

So here is first point, which is covered in the Nginx document

If proxy_pass is specified without a URI, the request URI is passed to the server in the same form as sent by a client when the original request is processed

Scroll back and examine my Nginx configuration, noticed that my proxy_pass entry is defined as http://localhost:3000, without a URI, hence, the original request will be forwarded to the Grafana backend. And in the first place, since my original URI is /public/plugins/welcome/../../sdk.ts, even after normalization by Nginx, it is /public/sdk.ts, which is a valid URI, hence, Nginx will not complain about it either.

This allows us to read arbitrary files up to three directory above the plugin directories. But the default plugin directories is deep at /usr/share/grafana/public/app/plugins/{plugin_id}, even being able to traverse up by 3 directories, there aren’t many files to read.

So here is the second point, which is covered in this post

URL consists of scheme:[//authority]path[?query][#fragment], and browsers don’t send #fragment. But how must a reverse proxy handle #fragment?

Nginx throws fragment off

So what would happen if my URI is /public/plugins/welcome/#/../../../../../../../../../etc/passwd?

Nginx will process until /public/plugins/welcome/, and forward the entire URI to the Grafana backend, and leads to path traversal all the way up to the root directory:

image-20211218000920958

Attack Surface under Grafana

In this section, we try to examine the possible attack surface under Grafana when we are able to read files on the file system.

Grafana Database

We can try to read the database file, which is located at /var/lib/grafana/grafana.db by default, which is a sqlite database:

image-20211218101302363

So, what is inside the Grafana database?

user table:

image-20211218110015309

The password is hard to decrypt, using a slow hash algorithm with salt as defined in pkg/util/encoding.go:

1
2
3
4
5
// EncodePassword encodes a password using PBKDF2.
func EncodePassword(password string, salt string) (string, error) {
newPasswd := pbkdf2.Key([]byte(password), []byte(salt), 10000, 50, sha256.New)
return hex.EncodeToString(newPasswd), nil
}

user_auth_token:

It seems that user_auth_token is also stored as one-way hash, as defined in /pkg/services/auth/auth_token.go:

1
2
3
4
func hashToken(token string) string {
hashBytes := sha256.Sum256([]byte(token + setting.SecretKey))
return hex.EncodeToString(hashBytes[:])
}

I can’t think of a way to exploit this, if possible, please tell me =)

data_source:

image-20211218112800825

The data_source tells Grafana where to pull the data from.

Finally there is something that we can exploit. In /pkg/cmd/grafana-cli/commands/datamigrations/encrypt_datasource_passwords_test.go:

1
2
3
4
5
6
7
8
9
10
11
12
func DecryptSecureJsonData(ds *models.DataSource) (map[string]string, error) {
decrypted := make(map[string]string)
for key, data := range ds.SecureJsonData {
decryptedData, err := util.Decrypt(data, setting.SecretKey)
if err != nil {
return nil, err
}

decrypted[key] = string(decryptedData)
}
return decrypted, nil
}

util.Decrypt is defined in /pkg/util/encryption.go, you can re-use it to decrypt the encrypted password in the data source, or you can use the script here:

image-20211218113745095

The secretkey is defined in the Grafana’s configuration file, which is located at /etc/grafana/grafana.ini, and it might contains other sensitive information as well. We will cover those next

Grafana Configuration File

Located at /etc/grafana/grafana.ini by default, which might contain several sensitive information. You can take a look at the default configuration file here and see what can be stored inside. I won’t go through them one by one.

grafana-image-renderer

Apart from the various credentials that could be leaked from the Grafana’s configuration file, another worth-mentioning entry is the grafana-image-renderer

1
2
3
4
5
6
7
8
9
[rendering]
# Options to configure a remote HTTP image rendering service, e.g. using https://github.com/grafana/grafana-image-renderer.
# URL to a remote HTTP image renderer service, e.g. http://localhost:8081/render, will enable Grafana to render panels and dashboards to PNG-images using HTTP requests to an external service.
server_url =
# If the remote HTTP image renderer service runs on a different server than the Grafana server you may have to configure this to a URL where Grafana is reachable, e.g. http://grafana.domain/.
callback_url =
# Concurrent render request limit affects when the /render HTTP endpoint is used. Rendering many images at the same time can overload the server,
# which this setting can help protect against by only allowing a certain amount of concurrent requests.
concurrent_render_request_limit = 30

grafana-image-renderer is a remote HTTP image rendering service, which you can ask the renderer to visit the Grafana panel, render into image and send to us.

The official guideline is to set up the grafana-image-renderer service inside a separate docker, and link it to the Grafana docker using Docker Compose like below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
version: '2'

services:
grafana:
image: grafana/grafana:latest
ports:
- '3000:3000'
environment:
GF_RENDERING_SERVER_URL: http://renderer:8081/render
GF_RENDERING_CALLBACK_URL: http://grafana:3000/
GF_LOG_FILTERS: rendering:debug
renderer:
image: grafana/grafana-image-renderer:latest
ports:
- 8081

Under this configuration, the renderer service is inaccessible from the Internet.

However, there is another option to run it as a standalone Node.js application.

image-20211218115649012

It seems that the service is also listening on the localhost, but actually it is accessible via public network interface:

image-20211218115803383

Exposing a renderer that attackers can specify any host for it to visit is not that dangerous unless you are running an outdated renderer:

image-20211218120020036

And without sandbox:

image-20211218120044608

So RCE is achievable:

image-20211218121158636

Actual payload is omitted.

Reference

https://grafana.com/blog/2021/12/08/an-update-on-0day-cve-2021-43798-grafana-directory-traversal/

https://j0vsec.com/post/cve-2021-43798/

https://mp.weixin.qq.com/s/dqJ3F_fStlj78S0qhQ3Ggw

https://pkg.go.dev/path/filepath

https://www.acunetix.com/blog/articles/a-fresh-look-on-reverse-proxy-related-attacks/

https://articles.zsxq.com/id_jb6bwow4zf5p.html

https://articles.zsxq.com/id_baeb9hmiroq5.html

https://github.com/jas502n/Grafana-CVE-2021-43798

https://securitylab.github.com/research/in_the_wild_chrome_cve_2021_30632/