Un WordPress piraté, deux attaquants, et la faille qu'aucun antivirus n'a vue

Le site ne répond plus. Page blanche, code HTTP 200, aucune erreur PHP dans les logs. Rien à quoi se raccrocher.

Googlebot, lui, reçoit des pages entières. Des liens vers des boutiques de contrefaçon, indexés depuis trois jours sous le nom du client. Le site est mort pour les humains et bien vivant pour les robots. C'est le pire scénario : personne ne voit le contenu qui salit le domaine, et le domaine se salit quand même.

L'antivirus vient de finir son passage. 373 fichiers analysés, zéro détection.

Il a fallu trois jours pour tout démêler : par où ils étaient entrés, ce qu'ils avaient laissé derrière, et ce qu'il fallait changer pour qu'aucun d'eux ne puisse recommencer. Le client, le domaine et les adresses sont anonymisés. Le reste est exact, y compris les fois où je me suis trompé de piste. Ce sont souvent les passages les plus utiles.

La page blanche n'était pas le problème

Le premier fichier que j'ouvre, c'est index.php. Il a bien été remplacé par une version vérolée. Coupable idéal, affaire classée.

Sauf qu'en lisant vraiment le code, le cheval de Troie ne s'arrête jamais pour un visiteur normal. Il sert la page attendue, et ne dévie que pour les robots d'indexation. Du cloaking : un contenu pour Google, un autre pour toi. Ce fichier n'avait aucune raison de produire une page blanche.

Mauvaise piste. La vraie cause était plus bête, et plus brutale : le répertoire wp-content avait été renommé. Extensions, thème, médias, tout ce que WordPress va chercher là devenait introuvable. Le cœur démarrait, ne trouvait aucun thème, rendait le vide. Un site entier tué par un mv.

Reste à comprendre pourquoi un attaquant saborde le site qu'il exploite. Ça n'a aucun sens. Un site en panne ne rapporte plus rien, et il fait rappliquer l'administrateur. C'est exactement ce qu'on veut éviter quand on vend des liens.

Les logs ont répondu. Ils étaient deux, et ils se détestaient.

Le second arrivé avait posé un .htaccess qui n'autorisait que ses propres portes dérobées. Celles du groupe précédent se sont retrouvées dehors. Ce groupe-là est revenu quelques heures plus tard, et s'est pris deux 403 sur ses propres outils, sur un serveur qu'il considérait comme le sien. Sa réponse a été de redéposer sa porte, puis de renommer wp-content. Trois secondes séparent les deux actions dans le journal.

Le sabotage ne visait donc pas le client. C'était un message adressé à un concurrent. Le site n'était que le terrain.

Dix-huit secondes

Restait la question que cette bagarre avait masquée : par où sont-ils entrés ?

Pour la trouver, on ne cherche pas les fichiers bizarres. On cherche ce qui s'est passé juste avant que le premier fichier bizarre apparaisse. Un attaquant efface ses fichiers sans mal. La requête qui les a créés, beaucoup moins.

J'ai daté chaque fichier suspect, gardé le plus ancien, et remonté le journal d'accès à cette minute-là. Deux lignes suffisent.

04:04:35  POST /?wpmudev-hub=<jeton>   200
04:04:53  GET  /<porte-dérobée>.php     200

Dix-huit secondes entre l'appel à l'extension et la première requête sur le webshell. Personne ne tape une URL au hasard et tombe sur un fichier qui n'existait pas vingt secondes plus tôt.

Ce paramètre wpmudev-hub appartient au tableau de bord WPMU DEV, une extension de gestion multi-sites. Ses versions jusqu'à la 5.0.0 portent un contournement d'authentification, référencé CVE-2026-15459. Il ne s'ouvre que dans un cas : quand la clé d'API du service n'a jamais été renseignée. L'extension accepte alors un appel anonyme, et te laisse en installer une autre. Installer une extension, c'est exécuter du code. Une faille n'a pas besoin d'être plus subtile que ça.

Deux détails ont transformé l'hypothèse en diagnostic. En base, l'option wpmudev_apikey était vide : l'état vulnérable, exactement. Et le nonce stocké par l'extension correspondait au suffixe des URL d'attaque. Ce n'était plus une histoire plausible. C'était la trace de l'exploitation, écrite par l'extension elle-même.

Neuf exploitations réussies, neuf adresses différentes, la dernière trois jours après la première. Personne n'avait pris la peine de refermer.

Chronologie de l'intrusion : appel non authentifié à l'extension, webshell dix-huit secondes plus tard, kit d'auto-réparation, cloaking, neuf exploitations en trois jours, puis sabotage entre groupes rivaux. 04:04:35 L'extension accepte un appel anonyme CVE-2026-15459, clé d'API jamais renseignée + 18 s Le webshell déposé répond déjà le serveur exécute leur code puis Six fichiers déguisés en images et scripts le kit qui remet la porte en place puis index.php remplacé : du spam pour les robots le visiteur humain ne voit rien 3 jours Neuf exploitations, neuf adresses la faille reste ouverte tout ce temps enfin Représailles entre groupes : le site tombe wp-content renommé, page blanche
Trois jours entre la porte laissée ouverte et la panne qui a fini par la révéler.

Ce que l'antivirus n'a pas vu, et ce que j'ai raté

Trouver la porte, c'est une chose. Retrouver tout ce qu'ils ont laissé derrière, c'en est une autre. Et là, mes outils m'ont lâché les uns après les autres.

ClamAV, d'abord : 0 détection sur 373 fichiers. Ce n'est pas un défaut du produit. C'est le plafond de l'approche par signature, face à du code écrit pour ne ressembler à rien.

Le réflexe suivant, c'est grep base64_decode. Il ne rend rien non plus. Les noms de fonctions sensibles sont assemblés caractère par caractère à l'exécution, et les URL de commande sont encodées. Il n'y a aucune chaîne à trouver : le fichier ne contient pas les mots qui le trahiraient, il les fabrique.

Les dates de modification, ensuite. Elles avaient été reculées pour se fondre dans l'installation d'origine. Un fichier déposé la semaine précédente affichait fièrement la date du reste du cœur WordPress. Seule la date de changement d'inode résiste, le ctime, parce que PHP ne sait pas la réécrire. C'est elle qui a permis de dater l'intrusion à la seconde. Sans elle, je cherchais encore.

Le plus soigné, c'était le kit d'auto-réparation. Six fichiers déguisés en images et en scripts, avec des noms faits pour survivre à une relecture rapide : server-sied-renderr.min.js, icon_twistedd.png. Tu lis vite, tu vois un script minifié et une icône, tu passes. Chacun contenait en fait une copie exacte, à l'octet près, du index.php vérolé et de son .htaccess. De faux wp-login.php se chargeaient de les remettre en place. Supprimer la porte dérobée sans supprimer le kit, c'est la regarder revenir.

Et j'ai raté un fichier au premier passage.

Pour balayer les fichiers récemment modifiés, j'avais exclu wp-content/cache et ses 78 000 fichiers légitimes. Une exclusion de confort, prise pour que la commande rende un résultat lisible. Un chargeur distant de 468 octets y avait survécu, avec son propre .htaccess qui l'autorisait. Je l'ai trouvé au second passage, celui que j'ai relancé sans aucune exclusion, en partant du principe que le premier avait échoué.

C'est la leçon que je garde de tout l'épisode. Une exclusion dans un balayage, c'est une cachette qu'on offre. Un répertoire trop gros pour être fouillé n'a pas besoin d'une exception, il a besoin de son propre contrôle.

Nettoyer, puis rendre le nettoyage inutile

Une fois l'inventaire vraiment complet, le nettoyage devient la partie ennuyeuse. Tant mieux.

