
Detailed proof-of-concept and analysis of CVE-2021-28378, a stored XSS vulnerability in Gitea enabling arbitrary code injection, privilege escalation, and remote code execution via git hooks.
Details about this CVE here.
This CVE targets a missing string escaping on the client-side from content fetched from the server. It enables an attacker to inject arbitrary code easily, by creating a comment on an issue or a pull request. This was introduced in commit 7d7ab1eeae43d99fe329878ac9c8db5e45e2dee5, by the end of Jan 2020, but because of a squash we can't exactly know who did this to investigate for similar bugs introduced.
First of all, let's understand where this vulnerability come from. For that, let's see the commit that fixes this CVE.
Before:
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>
`
We can see there is not any html escaping on label.name, issue.repository.full_name, issue.title and body, which are strings a user can manipulate, and so, inject arbitrary code.
Notice the issue.repository.full_name cannot really be manipulated to inject code while it has to follow too many rules ([\w._-]+) that we cannot bypass with this CVE.
This is exactly what the PR fixes by casting them into htmlEscape.
After:
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>
`
Let's take a quick look at the file web_src/js/features/contextpopup.js, to figure out how to trigger the 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>
`
});
});
}
What we see there is that for each node with the class ref-issue, we will add a vulnerable popup.
Now, we need to find where a ref-issue is sent to the user: let's grep (and keep only interesting results, so not any test files or similar ones) !
The following commit number corresponds to the 1.12.4 tag, the default version from the official docker-compose.yml file, which is affected by this 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")
We now know there is no static content with the class ref-issue, but it's dynamically generated.
The last is not interesting while it's just a sanitizer policy rule. The others are interesting, we'll see why.
Let's dive into 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"))
}
}
We'll also need to understand what getIssueFullPattern does.
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
}
Let's sum up to understand what job is done there.
In fullIssuePatternProcessor, we first need to be sure that ctx.metas is not nil (what we assume being true while it's built by higher functions/methods in the stack trace).
Then we check if the node data matches a path like http://mygitea.com/gituser/myrepo/issues/1. If there is a match, we then replace it by a link.
If the link is about the same user and repo, replace it by the #<issue_id> pattern, else by <user>/<repo>#<issue_id>.
In those both cases, we have a ref-issue class that is added to generate the popup.
The other grep-results does kind of the same thing.
Now we have the context, which is about replacing a link like http://mygitea.com/gituser/myrepo/issues/1 by a shorter link, we can deduce where such job is achieved: in issues and PR, where you can add a label on and/or post a comment.
For this, let's assume you've got a repository with write permissions.
Go to your repository, and create a new label with a name containing arbitrary code (such as <script>alert('label.name')</script>).
Go to your repository issues, and create a new issue with arbitrary code in the title and the body, and add it the previous label.
Create a new issue on any repository, and add in its body a link to your injected issue such as http://mygitea.com/gituser/myrepo/issues/1.
It will be replaced by the link we previously saw, and when someone will put his mouse over the link, the arbitrary code will get run: a popup is displayed embedding label.name.
The CVE-2021-28378 is proven being able to enable arbitrary code injection. Let's imagine what an attacker could do.