Compromission WordPress : autopsie d’une attaque invisible en 6 secondes

Autopsie d’une compromission WordPress : six secondes, aucun fichier modifié

Fin août 2026, une quarantaine d’articles de casino en ligne apparaissent sur mon blog. Aucun fichier n’a été altéré, aucune porte dérobée déposée, et wp core verify-checksums répond « conforme ». Récit d’une enquête — y compris des hypothèses qu’il a fallu abandonner en chemin. Une compromission WordPress sans fichier modifié, disséquée pas à pas.


Le signal faible : anatomie d’une compromission WordPress

Une notification SEO dans mon administration :

Un document précédemment publié a été déplacé dans la corbeille. Vous pouvez rediriger /eurojackpot-pl/ vers une nouvelle URL.

Sur un blog qui parle d’IA, de cybersécurité et de plongée, ce slug n’a aucune raison d’exister. Je venais de supprimer un compte administrateur que je n’avais pas créé, puis de mettre WordPress à jour. Je pensais l’affaire close. Elle ne l’était pas — et je n’avais aucune idée de son ampleur.

Compromission WordPress : notification SEO signalant un article de spam publié sur le blog

Première hypothèse, première erreur

Le moniteur 404 révèle immédiatement autre chose que du spam : une série de requêtes vers about.php7, un nom de webshell classique dont l’extension contourne les règles bloquant *.php. L’une d’elles datait du matin même.

Conclusion immédiate : une porte dérobée existe, l’attaquant vient de la sonder.

C’était faux. Cinq chemins différents, trois adresses distinctes hébergées chez le même fournisseur cloud, toutes en 404. Un opérateur qui possède un shell tape le même chemin et obtient un 200. Ce que je regardais était du balayage automatisé, sans rapport avec ma compromission.

C’est le premier piège de ce type d’enquête : on trouve d’abord ce qu’on sait chercher.

Ce que les contrôles d’intégrité ont dit

ContrôleRésultat
wp core verify-checksumsconforme
wp plugin verify-checksums --allconforme (hors extensions absentes du dépôt officiel)
functions.php du thème actifintact, aucun appel suspect
Webshell (.php7, .phtml, .phar)néant
Extensions must-use et drop-insnéant
Tâches cron WordPress et systèmetoutes légitimes
.htaccess / .user.iniaucun auto_prepend_file

Tout est vert. Et pourtant une quarantaine d’articles de spam ont été publiés pendant la nuit.

C’est le cœur de l’affaire : une attaque qui n’écrit aucun fichier est invisible pour tout l’outillage d’intégrité. Les checksums comparent le disque à une référence. S’il n’y a rien de nouveau sur le disque, il n’y a rien à comparer.

Remonter le fil

Le seul endroit où une action laisse forcément une trace, c’est le journal d’accès HTTP.

bash