Le cœur de WordPress a été comparé fichier par fichier à l'archive officielle de la même version, et chaque écart remplacé. Un seul était légitime : le fichier qui porte la variante linguistique de l'installation. Les extensions, elles, n'ont pas été nettoyées mais réinstallées depuis leur source officielle. C'est plus rapide et plus sûr, parce que personne ne sait relire trente extensions à la main. Le thème sur-mesure, lui, ne pouvait pas être remplacé. Il a été vérifié ligne à ligne. Il était intact.

À ce stade, le site est propre. Il est aussi exactement aussi vulnérable qu'avant.

C'est ce que la plupart des remédiations oublient : nettoyer répare le passé, ça n'écrit rien sur l'avenir. La partie qui compte commence ici.

Le serveur web n'a plus le droit d'écrire une seule ligne de code. Tout l'arbre appartient à root, le groupe du serveur web est en lecture seule, les répertoires sont en 750 et les fichiers en 640. Quelques répertoires doivent rester inscriptibles, forcément, puisque WordPress y dépose les médias et le cache. Pour ceux-là, l'exécution de PHP est refusée au niveau d'Apache :

<DirectoryMatch "…/wp-content/(uploads|cache|upgrade)(/|$)">
    <FilesMatch "\.(?i:php|phtml|php[0-9]|pht|cgi|pl|py|sh)$">
        Require all denied
    </FilesMatch>
</DirectoryMatch>

D'un côté les endroits où l'on peut écrire, de l'autre ceux où PHP s'exécute. L'intersection est vide, et c'est tout l'intérêt.

Deux ensembles disjoints : les répertoires inscriptibles par le serveur web d'un côté, ceux où PHP s'exécute de l'autre. Aucun répertoire n'appartient aux deux. Inscriptible par le serveur web PHP s'exécute uploads/ cache/ upgrade/ cœur WordPress extensions thème intersection
Aucun endroit où déposer du code et l'exécuter.

Rejoue l'attaque de 04:04:35 sur cette configuration. L'extension vulnérable accepte toujours l'appel anonyme. Elle essaie toujours d'écrire son fichier. Elle échoue. La faille est intacte, l'exploitation ne l'est plus.

La configuration qui ferme ce qui restait

Interdire l'écriture casse une chose au passage, et pas une petite : WordPress ne peut plus se mettre à jour tout seul. La constante DISALLOW_FILE_MODS rend ce blocage explicite, et retire du même geste l'installation d'extensions depuis l'interface.

C'est assumé, mais ça crée un nouveau risque. Un site qui ne reçoit plus de correctifs devient un problème différent, et franchement pire que celui qu'on vient de régler.

La compensation est une tâche hebdomadaire lancée par root. Elle bascule temporairement la constante, sauvegarde la base, met à jour, remet les permissions, vérifie les empreintes du cœur, teste que le site répond encore, reconstruit la référence de surveillance, et envoie un compte rendu. Le blocage tient face à un intrus, et les correctifs arrivent quand même. C'est le seul arrangement que j'ai trouvé qui ne sacrifie ni l'un ni l'autre.

Le reste de la configuration tient dans trois questions.

Qui est encore connecté ? Les sels de chiffrement ont été régénérés. Toutes les sessions ouvertes tombent, celles de l'attaquant comprises.

Qui peut encore déclencher du code ? Le cron interne de WordPress a été désactivé au profit d'une tâche système. Le cron interne dépend du trafic : il se déclenche depuis l'extérieur, sur simple visite. C'est une porte qui s'ouvre à la demande.

Qu'est-ce qui reste visible depuis la rue ? L'URL de connexion a bougé. L'éditeur de fichiers est coupé, l'administration forcée en HTTPS, TLS 1.0 et 1.1 refusés. Les fichiers qui parlent trop sont interdits d'accès : journaux de débogage, readme, composer.json, tout ce qui ressemble à un .env. Et une prison fail2ban bannit dès la première tentative sur un motif d'exploitation.

Pas de Content Security Policy, en revanche, et j'assume ce choix. Le site charge un gestionnaire de balises, deux régies publicitaires, une carte et un widget de réservation externe. Une CSP écrite à l'aveugle casse le site en silence. Une CSP assez permissive pour tout autoriser ne protège de rien, tout en cochant la case dans l'audit. Mieux vaut pas de CSP qu'une fausse. C'est un chantier, pas une ligne de configuration.

Côté secrets, la règle était simple : tout ce qui a pu être lu pendant la compromission est perdu. Mots de passe des comptes administrateurs, accès à la base, clés SSH régénérées. Une clé qui n'avait pas servi depuis un an a disparu au passage. Les clés d'API des services tiers ont été inventoriées avec leur portée, pour être renouvelées ensuite.

Puis j'ai regardé les sauvegardes. Elles existaient : une par semaine chez l'hébergeur, conservées sur 28 jours glissants, plus un instantané complet du serveur une fois par an.

Sur le papier, ça rassure. Dans cet incident, beaucoup moins. L'intrusion date du 7 août et n'a été découverte que le 10. Avec une sauvegarde hebdomadaire, la plus récente avait toutes les chances de contenir déjà la porte dérobée : restaurer, c'était réinstaller le problème. Et 28 jours de rétention laissent quatre points de retour, quand une compromission peut dormir des mois avant de se manifester.

Une sauvegarde ne vaut donc que par la certitude qu'on a de son contenu. C'est pour ça qu'un état de référence a été figé à part, une fois le site propre : archive du site, export de la base, liste des extensions avec leurs versions, et un fichier de 11 784 empreintes SHA-256 qui vaut preuve opposable de l'état sain à une date donnée. Ce n'est pas une sauvegarde de plus, c'est le seul point de retour dont on sache qu'il est sain.

Dans la foulée, quatre ans de code sur-mesure jamais versionné sont entrés dans un dépôt Git, avec une archive gardée hors du serveur. Jusque-là, la seule copie du thème était celle qui tournait en production. Sur la machine qui venait d'être piratée.

Savoir avant l'attaquant

Un site durci sans supervision, c'est un site dont tu ignores qu'il est retombé. Le durcissement fait baisser la probabilité, il ne la met pas à zéro. L'écart entre les deux se comble avec de la surveillance.

Une tâche s'exécute donc toutes les trente minutes, et n'envoie un courriel que s'il y a quelque chose à dire. Le silence est le succès.

Premier contrôle, l'intégrité du cœur. On interroge wordpress.org pour la version réellement installée. J'avais d'abord comparé à une archive figée sur disque, ce qui a produit une belle fausse alerte dès la première mise à jour : une référence gelée devient fausse toute seule, sans que personne n'ait rien fait de mal.

Vient ensuite l'empreinte. Près de 11 500 fichiers comparés à une référence : tous les PHP, les .htaccess, et les scripts et feuilles de style du thème. Le cache, trop volatil pour tenir dans une empreinte, a son propre contrôle. Exactement celui qui m'aurait évité de rater le chargeur de 468 octets.

Deux autres vérifications, bêtes et efficaces : pas d'exécutable dans le répertoire des médias, et pas un fichier dans celui des extensions à chargement automatique. Ce dernier est la cachette la plus commode de WordPress, parce que rien de ce qu'on y dépose ne peut être désactivé depuis l'interface.

En base, une sentinelle veille sur quatorze valeurs sensibles : adresse du site, courriel d'administration, ouverture des inscriptions, rôle par défaut, thème actif, extensions actives, et la liste complète des comptes, de leurs rôles et de leurs mots de passe d'application. Créer un compte administrateur discret est le premier geste de persistance de n'importe quel intrus. Ça déclenche maintenant une alerte en moins de trente minutes.

