
Prova de conceito detalhada e análise da CVE-2021-28378, uma vulnerabilidade de XSS armazenado no Gitea permitindo injeção arbitrária de código, escalonamento de privilégios e execução remota de código por meio de hooks do git.
Detalhes sobre este CVE aqui.
Este CVE tem como alvo uma falta de escape de strings no lado do cliente a partir de conteúdo obtido do servidor. Permite que um atacante injete código arbitrário facilmente, ao criar um comentário numa issue ou pull request. Isto foi introduzido no commit 7d7ab1eeae43d99fe329878ac9c8db5e45e2dee5, no final de janeiro de 2020, mas devido a um squash não podemos saber exatamente quem fez isto para investigar bugs semelhantes introduzidos.
Antes de mais, vamos perceber de onde vem esta vulnerabilidade. Para isso, vejamos o commit que corrige este CVE.
Antes:
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>
`
Podemos ver que não há qualquer escape HTML em label.name, issue.repository.full_name, issue.title e body, que são strings que um utilizador pode manipular e, portanto, injetar código arbitrário. Observe que issue.repository.full_name não pode realmente ser manipulado para injetar código, pois tem de seguir demasiadas regras ([\w._-]+) que não podemos contornar com este CVE. É exatamente isto que o PR corrige ao convertê-los para htmlEscape.
Depois:
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>
`
Vamos dar uma rápida olhada no ficheiro web_src/js/features/contextpopup.js, para perceber como desencadear o 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>
`
});
});
}
O que vemos aí é que para cada nó com a classe ref-issue, vamos adicionar um popup vulnerável. Agora, precisamos de encontrar onde um ref-issue é enviado ao utilizador: vamos fazer grep (e manter apenas resultados interessantes, portanto nenhum ficheiro de teste ou semelhantes)! O seguinte número de commit corresponde à tag 1.12.4, a versão padrão do ficheiro oficial docker-compose.yml, que é afetada por este 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")
Agora sabemos que não há conteúdo estático com a classe ref-issue, mas é gerado dinamicamente. O último não é interessante pois é apenas uma regra de política de saneamento. Os outros são interessantes, veremos porquê.
Vamos mergulhar em 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"))
}
}
Também precisaremos de entender o que getIssueFullPattern faz.
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
}
Vamos resumir para entender que trabalho é feito ali.
Em fullIssuePatternProcessor, primeiro precisamos de ter a certeza de que ctx.metas não é nil (o que assumimos ser verdade pois é construído por funções/métodos superiores na stack). Depois verificamos se os dados do nó correspondem a um caminho como http://mygitea.com/gituser/myrepo/issues/1. Se houver correspondência, substituímos por um link. Se o link for sobre o mesmo utilizador e repositório, substituímos pelo padrão #<issue_id>, caso contrário por <user>/<repo>#<issue_id>. Em ambos os casos, uma classe ref-issue é adicionada para gerar o popup.
Os outros resultados do grep fazem algo semelhante.
Agora que temos o contexto, que consiste em substituir um link como http://mygitea.com/gituser/myrepo/issues/1 por um link mais curto, podemos deduzir onde tal tarefa é realizada: nas issues e PRs, onde se pode adicionar uma label e/ou postar um comentário.
Para isso, vamos supor que tens um repositório com permissões de escrita.
Vai ao teu repositório e cria uma nova etiqueta com um nome contendo código arbitrário (como <script>alert('label.name')</script>).
Vai às issues do teu repositório e cria uma nova issue com código arbitrário no título e no corpo, e adiciona-lhe a etiqueta anterior.
Cria uma nova issue em qualquer repositório e adiciona no seu corpo um link para a tua issue injetada, como http://mygitea.com/gituser/myrepo/issues/1. Será substituído pelo link que vimos anteriormente, e quando alguém colocar o rato sobre o link, o código arbitrário será executado: um popup é exibido embutindo label.name.