Blog: Lire les articles privés de plus de 700 000 sites

Auteur: Nico D.
Publié le

Patrowl's blog - Lire les articles privés de plus de 700 000 sites Après le précédent article présentant la découverte d'une vulnérabilité sur le plugin WordPress UpdraftPlus, continuons d'analyser la sécurité de plugins WordPress populaires.

WordPress est un outil fascinant. Sorti en 2003, il y a plus de 20 ans, il a su devenir rapidement le CMS (Content Management System) le plus utilisé, et de loin. Pas moins de 43% des sites sur Internet l'utilisent.

Chez Patrowl, nous réalisons de tests d'intrusion automatisés à grande échelle avec près de 18.000 assets testés en continue et plus de 200.000 assets en reconnaissance passive et ce que nous observons confirme cette statistique : WordPress est utilisé partout ! Que ce soit pour des sites institutionnels, des blogs ou des plateformes de e-commerce, WordPress est aujourd'hui connu pour sa grande polyvalence. Cela n'est possible que grace à son fameux système de plugins.

WordPress est conçu de telle sorte que des plugins peuvent interagir avec tout son fonctionnement, notamment en utilisant la notion d'actions, de filtres ou de blocs. En utilisant ces éléments, un plugin peut complètement changer l'affichage ou le comportement de WordPress. Au passage, il peut également très significativement réduire son niveau de sécurité.

À la recherche d'une cible

Tout comme pour la précédente vulnérabilité, nous cherchons toujours une cible à partir des informations remontées par notre solution de tests d'intrusion continus et automatisés. En effet, au delà des vulnérabilités qu'elle remonte, elle nous indique également des informations sur les composants dépréciés, les messages d'erreurs verbeux, les technologies utilisées, etc. Nous fournissons à nos clients des informations validées et consolidées, mais nous utilisons également ces informations pour guider nos recherches.

Dernièrement, nous avons reçu une alerte concernant un plugin WordPress non à jour sur une application importante : Patrowl's blog - Lire les articles privés de plus de 700 000 sites

Cette version du plugin the-events-calendar a été déployée il y plus de 2 mois et plusieurs vulnérabilités ont déjà été remontées sur des versions plus anciennes. Avec plus de 700 000 installations actives, c'est une cible de choix pour mener un audit de code !

CVE-2023-6557 - The Events Calendar <= 6.2.8.2 - Unauthenticated Sensitive Information Exposure

Description

Le plugin The Events Calendar permet aux utilisateurs de créer et gérer des calendriers d'événements sur un site WordPress. C'est le principal plugin de ce type sur WordPress avec plus de 700 000 installations actives.

En analysant le code source de ce plugin, Patrowl a mis en évidence la présence d'un défaut dans la gestion des autorisations qui pourrait permettre à un attaquant d'obtenir des informations sur des articles privés, en brouillon ou protégés par un mot de passe.

Analyse du code source

Chez Patrowl, nous réalisons principalement des tests d'intrusion en boîte noire. De ce fait, nous sommes particulièrement friands de vulnérabilités exploitables par un attaquant anonyme. Dans WordPress, plusieurs hooks peuvent être déclenchés par des utilisateurs non authentifiés. En particulier, il est possible d'utiliser l'action wp_ajax_nopriv_{$action} qui sera déclenchée quand un utilisateur non authentifié enverra un requête sur /wp-admin/admin-ajax.php avec le paramètre action={$action}.

Dans le plugin The Events Calendar, nous retrouvons ce hook dans la classe Tribe__Ajax_Dropdown :

// common/src/Tribe/Ajax/Dropdown.php L.17
public function hook() {
    add_action( 'wp_ajax_tribe_dropdown', [ $this, 'route' ] );
    add_action( 'wp_ajax_nopriv_tribe_dropdown', [ $this, 'route' ] );
}

Cette classe est créée et cette méthode appelée quand le plugin est chargé, pour tout utilisateur.

La fonction route est définie de la manière suivante :

// common/src/Tribe/Ajax/Dropdown.php L.265
public function route() {
    // Push all POST params into a Default set of data
    $args = $this->parse_params( empty( $_POST ) ? [] : $_POST );
    
    if ( empty( $args->source ) ) {
        $this->error( esc_attr__( 'Missing data source for this dropdown', 'tribe-common' ) );
    }
    
    // Define a Filter to allow external calls to our Select2 Dropdowns.
    $filter = sanitize_key( 'tribe_dropdown_' . $args->source );
    if ( has_filter( $filter ) ) {
        $data = apply_filters( $filter, [], $args->search, $args->page, $args->args, $args->source );
    } else {
        $data = call_user_func_array( [ $this, $args->source ], array_values( (array) $args ) );
    }
    
    // If we've got a empty dataset we return an error.
    if ( empty( $data ) ) {
        $this->error( esc_attr__( 'Empty data set for this dropdown', 'tribe-common' ) );
    } else {
        $this->success( $data );
    }
}

Il y a 2 parties :

  • Les données POST sont "parsées" avec la fonction $this->parse_params et le résultat est placé dans la variable $args
  • La fonction call_user_func_array est appelée avec $args->source comme premier argument (le nom de la fonction à appeler) et la variable $args comme second (les arguments de cette fonction)

La méthode parse_params est simplement un wrapper de la fonction wp_parse_args définie dans WordPress qui fusionne les données utilisateur fournies en paramètre avec un objet par défaut. La valeur de sortie est donc complètement controlée par l'utilisateur.