Le dernier contrôle, enfin, demande la même page deux fois. Une fois en se présentant comme un navigateur, une fois comme Googlebot, puis compare la taille des réponses. C'est la signature du cloaking. Celui-là même qui servait du spam pendant que le site était vide.

Restait le piège qui tue toutes les supervisions, et il n'a rien de technique : l'accoutumance. La première version alertait sur tout motif d'exploitation vu dans les logs, ce qui donnait 856 lignes par jour. Presque toutes des scanners génériques qui ratissent l'internet entier et se prennent des 404. Un rapport quotidien de 856 lignes, tu le lis deux jours, puis tu le classes sans l'ouvrir.

J'ai d'abord trié ces motifs en deux familles, pour ne garder que ceux qui visaient ce site précisément. Le bruit est tombé à presque rien. Presque : deux fois par jour, un robot rejouait la faille d'origine, et la page d'accueil lui répondait 200 puisque l'extension n'existe plus. Deux alertes quotidiennes pour une attaque devenue impossible.

Alors on a coupé plus franchement. Le mail ne part plus que si quelque chose a réussi : un fichier apparu, un fichier modifié, une valeur changée en base, le site qui tombe. Les tentatives continuent d'être journalisées sur le serveur, disponibles pour une enquête, mais elles ne réveillent plus personne.

Deux colonnes : à gauche ce qui est seulement journalisé, les tentatives ; à droite ce qui déclenche un mail, les conséquences réelles. Ce qui reste au journal une URL de faille essayée un scan de /.env, /xmlrpc.php un 404 sur une ancienne porte dérobée aucun mail Ce qui déclenche un mail un fichier PHP nouveau ou modifié une valeur sensible changée en base l'extension vulnérable réapparue le site qui tombe ou qui cloake mail immédiat On alerte sur le résultat, jamais sur l'intention.
Une tentative n'apprend rien : elles arrivent par milliers. Un fichier qui bouge, si.

Et le vecteur de l'intrusion n'est plus guetté dans les journaux. C'est sa condition d'existence qui l'est : si l'extension vulnérable réapparaît un jour dans le répertoire des extensions, l'alerte part. On ne surveille plus qu'on essaie d'ouvrir la porte, on surveille que la porte soit toujours murée.

Une alerte qui part tous les jours pour rien finit par ne plus être lue. Et c'est ce jour-là que la vraie passe inaperçue.

La stack complete, a copier

Tout ce qui precede tient dans trois taches planifiees et deux scripts. Les voici en entier, anonymises : chemins, domaine, base et adresses sont remplaces par des valeurs d'exemple, le reste est le code qui tourne vraiment.

D'abord le cron. Rien d'exotique, et c'est voulu : un systeme de surveillance qu'on ne comprend pas au premier coup d'oeil ne sera jamais debogue le jour ou il se trompe.

# /etc/cron.d/surveillance
# Surveillance : toutes les 30 minutes
*/30 * * * *  root  /root/surveille.sh       >/dev/null 2>&1
# Mises a jour : le mardi a 4h17, hors trafic
17   4 * * 2  root  /root/maj.sh             >/dev/null 2>&1
# Antivirus : le dimanche a 3h17
17   3 * * 0  root  /root/scan-antivirus.sh  >/dev/null 2>&1

Ensuite les deux regles Apache. La premiere coupe le moteur PHP dans les repertoires inscriptibles, avec une directive qu'un .htaccess ne peut pas defaire. La seconde empeche d'y declarer un gestionnaire de fichiers. Sans elles, un .jpg contenant du PHP s'executait : verifie, puis reverifie une fois la regle posee.

# Interdiction d'executer PHP dans les zones que le serveur web peut ecrire.
#
# Complete lna-php-interdit.conf, qui refuse l'acces aux fichiers dont
# l'EXTENSION est executable. Le present fichier ferme l'autre moitie : un
# .htaccess pouvait declarer `AddType application/x-httpd-php .jpg` et faire
# executer un fichier dont l'extension paraissait inoffensive.
#
# php_admin_flag ne peut PAS etre surcharge par un .htaccess, quel que soit
# AllowOverride. C'est ce qui permet de laisser wp-content/cache en
# AllowOverride All, dont W3TC a besoin pour declarer ses types MIME, tout en
# lui interdisant l'execution.
#
# Sans effet sur les fichiers que W3TC inclut depuis son propre code : la
# directive porte sur le chemin de la REQUETE, pas sur un include() PHP.
<DirectoryMatch "^/var/www/lesmontheme\.com/public/wp-content/(uploads|uploads-webpc|smush-webp|cache|w3tc-config|upgrade|upgrade-temp-backup)(/|$)">
    php_admin_flag engine off
</DirectoryMatch>
# Les zones ou www-data ecrit ne doivent pas pouvoir declarer de gestionnaire.
#
# AllowOverride All y laissait un .htaccess poser AddType/AddHandler/SetHandler,
# donc rendre executable un fichier deguise en .jpg : le payload echappait alors
# au controle des extensions de la surveillance.
#
# AuthConfig et Limit restent autorises, volontairement : plusieurs extensions
# (Contact Form 7, WP Migrate DB) posent la un .htaccess qui INTERDIT l'acces a
# leurs fichiers. Les couper exposerait ce qu'elles protegent.
#
# wp-content/cache n'est pas dans la liste : W3TC y declare des types MIME pour
# servir ses variantes compressees, et le lui retirer degraderait le cache.
<DirectoryMatch "^/var/www/lesmontheme\.com/public/wp-content/(uploads|uploads-webpc|smush-webp|w3tc-config)(/|$)">
    AllowOverride AuthConfig Limit
</DirectoryMatch>

Ensuite les mises a jour. Ce script existe uniquement parce que DISALLOW_FILE_MODS empeche WordPress de se mettre a jour seul. Il leve le verrou, travaille, le remet, verifie, et rend compte. Deux details comptent. Le trap de la ligne 53, sans lequel un plantage au milieu laisserait le site inscriptible jusqu'a la semaine suivante. Et le garde-fou de la ligne 30 : si la sauvegarde de la base echoue, rien n'est mis a jour. Un filet qu'on croit tendu et qui ne l'est pas vaut moins que pas de filet du tout.

#!/bin/bash
# Mises a jour WordPress pilotees par le systeme.
# DISALLOW_FILE_MODS empeche WordPress de se mettre a jour seul : c'est
# volontaire (www-data ne doit pas pouvoir ecrire dans le coeur). Ce script
# le fait a sa place, en root, avec verification avant/apres.
SITE=/var/www/example.com/public
STATE=/var/lib/site-watch
DEST=alerte@example.com
FROM=surveillance@example.com
LOG=/var/log/maj.log
cd "$SITE" || exit 1

# Sans ce controle, un wp-cli casse rendait $dispo et $coeur vides : le script
# concluait "rien a mettre a jour" et le site cessait d'etre corrige en silence,
# semaine apres semaine. C'est exactement ce qui a mene a l'incident d'origine.
if ! command -v wp >/dev/null; then
    if printf '%s' "wp-cli est introuvable sur $(hostname).

Aucune mise a jour n'a pu etre tentee, et ce silence aurait pu durer des mois.
Reinstaller wp-cli puis relancer /root/maj.sh." \
        | mail -s "[MAJ] wp-cli introuvable, aucune mise a jour appliquee" -a "From: $FROM" "$DEST"; then
        echo "$(date -u '+%F %T') wp-cli introuvable, abandon" >> "$LOG"
    else
        echo "$(date -u '+%F %T') wp-cli introuvable, abandon, ET MAIL NON ENVOYE" >> "$LOG"
    fi
    exit 1
fi

