vendredi 25 septembre 2026

À propos

Contact

Sécurité

Quand un AJAX non protégé modifie les options : anatomie d’une élévation

Une action AJAX censée sauvegarder un réglage d'affichage permettait en réalité à n'importe quel visiteur connecté de changer le rôle par défaut des inscriptions. Reconstitution.

Par Clément Hadrot • 21 mars 2023 • 5 min de lecture • Aucun commentaire
Quand un AJAX non protégé modifie les options : anatomie d'une élévation

Le ticket de support disait simplement : « des comptes abonnés arrivent en administrateur, on ne comprend pas comment ». Le site tournait une extension de gestion d’adhésions un peu artisanale, développée en interne par une agence quelques années plus tôt puis abandonnée. Après quelques heures de recherche dans les logs d’activité, la cause est apparue : une action AJAX censée enregistrer une préférence d’affichage du tableau de bord acceptait en réalité n’importe quel nom d’option en paramètre, et l’écrivait sans le moindre contrôle.

Ce type de faille n’a rien d’exotique. Il s’agit d’une des causes les plus fréquentes d’élévation de privilèges dans les extensions maison ou peu maintenues, et elle mérite une reconstitution complète pour comprendre exactement où le raisonnement du développeur original s’est effondré.

Le code fautif, ligne par ligne

Voici, simplifié, le code trouvé dans l’extension. Il enregistrait une action AJAX accessible à tout utilisateur connecté, sans distinction de rôle :

add_action( 'wp_ajax_save_dashboard_pref', 'my_plugin_save_pref' );

function my_plugin_save_pref() {
    $option_name  = sanitize_key( $_POST['option'] );
    $option_value = sanitize_text_field( $_POST['value'] );

    update_option( $option_name, $option_value );

    wp_send_json_success();
}

À première vue, rien de choquant : la fonction sanitise bien ses entrées avec sanitize_key() et sanitize_text_field(), ce qui donne une fausse impression de rigueur. Le problème n’est pas l’absence d’échappement, il est ailleurs : rien n’empêche un utilisateur d’envoyer n’importe quel nom d’option, y compris default_role, l’option qui définit le rôle attribué automatiquement à toute nouvelle inscription sur le site.

Le scénario d’exploitation reconstitué

L'essentiel à retenir : Une action AJAX sans contrôle de capacité est ouverte à tout utilisateur connecté ; update_option sans vérification touche des réglages sensibles ; Le correctif tient en une ligne de current_user_can

Un attaquant disposant d’un simple compte abonné (le rôle le plus bas, souvent ouvert à l’inscription publique) pouvait envoyer la requête suivante, en réutilisant le nonce généré pour n’importe quelle autre action AJAX du tableau de bord, car aucune vérification de nonce n’était présente non plus :

fetch('/wp-admin/admin-ajax.php', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: 'action=save_dashboard_pref&option;=default_role&value;=administrator'
});

Une fois cette requête envoyée, toute nouvelle inscription sur le site recevait automatiquement le rôle d’administrateur. Il suffisait ensuite à l’attaquant de créer un second compte, ou d’inviter un compte existant à se réinscrire, pour obtenir un accès complet. Le premier compte abonné compromis servait ainsi de simple porte d’entrée vers un accès total, sans jamais avoir besoin d’identifiants d’administrateur.

Pourquoi la sanitisation seule ne protège rien ici

C’est le point le plus important à retenir de ce cas : sanitiser une valeur garantit sa forme (une clé d’option valide, un texte sans balises), pas sa légitimité. sanitize_key() transforme default_role en default_role tout aussi bien qu’un nom d’option inoffensif. La sanitisation répond à la question « est-ce que cette donnée a le bon format ? », jamais à la question « cet utilisateur a-t-il le droit de faire cette action ? ». Ce sont deux problèmes différents, et confondre les deux est l’erreur exacte commise ici.

Le correctif appliqué

La correction a consisté à ajouter un contrôle de capacité avant toute écriture, et à restreindre la liste des options modifiables par cette action à une liste blanche explicite plutôt qu’à un nom arbitraire :

add_action( 'wp_ajax_save_dashboard_pref', 'my_plugin_save_pref' );

function my_plugin_save_pref() {
    check_ajax_referer( 'my_plugin_dashboard', 'nonce' );

    if ( ! current_user_can( 'read' ) ) {
        wp_send_json_error( 'forbidden', 403 );
    }

    $allowed = array( 'my_plugin_widget_columns', 'my_plugin_widget_order' );
    $option_name = sanitize_key( $_POST['option'] );

    if ( ! in_array( $option_name, $allowed, true ) ) {
        wp_send_json_error( 'unknown option', 400 );
    }

    update_option( 'user_' . get_current_user_id() . '_' . $option_name,
        sanitize_text_field( $_POST['value'] ) );

    wp_send_json_success();
}

Deux changements structurels au-delà de l’ajout du nonce : la vérification de capacité (ici read, la capacité de base de tout utilisateur connecté, adaptée au contexte réel de la fonctionnalité), et surtout la liste blanche d’options autorisées, préfixées par l’identifiant de l’utilisateur pour éviter tout écrasement d’une option globale.

Ce qu’il faut vérifier dans son propre code

  • Toute action qui appelle update_option(), update_user_meta() ou update_post_meta() avec un nom dérivé d’une entrée utilisateur est suspecte par défaut.
  • Un nom d’option ou de méta doit toujours venir d’une liste fixe définie dans le code, jamais directement de $_POST ou $_GET.
  • Chaque action AJAX qui modifie un état doit vérifier à la fois un nonce et une capacité adaptée à l’action réelle, pas seulement au fait d’être connecté.

Sur ce dossier, le plus troublant n’était pas la faille elle-même mais le fait qu’elle dormait depuis près de trois ans sans qu’aucun scan automatisé ne la détecte, car elle ne correspondait à aucune signature connue. Seule la lecture du code l’a révélée.

Pour aller plus loin

Ce cas illustre une règle simple mais souvent oubliée : toute fonction qui écrit en base de données doit répondre à deux questions avant d’exécuter quoi que ce soit, « qui peut appeler cette fonction » et « sur quoi précisément peut-elle agir ». La documentation officielle sur les capacités utilisateur reste la référence à consulter dès qu’une action modifie un état persistant du site.

Partager :

À propos de l'auteur

Clément Hadrot

Développeur WordPress, passionné par Elementor, le FSE et l’automatisation par IA.

Voir tous ses articles

Dans la même veine

À lire aussi