
Prueba de concepto y análisis detallado de CVE-2021-28378, una vulnerabilidad XSS almacenada en Gitea que permite inyección de código arbitrario, escalada de privilegios y ejecución remota de código a través de ganchos de git.
Detalles sobre este CVE aquí.
Este CVE se debe a la ausencia de escape de cadenas en el lado del cliente para el contenido obtenido desde el servidor. Permite a un atacante inyectar código arbitrario fácilmente, creando un comentario en un issue o un pull request. Esto fue introducido en el commit 7d7ab1eeae43d99fe329878ac9c8db5e45e2dee5, a finales de enero de 2020, pero debido a un squash no podemos saber exactamente quién hizo esto para investigar errores similares introducidos.
En primer lugar, entendamos de dónde proviene esta vulnerabilidad. Para ello, veamos el 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 no hay escapado HTML en label.name, issue.repository.full_name, issue.title y body, que son cadenas que un usuario puede manipular y, por tanto, inyectar código arbitrario.
Observa que issue.repository.full_name no puede manipularse realmente para inyectar código, ya que debe seguir demasiadas reglas ([\w._-]+) que no podemos evadir con este CVE.
Esto es exactamente lo que corrige el PR al pasarlas por htmlEscape.
Después:
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>
`
Echemos un vistazo rápido al archivo web_src/js/features/contextpopup.js para averiguar cómo desencadenar el 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>
`
});
});
}
Lo que vemos ahí es que para cada nodo con la clase ref-issue, añadiremos un popup vulnerable.
Ahora, necesitamos encontrar dónde se envía un ref-issue al usuario: ¡hagamos grep (y quedémonos solo con los resultados interesantes, es decir, no con archivos de prueba o similares)!
El siguiente número de commit corresponde al tag 1.12.4, la versión por defecto del archivo oficial docker-compose.yml, afectado 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")
Ahora sabemos que no hay contenido estático con la clase ref-issue, sino que se genera dinámicamente.
La última no es interesante, ya que es solo una regla de política del sanitizador. Las demás son interesantes; veremos por qué.
Profundicemos en 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"))
}
}
También necesitaremos entender qué hace 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
}
Resumamos para entender qué trabajo se hace ahí.
En fullIssuePatternProcessor, primero debemos asegurarnos de que ctx.metas no sea nil (lo que asumimos como cierto, ya que es construido por funciones/métodos superiores en la pila de llamadas).
Luego comprobamos si los datos del nodo coinciden con una ruta como http://mygitea.com/gituser/myrepo/issues/1. Si hay coincidencia, la reemplazamos por un enlace.
Si el enlace es sobre el mismo usuario y repositorio, se reemplaza por el patrón #<issue_id>, en caso contrario por <user>/<repo>#<issue_id>.
En ambos casos, se añade la clase ref-issue para generar el popup.
Los otros resultados del grep hacen algo parecido.
Ahora que tenemos el contexto, que se trata de reemplazar un enlace como http://mygitea.com/gituser/myrepo/issues/1 por un enlace más corto, podemos deducir dónde se realiza dicho trabajo: en los issues y PR, donde puedes añadir una etiqueta y/o publicar un comentario.
Para esto, supongamos que tienes un repositorio con permisos de escritura.
Ve a tu repositorio y crea una nueva etiqueta con un nombre que contenga código arbitrario (como <script>alert('label.name')</script>).
Ve a los issues de tu repositorio y crea un nuevo issue con código arbitrario en el título y el cuerpo, y añádele la etiqueta anterior.
Crea un nuevo issue en cualquier repositorio y añade en su cuerpo un enlace a tu issue inyectado, como http://mygitea.com/gituser/myrepo/issues/1.
Será reemplazado por el enlace que vimos anteriormente, y cuando alguien pase el ratón sobre el enlace, el código arbitrario se ejecutará: se mostrará un popup que incrusta label.name.