# Les listes de mises a jour sont mises en cache en base, et www-data lit
# wp-config.php donc dispose des acces MySQL : il peut y ecrire l'URL d'archive
# de son choix, que root installerait. On force une resolution reelle.
wp option delete _site_transient_update_plugins --allow-root >/dev/null 2>&1
wp option delete _site_transient_update_core --allow-root >/dev/null 2>&1
wp option delete _site_transient_update_themes --allow-root >/dev/null 2>&1

dispo=$(wp plugin list --update=available --field=name --allow-root 2>/dev/null)
coeur=$(wp core check-update --minor --field=version --allow-root 2>/dev/null | grep -E "^[0-9]+\." | head -1)

if [ -z "$dispo" ] && [ -z "$coeur" ]; then
    echo "$(date -u '+%F %T') rien a mettre a jour" >> "$LOG"
    exit 0
fi

RAPPORT="Mises a jour appliquees sur example.com le $(date -u '+%F a %H:%M UTC').
"

# Sauvegarde de la base avant toute operation. Sans elle on ne met rien a jour :
# un filet de securite qu'on croit tendu et qui ne l'est pas vaut moins que pas
# de filet du tout, parce qu'on prend le risque en pensant etre couvert.
mkdir -p /root/backups
DUMP="/root/backups/db-avant-maj-$(date -u +%Y%m%dT%H%M%S).sql.gz"
set -o pipefail
mysqldump --single-transaction wordpress_prod 2>/dev/null | gzip > "$DUMP"
etat_dump=$?
set +o pipefail
if [ "$etat_dump" -ne 0 ] || [ "$(stat -c %s "$DUMP" 2>/dev/null || echo 0)" -lt 100000 ]; then
    rm -f "$DUMP"
    if printf '%s' "Mise a jour ANNULEE sur example.com le $(date -u '+%F a %H:%M UTC').

La sauvegarde de la base a echoue, ou le fichier produit est trop petit pour
etre credible. Rien n'a ete mis a jour : on ne touche pas au site sans point
de retour. Verifier MySQL et l'espace disque, puis relancer /root/maj.sh." \
        | mail -s "[MAJ] ECHEC sauvegarde, mise a jour annulee" -a "From: $FROM" "$DEST"; then
        echo "$(date -u '+%F %T') sauvegarde impossible, mise a jour annulee" >> "$LOG"
    else
        echo "$(date -u '+%F %T') sauvegarde impossible, annulee, ET MAIL NON ENVOYE" >> "$LOG"
    fi
    exit 1
fi
find /root/backups -name 'db-avant-maj-*.sql.gz' -mtime +30 -delete 2>/dev/null

# Le piege est arme AVANT de poser le temoin : entre les deux, une interruption
# laisserait un temoin orphelin, et la surveillance se tairait 15 minutes sur une
# panne reelle du site.
remettre_le_verrou() {
    sed -i "s/define( 'DISALLOW_FILE_MODS', false );/define( 'DISALLOW_FILE_MODS', true );/" "$SITE/wp-config.php"
    rm -f "$STATE/maj-en-cours"
}
trap remettre_le_verrou EXIT INT TERM

# Temoin lu par la surveillance : pendant cette fenetre, un site qui ne repond
# pas est normal. Le temoin est date, il perime tout seul en 15 minutes.
mkdir -p "$STATE"; touch "$STATE/maj-en-cours"

# upgrade/ et upgrade-temp-backup/ doivent appartenir a root AVANT que
# l'upgrader WordPress y travaille : sinon www-data y pose un lien symbolique,
# que l'upgrader suit en root en vidant le repertoire, ce qui efface la cible.
# On DEPLACE puis on recree : un rm -rf peut echouer sans le dire si www-data
# recree des entrees en boucle, et install -d ne corrige alors que la racine.
for d in upgrade upgrade-temp-backup; do
    if [ -e "${SITE:?}/wp-content/$d" ]; then
        # Hors de la racine web : le rm -rf qui suit peut echouer si www-data
        # y ecrit en boucle, et un arbre qui lui appartient sous wp-content
        # serait un repertoire executable de plus, invisible du controle 3.
        mv "${SITE:?}/wp-content/$d" "/root/upgrade-ancien-$(date -u +%Y%m%dT%H%M%S)-$d" 2>/dev/null
    fi
    install -d -o root -g www-data -m 750 "$SITE/wp-content/$d"
    if [ -n "$(find "$SITE/wp-content/$d" -mindepth 1 -print -quit 2>/dev/null)" ]; then
        echo "$(date -u '+%F %T') reprise de $d impossible, mise a jour annulee" >> "$LOG"
        printf '%s' "Impossible de reprendre wp-content/$d a www-data avant la mise a jour.
Quelque chose y ecrit en continu. Mise a jour annulee : la faire dans ces
conditions exposerait a un effacement en root. A regarder a la main." \
            | mail -s "[MAJ] reprise de $d impossible, mise a jour annulee" -a "From: $FROM" "$DEST"
        exit 1
    fi
done
rm -rf /root/upgrade-ancien-* 2>/dev/null
reste=$(find /root -maxdepth 1 -name 'upgrade-ancien-*' -print -quit 2>/dev/null)
[ -n "$reste" ] && RAPPORT="$RAPPORT
ATTENTION : d'anciens repertoires upgrade n'ont pas pu etre supprimes ($reste).
Quelque chose y ecrit en continu, a regarder a la main.
"

# La purge des transients est refaite ICI, juste avant l'installation : faite
# plus haut, elle laissait plusieurs minutes a www-data (qui lit wp-config.php,
# donc les acces MySQL) pour reecrire l'URL d'archive que root installerait.
wp option delete _site_transient_update_plugins --allow-root >/dev/null 2>&1
wp option delete _site_transient_update_core --allow-root >/dev/null 2>&1

# DISALLOW_FILE_MODS bloque aussi wp-cli : on le neutralise le temps de l'operation.
sed -i "s/define( 'DISALLOW_FILE_MODS', true );/define( 'DISALLOW_FILE_MODS', false );/" wp-config.php

if [ -n "$dispo" ]; then
    RAPPORT="$RAPPORT
## Extensions
$(wp plugin update --all --allow-root 2>&1 | tail -20)
"
fi
if [ -n "$coeur" ]; then
    RAPPORT="$RAPPORT
## Coeur WordPress (branche courante)
$(wp core update --minor --allow-root 2>&1 | tail -6)
$(wp core update-db --allow-root 2>&1 | tail -2)
"
fi

remettre_le_verrou

# Les fichiers ecrits par root doivent rester lisibles par www-data
chown -R root:www-data "$SITE/wp-admin" "$SITE/wp-includes" "$SITE/wp-content/plugins" 2>/dev/null
find "$SITE/wp-admin" "$SITE/wp-includes" "$SITE/wp-content/plugins" -type d -exec chmod 750 {} + 2>/dev/null
find "$SITE/wp-admin" "$SITE/wp-includes" "$SITE/wp-content/plugins" -type f -exec chmod 640 {} + 2>/dev/null

# Verification. Les resultats CONDITIONNENT la suite : on ne fige pas une
# reference sur un etat qu'on n'a pas su verifier.
coeur_ok=0; ext_ok=0
v_coeur=$(wp core verify-checksums --allow-root --skip-plugins --skip-themes 2>&1)
echo "$v_coeur" | grep -q "^Success:" && coeur_ok=1
v_ext=$(wp plugin verify-checksums --all --allow-root 2>&1)
echo "$v_ext" | grep -q "^Success:" && ext_ok=1
RAPPORT="$RAPPORT
## Verification apres mise a jour
$(echo "$v_coeur" | tail -3)
$(echo "$v_ext" | tail -2)
"
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 40 https://www.example.com/)
RAPPORT="$RAPPORT
Page d'accueil apres mise a jour : HTTP $code
"