La fonction call_user_func_array est utilisée de telle sorte qu'il est uniquement possible d'executer des méthodes de la classe Tribe__Ajax_dropdown. Parmi toutes les méthodes implémentées, search_posts est particulièrement intéressante :

// common/src/Tribe/Ajax/Dropdown.php L.113
public function search_posts( $search, $page = 1, $args = [], $selected = null ) {
    if ( ! empty( $search ) ) {
        $args['s'] = $search;
    }

    $args['paged']                  = $page;
    $args['update_post_meta_cache'] = false;
    $args['update_post_term_cache'] = false;

    $results        = new WP_Query( $args );
    $has_pagination = $results->post_count < $results->found_posts;

    return $this->format_posts_for_dropdown( $results->posts, $selected, $has_pagination );
}

Cette fonction effectue une recherche d'articles en utilisant l'objet WP_Query et utilise la variable $args, contrôlée par les utilisateurs, pour configurer cette recherche. Le résultat de la recherche est ensuite formaté par la méthode format_posts_for_dropdown qui ne retourne que les titres et ID des articles retournés. Avoir un contrôle total sur les arguments passés à WP_Query par un utilisateur anonyme peut conduire à des fuites de données importantes.

Exploitation

Commençons par créer une nouvelle instance de WordPress avec le plugin vulnérable et créons des articles avec différents statuts : Patrowl's blog - Lire les articles privés de plus de 700 000 sites

Sans être authentifié, envoyons une requête sur le point d'entrée identifié plus tôt pour lister les titres de tous les articles : Patrowl's blog - Lire les articles privés de plus de 700 000 sites

La requête ci-dessus contient les paramètres suivants :

  • action=tribe_dropdown, pour exécuter l'action wp_ajax_nopriv_tribe_dropdown
  • source=search_posts, pour appeler la fonction vulnérable identifiée
  • args[nopaging]=true, pour désactiver la pagination et obtenir tous les résultats
  • args[post_status][]=all, pour lister tous les articles, y compris les articles privés

Ainsi, comme attendu suite à l'analyse du code, un utilisateur anonyme est en mesure de lister les titres de tous les articles, y compris ceux encore en brouillon ainsi que ceux privés et protégés par un mot de passe.

Récupération du contenu des articles

Être en mesure de lister les titres de tous les articles est certes intéressant et peut conduire à des fuites de données, mais pouvoir lire leur contenu serait bien plus intéressant ! L'objet WP_Query permet d'effectuer des recherches précises dans le contenu des posts. Ainsi, il est possible de retrouver le contenu d'un article, caractère par caractère.

WordPress implémente par défaut une heuristique de recherche dans WP_Query ayant pour objectif d'être souple dans les recherches et de retrouver le plus de résultats plausibles que possible. Néanmoins, ces règles rendent la récupération du contenu d'un article plus difficile. En explorant la documentation et le code source de WP_Query, nous avons identifié plusieurs arguments permettant de durcir les règles de recherche et rendre possible l'extraction du contenu d'un article :

  • p: permet de spécifier l'ID de l'article dans lequel faire une recherche. Ce paramètre peut être pratique pour éviter de chercher dans tous les articles et en cibler un spécifique. À noter qu'il n'est pas possible de l'utiliser avec un article privé.
  • search_columns : Liste des colonnes en base de données dans lesquelles effectuer la recherche. Seules les valeurs suivantes sont autorisées :
    • post_title
    • post_excerpt
    • post_content : C'est celle qui nous intéresse
  • sentence : Si elle est activée, l'option permet de s'assurer que le texte de recherche est exactement présent dans l'article.

En combinant ces paramètres, il est possible d'effectuer des recherches spécifiques dans le contenu d'un article et de retrouver tout son contenu, caractère par caractère.

Évidemment, cette recherche reste lente avec une complexité en O(n*m)n est la taille de l'article et m est le nombre de caractères dans l'alphabet de recherche. Il est possible de réduire m au strict minimum en vérifiant dans un premier temps si un caractère est présent. Cela est faisable en exploitant le fait que si le critère de recherche commence par -, WP_Query effectue une recherche inversée. Ainsi, si la recherche -c retourne un résultat, cela signifie que le caractère c n'est pas présent dans l'article.

Nous avons utilisé cette méthode et, bien qu'elle demeure lente, nous avons été en mesure de retrouver le contenu d'articles privés dans un temps raisonnable.

Correction

Dans le correctif publié par l'éditeur, l'attribut args est désormais forcé avec la valeur [ 'post_status' => 'publish' ] et seule les clés taxonomy et post_type sont désormais contrôlables. Il n'est ainsi plus possible d'obtenir d'informations d'articles privés et la méthode de récupération du contenu décrite précédemment n'est plus exploitable.

Un correctif simple et efficace !

Conclusion

Chez Patrowl, on aime les tests d'intrusion automatisés et continus. Notre solution unique de scan et de tests automatisé nous guide dans la conduite de tests manuels afin de toujours remonter des vulnérabilités pertinentes et intéressantes à nos clients. Nous allons continuer de chercher de nouvelles vulnérabilités, toujours conduit par les résultats que nous obtenons sur les assets de nos clients. Alors attends toi à de futurs articles !

Blog: Programme CaRE : les établissements de santé comblent le retard en cybersécurité avec Patrowl

Blog: Lire les articles privés de plus de 700 000 sites

Blog: D'un warning à une CSRF impactant plus de 3 millions de sites