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.

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ôle | Résultat |
|---|---|
wp core verify-checksums | conforme |
wp plugin verify-checksums --all | conforme (hors extensions absentes du dépôt officiel) |
functions.php du thème actif | intact, aucun appel suspect |
Webshell (.php7, .phtml, .phar) | néant |
| Extensions must-use et drop-ins | néant |
| Tâches cron WordPress et système | toutes légitimes |
.htaccess / .user.ini | aucun 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.

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.