# La reference de surveillance ne se reconstruit QUE sur un etat verifie. Sinon
# elle absorberait ce que la mise a jour a pu laisser derriere, et la
# surveillance ne le verrait plus jamais.
if [ "$coeur_ok" = "1" ] && [ "$ext_ok" = "1" ] && [ "$code" = "200" ]; then
    /root/surveille.sh --rebaseline >/dev/null 2>&1
    RAPPORT="$RAPPORT
Empreinte de surveillance reconstruite (coeur et extensions conformes, site en 200).
"
    sujet="[MAJ] example.com"
else
    RAPPORT="$RAPPORT
ATTENTION : empreinte de surveillance NON reconstruite.
  coeur conforme : $([ "$coeur_ok" = 1 ] && echo oui || echo NON)
  extensions conformes : $([ "$ext_ok" = 1 ] && echo oui || echo NON)
  page d'accueil : HTTP $code
La surveillance va donc signaler les fichiers mis a jour comme des ecarts, ce qui
est voulu : il faut regarder avant de valider. Une fois l'etat verifie a la main,
lancer /root/surveille.sh --rebaseline.
Restauration possible depuis /root/golden/ et les dumps /root/backups/.
"
    sujet="[MAJ] ATTENTION : verification en echec"
fi

if printf '%s' "$RAPPORT" | mail -s "$sujet" -a "From: $FROM" "$DEST"; then
    echo "$(date -u '+%F %T') mises a jour appliquees, HTTP $code, rebaseline=$([ "$coeur_ok$ext_ok$code" = "11200" ] && echo oui || echo non)" >> "$LOG"
else
    echo "$(date -u '+%F %T') mises a jour appliquees mais COMPTE RENDU NON ENVOYE" >> "$LOG"
fi

Enfin la surveillance. Dix controles, dont un seul regarde les journaux, et il n'envoie jamais de mail. Le courrier est reserve a ce qui a reussi : un fichier apparu, un fichier modifie, une valeur changee en base.

#!/bin/bash
# Surveillance du site example.com — n'envoie un mail QUE s'il y a une anomalie.
# Installe par le cron. Ne rien afficher en cas de normalite : le silence est le succes.

RACINE=/var/www/example.com
SITE="$RACINE/public"
STATE=/var/lib/site-watch
DEST=alerte@example.com
FROM=surveillance@example.com
HOST=$(hostname)
mkdir -p "$STATE"
chmod 700 "$STATE"