zcat -f ~/access-logs/* ~/logs/* 2>/dev/null \
  | grep -E '"POST /(wp-login\.php|xmlrpc\.php)' \
  | awk '($9==302||$9==200){print $1,$4,$7,$9}' | sort -k2

Ce qui remonte d’abord, c’est une tentative de force brute et son échec : une cinquantaine de POST /wp-login.php espacés d’une minute, tous en HTTP 200. Sur wp-login.php, un 200 signifie échec — WordPress réaffiche le formulaire avec l’erreur. Une connexion réussie renvoie un 302. Cet attaquant-là n’est jamais entré.

Deuxième hypothèse : XML-RPC. Le protocole n’a aucune limitation de tentatives, ne journalise pas les échecs, et system.multicall permet des centaines d’essais par requête. Le volume était là : plusieurs centaines de POST répartis sur cinq adresses.

Faux également. De la force brute distribuée, permanente sur n’importe quel WordPress exposé, sans aucune corrélation avec les horaires de publication. Une heure perdue.

Six secondes

La bonne requête était plus simple : chercher la création d’un compte, ou celle d’un jeton.

bash

zcat -f ~/access-logs/* ~/logs/* 2>/dev/null \
  | grep -E '"(POST|PUT) /(wp-admin/user-new\.php|wp-json/wp/v2/users|wp-signup\.php)'

Tout se déplie d’un coup :

06:06:29  GET  /wp-json/                                          200
06:06:31  GET  /                                                  200
06:06:31  POST /wp-login.php                                      302   ← connexion réussie
06:06:34  GET  /wp-admin/admin-ajax.php?action=rest-nonce         200
06:06:35  POST /wp-json/wp/v2/users/me/application-passwords      201   ← jeton créé

Six secondes. Découverte de l’API REST, connexion, récupération du nonce, création d’un mot de passe d’application persistant. Puis, pendant les vingt heures suivantes, une publication toutes les deux à trente minutes :

POST /wp-json/wp/v2/posts   201

Trois éléments rendent la séquence concluante. Aucun échec ne la précède : l’adresse n’apparaît nulle part avant. Un mot de passe cassé par force brute laisse des centaines de 200 derrière lui ; ici, une requête, un 302. La chaîne est automatisée : six secondes sans navigation intermédiaire, c’est un script qui reçoit un couple identifiant/mot de passe en entrée. Et le jeton est le vrai objectif : un mot de passe d’application survit au changement de mot de passe, à l’authentification à deux facteurs et à la destruction des sessions. En une requête, un accès ponctuel devient un accès durable.

Le périmètre réel

Le lendemain, un agrégateur passe sur le site et énumère méthodiquement les identifiants d’articles, ainsi qu’une archive d’auteur inconnue de moi. C’est ce crawl, et non mon inspection de l’administration, qui m’a donné l’ampleur : une cinquantaine d’URLs, ciblant une quinzaine de marchés nationaux avec un contenu rédigé dans la langue de chacun. Plus deux pages de structure : le squelette d’un faux site d’affiliation greffé sur le mien.

Les articles pointaient vers les domaines des affiliés. Mon domaine, ancien et bien référencé, servait de relais de crédibilité.

Et l’extension de partage automatique a relayé chaque publication sur mes comptes sociaux connectés. L’un des réseaux a retiré les publications concernées en quelques heures pour violation de ses règles.

Les fausses pistes, honnêtement

Je les liste parce qu’elles sont la partie utile de l’exercice.

Le webshell inexistant. Plusieurs heures sur du bruit de scanner.

XML-RPC. Vecteur plausible, volume réel, aucun rapport.

Un « incident » antérieur. J’avais trouvé un dossier de sauvegarde daté de juillet et bâti tout un scénario de compromission plus ancienne. C’était ma propre sauvegarde, faite après avoir cassé mon site en le modifiant.

Le filtrage par mots-clés. Pour reconstituer la liste des URLs à désindexer, mon premier réflexe a été un grep -iE 'casino|stake|bet…'. Il a remonté un vieil article dont le slug contenait « mistakes », et un autre consacré à une salle de spectacle parisienne dont le nom comporte le mot « casino ». Deux faux positifs qui auraient fait désindexer du contenu légitime.

La méthode qui fonctionne n’utilise aucun mot-clé : croiser les URLs vérifiées par l’attaquant après chaque publication avec celles énumérées par l’agrégateur, puis ne retenir que celles qui renvoient 404 aujourd’hui. Le contenu vivant répond 200 et s’écarte de lui-même.

Le durcissement après la compromission WordPress

php

// Mots de passe d'application désactivés au niveau du code
add_filter( 'wp_is_application_passwords_available', '__return_false' );

// Énumération des comptes fermée aux visiteurs non authentifiés
add_filter( 'rest_endpoints', function ( $e ) {
    if ( is_user_logged_in() ) return $e;
    unset( $e['/wp/v2/users'], $e['/wp/v2/users/(?P<id>[\d]+)'] );
    return $e;
} );

defined( 'DISALLOW_FILE_EDIT' ) || define( 'DISALLOW_FILE_EDIT', true );

Plus : authentification à deux facteurs avec WebAuthn en méthode principale et TOTP en secours, xmlrpc.php refusé au niveau Apache, thèmes et extensions inactifs supprimés, inscriptions fermées, comptes dormants purgés, clés de salage renouvelées.

Quatre enseignements

La 2FA ne protège pas les mots de passe d’application. C’est explicite dans l’extension Two-Factor depuis la version 0.14.0 : les jetons applicatifs passent outre, par conception. Activer la double authentification sans désactiver les jetons laisse ouverte la porte exacte de cet incident. Les deux mesures vont ensemble ou ne servent à rien.

Compromission WordPress : autopsie d'une attaque invisible en 6 secondes

Le libellé d’un jeton d’application n’est pas un champ anodin. WordPress le conserve en clair dans wp_usermeta et l’affiche sur la page d’édition du compte, accessible à tout administrateur du site. Ce champ attend un nom d’usage — « script de sauvegarde », « application mobile ». Tout ce qui n’est pas cela y devient lisible par quiconque obtient un accès administrateur. Auditez les vôtres.

Un compte administrateur dormant est une cible, pas un filet de sécurité. Quand on dispose de SSH et de WP-CLI, wp user create, wp user reset-password et wp plugin deactivate constituent un dispositif de secours strictement supérieur : aucun identifiant permanent à voler.

La rétention des journaux définit la limite de l’enquête. Mon hébergeur conserve le mois courant. Le compte malveillant ayant été créé avant cette fenêtre, sa création restera inconnue. C’est la seule question à laquelle je n’ai pas de réponse, et c’est la plus importante — sans elle, rien ne garantit que la faille d’origine soit refermée.


Les contrôles d’intégrité ne détectent pas ce qui n’écrit pas. Quand un attaquant dispose d’identifiants valides et d’une API REST, il n’a besoin de rien poser sur votre disque. C’est la signature d’une compromission WordPress moderne : des identifiants, pas un malware.

Laisser un commentaire