# Credential stuffing sur wp-login.php : au-delà du simple rate limiting

> Le credential stuffing rejoue des identifiants volés ailleurs et passe sous les limites de tentatives classiques. Voici comment le repérer et le freiner.

- Auteur : Clément Hadrot
- Publié le : 2020-01-19
- Mis à jour le : 2020-01-19
- Catégorie : Sécurité
- URL : https://wpmoderne.dev.wordpress-developpement.fr/securite/credential-stuffing-wp-login-au-dela-rate-limiting/

## L’essentiel

- Le mot de passe est déjà « valide » ailleurs
- Le rate limiting par IP ne suffit plus
- L'empreinte de comportement change la donne

Un matin de janvier, un client nous signale une avalanche de connexions échouées sur son site vitrine. Rien d'alarmant en apparence : une dizaine de tentatives par minute, chacune avec un identifiant différent, chacune avec un mot de passe différent. Pas de pic brutal, pas de rafale sur un seul compte. Le plugin de limitation de tentatives installé depuis des mois ne réagit pas, parce qu'il surveille les échecs répétés sur *un même* compte ou depuis *une même* IP. Ce n'est pas ce qui se passe ici.

Ce que nous observons est du credential stuffing : un attaquant dispose d'une liste de couples identifiant/mot de passe issue d'une fuite tierce (un forum, un service e-commerce, peu importe la source) et les rejoue tels quels sur wp-login.php, en pariant sur la réutilisation des mots de passe entre services. Contrairement au force brute classique, il ne cherche pas à deviner un mot de passe : il vérifie une hypothèse déjà forte. Cette distinction change complètement la façon de se défendre.

## Pourquoi le rate limiting classique passe à côté

La plupart des extensions de protection de connexion comptent les échecs par IP ou par nom d'utilisateur, puis bloquent après un seuil (cinq échecs en cinq minutes, par exemple). Ce modèle suppose que l'attaquant s'acharne sur une cible précise. Le credential stuffing fonctionne à l'inverse : des centaines de couples différents, une seule tentative chacun, répartis sur un pool de dizaines ou de centaines d'adresses IP louées à la minute via des services de proxy résidentiel. Aucun seuil par IP n'est jamais atteint, aucun compte n'accumule assez d'échecs pour déclencher une alerte.

Pire : si la liste contient le bon couple pour un utilisateur du site, la connexion réussit du premier coup. Aucune règle de blocage ne s'applique puisqu'il n'y a pas eu d'échec préalable sur ce compte précis.

## Détecter par empreinte de comportement plutôt que par seuil

> L'essentiel à retenir : Le mot de passe est déjà « valide » ailleurs ; Le rate limiting par IP ne suffit plus ; L'empreinte de comportement change la donne

La piste que nous avons suivie chez ce client consiste à s'accrocher au hook `wp_login_failed` et à `authenticate`, mais en changeant l'angle d'analyse : au lieu de compter les échecs par compte, on construit une empreinte de la requête elle-même. Plusieurs signaux, combinés, deviennent parlants :

- La vitesse entre deux tentatives consécutives, souvent inférieure à la seconde pour un script automatisé, contre plusieurs secondes pour un humain qui tape au clavier.
- L'absence ou l'incohérence du user-agent et des en-têtes `Accept-Language`, souvent tronqués par les outils de credential stuffing bas de gamme.
- L'absence de requête préalable vers la page de connexion elle-même : un navigateur charge d'abord le formulaire HTML avant de poster les identifiants, un script direct saute cette étape.
- Le ratio d'échecs global sur la fenêtre glissante, tous comptes confondus, plutôt que par compte isolé.

Concrètement, nous journalisons chaque tentative de connexion avec un identifiant de session anonymisé et un score cumulé :

```
add_action( 'wp_login_failed', function( $username ) {
    $ip = $_SERVER['REMOTE_ADDR'] ?? '';
    $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
    $score = get_transient( 'stuffing_score_' . md5( $ip ) ) ?: 0;

    // Pas de user-agent ou requête trop rapide après la précédente : signal fort.
    if ( empty( $ua ) || strlen( $ua ) < 20 ) {
        $score += 3;
    } else {
        $score += 1;
    }

    set_transient( 'stuffing_score_' . md5( $ip ), $score, 10 * MINUTE_IN_SECONDS );

    if ( $score > 15 ) {
        // Blocage progressif : on ne bannit pas, on ralentit.
        set_transient( 'stuffing_delay_' . md5( $ip ), true, HOUR_IN_SECONDS );
    }
}, 10, 1 );
```

Le score n'est pas rattaché à un compte mais à une origine (IP, ou mieux, une combinaison IP + empreinte de navigateur côté serveur). Il s'accumule quel que soit le compte ciblé, ce qui permet de détecter la campagne même si chaque identifiant n'est essayé qu'une seule fois.

## Le blocage progressif plutôt que le bannissement sec

Bannir immédiatement une IP suspecte est tentant, mais deux problèmes se posent. D'abord, les IP louées changent en continu : bannir une adresse ne coûte rien à l'attaquant, qui en obtient une nouvelle à la tentative suivante. Ensuite, un faux positif (un utilisateur légitime derrière un VPN d'entreprise partagé) se retrouve bloqué sans recours simple.

Nous préférons un ralentissement progressif : au-delà d'un certain score, chaque tentative de connexion depuis l'origine suspecte est retardée artificiellement de quelques secondes via `usleep()` avant de rendre la réponse. Un humain ne remarque presque rien ; un script qui teste des milliers de couples par heure voit son débit s'effondrer. C'est une défense qui coûte cher à l'attaquant sans pénaliser injustement un visiteur légitime isolé.

## Compléter par une vérification côté compte

La détection comportementale réduit le volume, mais ne remplace pas une seconde ligne de défense sur les comptes eux-mêmes. Deux mesures simples et indépendantes du sujet des jetons à double facteur (déjà traité dans un autre article) aident particulièrement contre le credential stuffing :

- Vérifier, à la création ou au changement de mot de passe, que celui-ci ne figure pas dans une liste de mots de passe compromis connus (l'API « Pwned Passwords » de Have I Been Pwned fonctionne par préfixe de hachage SHA-1, sans jamais transmettre le mot de passe en clair).
- Notifier l'utilisateur par e-mail lors d'une connexion réussie depuis une origine inhabituelle, via le hook `wp_login`, en comparant à un historique des dernières connexions stocké en `usermeta`.

> Sur nos projets, croiser vitesse de frappe et absence d'appel préalable au formulaire de connexion a réduit de plus de 90 % le volume de tentatives qui atteignaient réellement `wp_authenticate`, sans bloquer un seul utilisateur légitime signalé par le client en trois mois de suivi.

## En résumé

Le credential stuffing ne se combat pas avec les mêmes outils que le force brute : il faut sortir du comptage par compte ou par IP isolée pour raisonner en empreinte comportementale et en score cumulé sur une fenêtre glissante. Le blocage progressif, plus que le bannissement sec, préserve l'expérience des utilisateurs légitimes tout en rendant l'attaque économiquement inintéressante. Associez cette détection à une hygiène de mots de passe côté compte, et la surface d'attaque réelle devient beaucoup plus étroite.