ALERTS=""
add() { ALERTS="${ALERTS}
## $1
$2
"; }

# Repertoires que le serveur web peut ecrire. Tout le reste appartient a root.
# upgrade/ et upgrade-temp-backup/ n'y sont PLUS : ils appartiennent a root,
# sinon www-data peut y poser un lien symbolique que l'upgrader WordPress suit
# en root au moment de la mise a jour hebdomadaire (efface la cible du lien).
ZONES="uploads cache uploads-webpc smush-webp w3tc-config"

# Sous-dossiers ou W3TC ecrit legitimement des .php generes, en flot continu.
# Ancres au chemin reel : un motif en */cache/* laisserait passer un
# wp-content/upgrade/cache/shell.php, que l'attaquant nomme comme il veut.
GENERES="$SITE/wp-content/cache/db $SITE/wp-content/cache/object
$SITE/wp-content/cache/page_enhanced $SITE/wp-content/cache/minify
$SITE/wp-content/cache/fragment $SITE/wp-content/cache/stats
$SITE/wp-content/cache/tmp $RACINE/node_modules $RACINE/.git"

# Un seul passage a la fois. Sans ce verrou, un `wp` bloque sur un reseau
# degrade laisse demarrer le passage suivant, et deux instances ecrivent les
# memes fichiers d'etat. Le mode --rebaseline en est exempte : il est appele
# par le script de mise a jour, qui tournerait alors dans le vide.
if [ "${1:-}" != "--rebaseline" ]; then
    exec 9>"$STATE/.verrou"
    if ! flock -n 9; then
        # Sortir en silence ici, c'est faire passer "je n'ai rien verifie" pour
        # "tout va bien". Un passage bloque doit se voir, et au-dela d'une heure
        # il faut le dire par mail : la surveillance est morte sans le dire.
        debut=$(cat "$STATE/.verrou-debut" 2>/dev/null || echo 0)
        age=$(( $(date +%s) - debut ))
        echo "$(date -u '+%F %T') passage saute : verrou tenu depuis ${age}s" >> /var/log/surveillance.log
        if [ "$debut" -gt 0 ] && [ "$age" -gt 3600 ]; then
            printf 'La surveillance de %s est bloquee depuis %s secondes.\nAucun controle ne tourne plus. Voir /var/log/surveillance.log\n' \
                "$HOST" "$age" | mail -s "[ALERTE] surveillance bloquee" -a "From: $FROM" "$DEST"
        fi
        exit 0
    fi
    date +%s > "$STATE/.verrou-debut"
fi

# Construit l'expression d'exclusion de find a partir de $GENERES.
exclusions() {
    local d
    for d in $GENERES; do printf '%s\n' "-not" "-path" "$d/*"; done
}

# L'empreinte : tout ce qui est code, hors flots generes.
# empreinte <fichier-de-sortie> : rend non nul si le hachage a ete tronque.
# Sans ce statut, un timeout rendait une liste amputee avec un succes apparent,
# et --rebaseline la figeait comme reference : tout ce qui est au-dela du point
# de coupure n'etait alors plus compare a rien, sans un mot.
empreinte() {
    local ex=() st
    mapfile -t ex < <(exclusions)
    # -type f est vital : sans lui, find retient aussi un tube nomme, et
    # sha256sum s'y bloque indefiniment. Combine au verrou plus bas, cela
    # arretait la surveillance pour de bon, en silence. Verifie le 11/08/2026.
    # LC_ALL=C : `comm` exige que les deux cotes soient tries pareil, et un
    # rebaseline lance a la main depuis un shell en fr_FR ne trie pas comme le cron.
    find "$RACINE" -type f \( -name "*.php" -o -name ".htaccess" \
        -o -path "*/themes/montheme/*.js" \
        -o -path "*/themes/montheme/*.css" \) \
        "${ex[@]}" -print0 2>/dev/null \
        | timeout 300 xargs -0 sha256sum 2>/dev/null > "$1"
    st=${PIPESTATUS[1]}
    LC_ALL=C sort -o "$1" "$1"
    return "$st"
}

# Les valeurs de base qui ne changent jamais toutes seules.
sentinelle_db() {
    mysql -N -r wordpress_prod 2>/dev/null -e "
SELECT CONCAT('option:', option_name, '=', LEFT(option_value, 400))
  FROM wp_options
 WHERE option_name IN ('siteurl','home','admin_email','users_can_register',
                       'default_role','template','stylesheet','active_plugins')
 ORDER BY option_name;
SELECT CONCAT('user:', u.ID, ':', u.user_login, ':', u.user_email, ':', IFNULL(m.meta_value,''))
  FROM wp_users u
  LEFT JOIN wp_usermeta m ON m.user_id = u.ID AND m.meta_key = 'wp_capabilities'
 ORDER BY u.ID;
SELECT CONCAT('apppass:', COUNT(*)) FROM wp_usermeta WHERE meta_key = '_application_passwords';
SELECT CONCAT('mu-plugin-option:', COUNT(*)) FROM wp_options WHERE option_value REGEXP 'eval\\\\(|base64_decode|gzinflate';
"
}

# --- 1. Integrite du coeur WordPress -----------------------------------------
# On interroge wordpress.org pour la version REELLEMENT installee : une archive
# de reference figee devient fausse des la premiere mise a jour du coeur.
if command -v wp >/dev/null; then
    # --skip-plugins/--skip-themes : sans eux, root execute le code des
    # extensions du site a chaque passage. timeout : un wordpress.org
    # injoignable ne doit pas laisser le script pendu jusqu'au passage suivant.
    chk=$(cd "$SITE" && timeout 90 wp core verify-checksums --allow-root --skip-plugins --skip-themes 2>&1)
    if echo "$chk" | grep -q "^Success:"; then
        :   # coeur conforme
    elif echo "$chk" | grep -qiE "couldn't fetch|failed to|could not resolve|error establishing"; then
        :   # wordpress.org injoignable : on ne crie pas au loup
    else
        add "Integrite du coeur WordPress" "$(echo "$chk" | grep -vE '^(Success|Warning: Could not)' | head -30)"
    fi
else
    # Un controle qui disparait en silence est pire qu'un controle absent :
    # on croit etre surveille alors qu'on ne l'est plus.
    add "Controle du coeur impossible : wp-cli introuvable" "Reinstaller wp-cli, sinon l'integrite du coeur n'est plus verifiee."
fi

# --- 2. Tout PHP absent de l'empreinte de reference ---------------------------
# Reference etablie apres nettoyage. Apres une mise a jour WordPress legitime,
# la reconstruire avec : /root/surveille.sh --rebaseline
BASE="$STATE/baseline-php.sha256"
DBREF="$STATE/db-sentinelle.txt"
if [ "${1:-}" = "--rebaseline" ]; then
    neuve=$(mktemp)
    if ! empreinte "$neuve"; then
        rm -f "$neuve"
        echo "Hachage interrompu (delai depasse) : reference NON remplacee"
        exit 1
    fi
    # Une reference qui maigrit brutalement n'est pas une reference, c'est un
    # accident. On refuse plutot que de figer un inventaire amoindri.
    ancien=$(wc -l < "$BASE" 2>/dev/null || echo 0)
    if [ "$ancien" -gt 0 ] && [ "$(wc -l < "$neuve")" -lt $(( ancien * 8 / 10 )) ]; then
        echo "Empreinte amputee de plus de 20% ($(wc -l < "$neuve") contre $ancien) : reference NON remplacee"
        rm -f "$neuve"
        exit 1
    fi
    mv "$neuve" "$BASE"
    # La sentinelle base se reconstruit ICI aussi. Auparavant le script sortait
    # avant de l'atteindre : le "lancer --rebaseline" affiche dans le mail
    # d'alerte ne faisait donc rien, et la meme alerte revenait tous les jours.
    db=$(sentinelle_db)
    if [ -n "$db" ]; then
        printf '%s\n' "$db" > "$DBREF"
        echo "Reference reconstruite : $(wc -l < "$BASE") fichiers, sentinelle base a jour"
    else
        echo "Reference fichiers reconstruite, mais la base n'a pas repondu : sentinelle NON mise a jour"
        exit 1
    fi
    exit 0
fi
if [ -f "$BASE" ]; then
    current=$(mktemp)
    if ! empreinte "$current"; then
        add "Empreinte incomplete" "Le hachage des fichiers a depasse le delai imparti.
Aucune comparaison fiable n'a pu etre faite ce passage : la surveillance des
fichiers est aveugle tant que ce n'est pas regle."
    fi
    drift=$(comm -13 "$BASE" "$current" | awk '{print $2}' | head -30)
    # `gone` se calcule sur les CHEMINS seuls. Sur les lignes completes
    # (empreinte + chemin), un fichier simplement modifie sortait a la fois en
    # "nouveau" et en "disparu" : l'alerte annoncait la perte d'un fichier
    # qu'on venait en realite d'infecter.
    gone=$(comm -23 <(awk '{print $2}' "$BASE" | LC_ALL=C sort) <(awk '{print $2}' "$current" | LC_ALL=C sort) | head -10)
    rm -f "$current"
    [ -n "$drift" ] && add "Fichiers nouveaux ou modifies depuis la reference" "$drift"
    [ -n "$gone" ] && add "Fichiers disparus depuis la reference" "$gone"
else
    add "Empreinte de reference absente" "Lancer : /root/surveille.sh --rebaseline"
fi

# --- 3. Executable depose dans une zone inscriptible -------------------------
# Un seul controle pour toutes les zones que www-data peut ecrire. L'ancienne
# version ne regardait que uploads/ et cache/, et exemptait des sous-dossiers
# par motif : upgrade/node_modules/x.php ou cache/minify/x.php passaient donc
# entre les mailles. Ici les seules exemptions sont ancrees et nommees.
zones=""
for z in $ZONES; do [ -d "$SITE/wp-content/$z" ] && zones="$zones $SITE/wp-content/$z"; done
if [ -n "$zones" ]; then
    # shellcheck disable=SC2086
    intrus=$(find $zones -type f \( -iname "*.php" -o -iname "*.php[0-9]" -o -iname "*.phtml" \
                -o -iname "*.pht" -o -iname "*.phps" -o -iname "*.cgi" -o -iname "*.pl" \
                -o -iname "*.py" -o -iname "*.sh" \) 2>/dev/null \
        | while read -r f; do
              # W3TC ecrit des caches serialises nommes en md5, dans ces dossiers seuls
              case "$f" in
                "$SITE/wp-content/cache/db/"*|"$SITE/wp-content/cache/object/"*|"$SITE/wp-content/cache/fragment/"*|"$SITE/wp-content/cache/stats/"*)
                    # Le nom md5 ne prouve rien, l'attaquant le choisit aussi.
                    # Un cache W3TC commence par la garde qui empeche son inclusion.
                    head -c 16 "$f" 2>/dev/null | grep -q '<?php exit' && continue ;;
                "$SITE/wp-content/w3tc-config/master.php") continue ;;
              esac
              # Un index.php de garde ne contient aucune primitive. Exempter sur la
              # seule taille laissait passer un webshell de 23 octets.
              case "$f" in
                */index.php)
                    # shellcheck disable=SC2016
                    grep -qEi '\$_(GET|POST|REQUEST|COOKIE)|eval|assert|base64_decode|gzinflate|str_rot13|system|exec|passthru|shell_exec|popen|proc_open|create_function|file_put_contents|move_uploaded_file|preg_replace|include|require' "$f" 2>/dev/null \
                        || continue ;;
              esac
              printf '%s\n' "$f"
          done | head -20)
    [ -n "$intrus" ] && add "Executable depose dans une zone inscriptible" "$intrus"

    # Les controles ci-dessus se limitent aux fichiers reguliers, sans quoi un
    # tube nomme fige sha256sum. Ce qui n'est ni fichier ni repertoire n'a rien
    # a faire ici, et sert justement a bloquer ce qui vient lire.
    # shellcheck disable=SC2086
    bizarres=$(find $zones ! -type f ! -type d 2>/dev/null | head -10)
    [ -n "$bizarres" ] && add "Fichier de type inattendu dans une zone inscriptible" "$bizarres
(tube nomme, socket ou peripherique : sert a bloquer les outils qui les lisent)"

    # Pas de controle par mots-cles sur les .htaccess : essaye, et tu signales
    # les directives de durcissement en meme temps que celles qui ouvrent, plus
    # celles que W3TC ecrit lui-meme dans minify/ et page_enhanced/. Les
    # .htaccess stables des zones inscriptibles sont dans l'empreinte, donc
    # surveilles par leur contenu ; et un .htaccess qui reactiverait PHP ne sert
    # a rien sans un fichier a executer, que le controle ci-dessus attrape.
fi

# --- 4. mu-plugins doit rester vide ------------------------------------------
mu=$(find "$SITE/wp-content/mu-plugins" -type f 2>/dev/null | head -10)
[ -n "$mu" ] && add "Fichiers presents dans mu-plugins (chargement automatique, sans desactivation possible)" "$mu"

# --- 4bis. L'extension du vecteur d'aout 2026 doit rester absente ------------
# On ne guette plus l'appel `wpmudev-hub` dans les logs : l'extension etant
# desinstallee, le parametre ne fait rien et la page d'accueil repond 200 a
# chaque passage de robot, soit deux alertes par jour sans objet. On surveille
# desormais la condition qui rendrait l'attaque possible, pas la tentative.
wpmu=$(find "$SITE/wp-content/plugins" -maxdepth 1 \( -iname "*wpmudev*" -o -iname "*wpmu-dev*" \) 2>/dev/null | head -3)
[ -n "$wpmu" ] && add "L'extension WPMU DEV est reapparue (vecteur de l'intrusion du 07/08/2026)" "$wpmu
Verifier la version : les 5.0.0 et anterieures portent CVE-2026-15459."

# --- 5. Tentatives d'exploitation : journalisees, JAMAIS envoyees par mail ----
# Une tentative n'est pas un evenement, c'est le bruit de fond d'internet : des
# robots essaient toutes les URL connues sur tous les sites, en permanence. Le
# mail est reserve a ce qui a REUSSI, c'est-a-dire un fichier nouveau, un
# fichier modifie ou une valeur changee en base (controles 1, 2, 3, 3bis, 4,
# 4bis et 10). La trace reste dans /var/log/tentatives.log pour l'enquete.
# On ne journalise que ce qui ne l'a jamais ete : sans cela, les memes
# evenements ressortent a chaque passage tant qu'ils sont dans la fenetre.
# TRUSTED : adresses d'administration, exclues pour ne pas signaler nos propres tests.
TRUSTED="203.0.113.10 203.0.113.10 203.0.113.10"
SEEN="$STATE/last-exploit-epoch"
last_epoch=$(cat "$SEEN" 2>/dev/null || echo 0)
max_epoch=$last_epoch

# Deux familles de motifs, deux exigences.
# CIBLE : ce qui vise ce site-ci — le vecteur d'aout 2026 et les webshells alors
# poses. Signale meme en echec (403, 404, 503) : y toucher suppose de connaitre
# son histoire. Deux exceptions, ou la requete n'a jamais atteint WordPress : le
# 301 http->https, qui revient aussitot en https et serait donc compte deux fois,
# et le 421, ou le nom demande ne correspond pas au certificat presente.
# BALAYAGE : scanners qui ratissent tout l'internet, des dizaines par jour, tous
# en 404. Signale seulement si le serveur a repondu autre chose qu'un refus. Une
# alerte quotidienne sans objet finit par ne plus etre lue, et c'est ce jour-la
# que la vraie passe inapercue.
# Volontairement absent : l'injection d'arguments PHP-CGI (auto_prepend_file,
# allow_url_include). PHP tourne en mod_php, la page d'accueil repond donc 200 a
# chaque tentative alors qu'aucune ne s'execute — verifie le 10/08/2026 avec un
# marqueur qui n'est jamais ressorti. Si une telle injection aboutissait un jour,
# elle laisserait un fichier, et c'est le controle 2 qui le verrait.
CIBLE="\?action=[A-Za-z0-9]{15,}|shell1\.php|shell2\.php|shell3"
BALAYAGE="eval-stdin|/bin/sh|/\.env|/\.git/|phpinfo|wp-config\.php[.~]|/shell|alfa-?rex|/xmlrpc\.php"
# Fichiers de travail dans $STATE (root, 700) et non dans /tmp : root y ecrit,
# et /tmp est inscriptible par www-data. Le noyau bloque deja le detournement
# par lien symbolique, mais un script de securite ne se repose pas sur un sysctl.
ACCES="$STATE/acces-du-jour.txt"

YDAY=$(date -u -d "yesterday" "+%d/%b/%Y")
TODAY=$(date -u "+%d/%b/%Y")
expl=""
while IFS= read -r line; do
    [ -z "$line" ] && continue
    ip=${line%% *}
    case " $TRUSTED " in *" $ip "*) continue ;; esac
    ts=$(printf '%s' "$line" | grep -oE '[0-9]{2}/[A-Za-z]{3}/[0-9]{4}:[0-9:]{8}')
    [ -z "$ts" ] && continue
    ep=$(date -u -d "$(printf '%s' "$ts" | tr ':' ' ' | awk '{print $1" "$2":"$3":"$4}' | sed 's|/| |g')" +%s 2>/dev/null || echo 0)
    [ "$ep" -le "$last_epoch" ] && continue
    [ "$ep" -gt "$max_epoch" ] && max_epoch=$ep
    expl="${expl}${line}
"
done <<EOF
$(
    grep -ahE "$YDAY|$TODAY" /var/log/apache2/*access*.log 2>/dev/null \
        | sed -E 's/^[a-z0-9.-]*lesmontheme\.com:[0-9]+ //' > "$ACCES"
    {
        # Le statut se lit APRES le guillemet fermant de la requete, pas au
        # champ 9 : l'attaquant choisit sa ligne de requete, donc le nombre de
        # champs, et pouvait faire tomber un code exclu en $9 pour disparaitre.
        # Le statut est le premier champ APRES le guillemet fermant de la
        # requete. Apache echappe les guillemets internes en \" : on les retire
        # avant de decouper, sinon l'attaquant place un faux statut dans sa
        # propre ligne de requete pour se faire ecarter du journal.
        # shellcheck disable=SC2016
        statut='{ s=$0; gsub(/\\"/, "", s); n=split(s, q, "\""); if (n>=3) { split(q[3], c, " "); st=c[1] } else st="" }'
        grep -aiE "$CIBLE" "$ACCES" | awk "$statut"' st !~ /^(301|302|421)$/ { print }'
        grep -aiE "$BALAYAGE" "$ACCES" | awk "$statut"' st !~ /^(301|302|400|401|403|404|408|421|429|503)$/ { print }'
    } | awk '{printf "%-16s %s %s %s %s\n", $1, $4, $6, $7, $9}' | sed 's/\[//' | sort -u | head -40
    rm -f "$ACCES"
)
EOF
echo "$max_epoch" > "$SEEN"
if [ -n "$expl" ]; then
    {
        echo "===== $(date -u '+%F %T UTC') ====="
        printf '%s' "$expl"
    } >> /var/log/tentatives.log
    # Personne ne purge ce fichier : on le borne ici, sinon il grossit sans fin.
    if [ "$(stat -c %s /var/log/tentatives.log 2>/dev/null || echo 0)" -gt 5000000 ]; then
        tail -n 5000 /var/log/tentatives.log > /var/log/tentatives.log.tmp \
            && mv /var/log/tentatives.log.tmp /var/log/tentatives.log
    fi
fi

# --- 6. Le site repond-il ? ---------------------------------------------------
# Seconde tentative avant de crier : un rechargement d Apache ou un incident
# reseau passager ne doit pas declencher une alerte.
verifier_site() {
    : > "$STATE/derniere-page.html"
    curl -s -o "$STATE/derniere-page.html" -w "%{http_code}" --max-time 25 \
         -A "Mozilla/5.0 (surveillance)" https://www.example.com/ 2>/dev/null
}
code=$(verifier_site)
if [ "$code" != "200" ]; then
    sleep 20
    code=$(verifier_site)
fi
size=$(stat -c %s "$STATE/derniere-page.html" 2>/dev/null || echo 0)
# Le 503 etait auparavant traite comme un etat sain, vestige de la periode de
# maintenance : un site durablement en panne ne declenchait donc jamais rien.
# Seule exception legitime desormais, la fenetre de mise a jour, qui pose un
# temoin daté ; au-dela de 15 minutes il est perime et l'alerte repart.
maj_en_cours=0
if [ -f "$STATE/maj-en-cours" ] \
   && [ $(( $(date +%s) - $(stat -c %Y "$STATE/maj-en-cours" 2>/dev/null || echo 0) )) -lt 900 ]; then
    maj_en_cours=1
fi
if [ "$code" != "200" ] && [ "$maj_en_cours" = "1" ]; then
    :   # mise a jour en cours, on laisse passer
elif [ "$code" != "200" ]; then
    add "Le site ne repond pas normalement" "code HTTP = $code, taille = $size octets"
elif [ "$code" = "200" ] && [ "$size" -lt 5000 ]; then
    add "Le site repond 200 mais la page est quasi vide" "taille = $size octets (une page vide en 200 est exactement le symptome de l'incident d'aout 2026)"
fi

# --- 7. Cloaking : le contenu doit etre identique pour un robot et un humain --
if [ "$code" = "200" ]; then
    sbot=$(curl -s -o /dev/null -w "%{size_download}" --max-time 25 \
        -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
        https://www.example.com/ 2>/dev/null)
    if [ -n "$sbot" ] && [ "$sbot" -gt 0 ] 2>/dev/null; then
        diff=$(( sbot > size ? sbot - size : size - sbot ))
        limit=$(( size / 5 ))
        [ "$diff" -gt "$limit" ] && add "Contenu different selon le visiteur (cloaking possible)" \
            "navigateur = $size octets, Googlebot = $sbot octets"
    fi
fi

# --- 8. Services et disque ----------------------------------------------------
for svc in apache2 mysql; do
    systemctl is-active --quiet "$svc" || add "Service arrete" "$svc"
done
use=$(df --output=pcent / | tail -1 | tr -dc '0-9')
[ "${use:-0}" -gt 85 ] && add "Espace disque" "partition / occupee a ${use} %"

# --- 9. Resultat du scan ClamAV hebdomadaire (lu, pas execute ici) ------------
# Le scan lui-meme tourne dans /root/scan-antivirus.sh (cron hebdomadaire) : trop lent
# pour etre lance a chaque passage.
if [ -f "$STATE/clamscan-last.txt" ]; then
    hits=$(grep -a "FOUND" "$STATE/clamscan-last.txt" 2>/dev/null | head -20)
    [ -n "$hits" ] && add "Detections ClamAV (dernier scan hebdomadaire)" "$hits"
fi

# --- 10. Sentinelle base de donnees -------------------------------------------
# Un attaquant ayant l'acces base peut agir sans toucher un seul fichier :
# c'est ainsi qu'a ete active le faux plugin le 7 aout. On surveille donc les
# valeurs qui comptent, pas la base entiere.
dbnow=$(sentinelle_db)

if [ -n "$dbnow" ]; then
    if [ -f "$DBREF" ]; then
        dbdiff=$(diff "$DBREF" <(printf '%s\n' "$dbnow") 2>/dev/null | grep -E '^[<>]' | head -20)
        [ -n "$dbdiff" ] && add "Base de donnees : valeur sensible modifiee" "$dbdiff
(< = valeur de reference, > = valeur actuelle. Si le changement est legitime :
 /root/surveille.sh --rebaseline)"
    else
        # On n'ecrit PAS la reference ici. L'empreinte fichiers ne le fait pas
        # non plus : une reference posee sans regard humain, sur un etat peut-etre
        # deja compromis, rend la surveillance complice. On alerte jusqu'a ce
        # qu'un humain regarde et lance --rebaseline.
        add "Sentinelle base de donnees absente" "Aucune reference n'existe pour les comptes et les options.
Verifier a la main que les comptes administrateurs et les extensions actives sont
ceux attendus, PUIS lancer : /root/surveille.sh --rebaseline"
    fi
else
    # Muette quoi qu'il arrive, reference ou pas : si elle ne rend rien des le
    # premier passage, elle ne s'initialise jamais et ne se plaint jamais.
    add "Sentinelle base de donnees muette" "La requete de controle ne rend rien.
Verifier que MySQL repond et que les acces de root sont valides, sinon les
comptes et les options ne sont plus surveilles du tout."
fi

# --- Envoi --------------------------------------------------------------------
if [ -n "$ALERTS" ]; then
    # Deduplication : ne pas re-signaler une anomalie identique avant 12 h,
    # sinon une anomalie persistante inonde la boite mail.
    sig=$(printf '%s' "$ALERTS" | sha256sum | cut -d' ' -f1)
    last="$STATE/last-alert-$sig"
    if [ -f "$last" ] && [ $(( $(date +%s) - $(stat -c %Y "$last") )) -lt 43200 ]; then
        echo "$(date -u '+%F %T') anomalie identique deja signalee, mail supprime" >> /var/log/surveillance.log
        exit 0
    fi
    find "$STATE" -name 'last-alert-*' -mtime +2 -delete 2>/dev/null
    touch "$last"
    # Trace locale systematique : l'alerte survit meme si l'envoi echoue.
    {
        echo "===== $(date -u '+%F %T UTC') ====="
        echo "$ALERTS"
    } >> /var/log/alertes.log
    {
        echo "Anomalies detectees sur $HOST ($(date -u '+%Y-%m-%d %H:%M UTC'))."
        echo "Site : https://www.example.com/"
        echo "Serveur : 203.0.113.10"
        echo "$ALERTS"
        echo
        echo "--"
        echo "Surveillance automatique installee apres l'incident du 10 aout 2026."
        echo "Script : /root/surveille.sh — journal : /var/log/surveillance.log"
        echo "Ce mail ne part que si un fichier ou une valeur en base a change."
        echo "Les tentatives d'URL sont journalisees sans alerte : /var/log/tentatives.log"
    # Sans ce test, le journal ecrivait "ALERTE envoyee" meme quand le MTA
    # etait tombe : on se croyait prevenu sans l'avoir ete.
    } | if mail -s "[ALERTE] example.com" -a "From: $FROM" "$DEST"; then
        echo "$(date -u '+%F %T') ALERTE envoyee" >> /var/log/surveillance.log
    else
        echo "$(date -u '+%F %T') ALERTE NON ENVOYEE (echec du mail) - voir /var/log/alertes.log" >> /var/log/surveillance.log
    fi
else
    echo "$(date -u '+%F %T') OK" >> /var/log/surveillance.log
fi

Une piece n'est pas dans ce tableau et ne peut pas y etre : la cadence des sauvegardes cote hebergeur. Une par semaine sur 28 jours, c'est quatre points de retour, dont aucun n'est garanti sain. Aucun script ne rattrape ca.

Ce qui a vraiment changé quelque chose

J'ai mené cette remédiation avec Claude en binôme, et je lui ai demandé de noter la sécurité du site avant et après : 70, puis 92. Autant le dire tout de suite, ce chiffre ne veut rien dire. Il additionne des mesures qui n'ont pas le même poids, et il est produit par celui-là même qui a fait le travail. Il sert à une seule chose, et je le garde pour ça : vérifier qu'on avance dans le bon sens.

Ce qui mérite d'être retenu, c'est la répartition. L'antivirus n'a rien vu. Le grep sur les fonctions dangereuses n'a rien vu. Les dates de modification mentaient activement. Les seuls contrôles qui ont produit de la connaissance sont ceux qui comparaient l'installation à une référence extérieure : les empreintes officielles du cœur, et le ctime que l'attaquant ne sait pas réécrire. Tout le reste m'a fait perdre du temps.

Et le seul changement qui aurait empêché l'intrusion n'est ni un antivirus, ni une empreinte, ni une règle de détection. C'est une ligne de permissions. Reconnaître une attaque précise, ça se contourne en changeant d'attaque. Rendre une classe entière d'attaques impossible, ça ne se contourne pas.

La détection sert à savoir. C'est l'architecture qui protège.

Commentaires (0)