Le dimanche 17 août 2025, mon blog a fait l'objet d'un incident informatique.
Tellement c'était naïf et dénué de finalité, j'hésite à parler d'attaque informatique, de DDoS, et autres termes galvaudés.
Ce fut l'occasion de réviser les commandes du pare-feu de Linux ainsi que fail2ban
.
2025-08-17T08:09:15 <IP_CENSURÉE> - - "GET http://www.guiguishow.info/page-sitemap.xml HTTP/1.1" 200 921 "-" "python-requests/2.32.3"
Ce site web est hébergé sur une machine virtuelle (VPS) louée à OVH. L'anti-DDoS de ce dernier ne s'est pas activé. Je sais qu'on peut le déclencher soi-même, mais moins un prestataire, quel qu'il soit, se mêle de mes affaires selon des critères obscurs, mieux je me porte.
Dans le feu de l'action, il est recommandé d'utiliser uniquement ce que l'on connaît. C'est une des raisons pour lesquelles je n'ai pas utilisé le fameux Anubis que tout le monde s'arrache (les autres raisons : disproportionné par rapport à l'incident ; pas envie d'avoir un logiciel de plus à administrer).
J'ai commencé à intervenir un peu moins d'une heure après le début de l'incident.
Mon shaarli était un poil plus lent que d'habitude mais sans plus. La charge du système était d'environ 10. Pour une machine dotée d'un seul processeur virtuel. Le système était donc surchargé.
Les ressources web demandées, page-sitemap.xml, post-sitemap.xml, et sitemap-misc.xml, sont générées à la volée à chaque requête. Donc PHP et MariaDB sont mis en branle.
Il faut agir sur ce fait, soit en transformant ces ressources en pages web statiques (c'est-à-dire les wget
puis les mettre dans le DocumentRoot), soit en en interdisant l'accès.
Vu l'absence de criticité, j'ai choisi la deuxième option. Dans la configuration d'Apache httpd (je n'utilise pas les htaccess) :
<FilesMatch "(page-sitemap|post-sitemap|sitemap-misc).xml">
Require all denied
</FilesMatch>
La charge système tombe en dessous de 1.
Le code HTTP 403, qui signifie accès refusé, n'arrête pas le robot.
Je constaterai plus tard que, de toute façon, le robot s'en fiche d'avoir une réponse, y compris la 2e étape de la poignée de main TCP (SYN+ACK), il continue.
J'aurais pu m'arrêter ici : mon service fonctionnait et mon serveur n'était pas débordé. Mais les journaux se remplissaient.
La première idée qui m'est venue, c'est d'utiliser le module string
du pare-feu Linux pour dégager toutes les requêtes HTTP pour les ressources et l'user-agent impliqués. Je l'ai déjà utilisé par le passé.
Mais nftables, la nouvelle interface de commande de Netfilter (et plus), ne prend pas en charge le module string. Ni u32 d'ailleurs (même si ça n'aurait pas convenu ici à cause des entêtes de taille variable). Hors de question que j'écrive un programme eBPF.
J'ai un fail2ban
(f2b) configuré pour bannir des IPs qui accèdent à certains fichiers (wp-config.php, par exemple) ou qui tentent des injections SQL. J'ai dupliqué l'action nftables pour bannir par /24 en IPv4 et en /64 par IPv6, c'est-à-dire par réseau contenant une IP qui a tenté un truc malveillant.
J'ai ajouté un filtre sur les URL et le User-Agent. J'ai réutilisé mon action nftables modifiée. J'ai créé une jail pour bannir 12 h.
f2b a galéré. Dans son journal, essentiellement des lignes « Found », très peu de « Ban », comme s'il ne tenait pas la cadence. Le nombre de requêtes par seconde ne diminuait pas vraiment. Même après 15-30 minutes.
Rétrospectivement, j'ai commis plusieurs erreurs.
D'une part, puisqu'il était nouveau dans sa configuration, fail2ban a lu l'ensemble du journal Apache httpd. Il contenait plus d'une heure de requêtes (environ 100 k). J'aurais dû stopper httpd, créer un journal vierge, etc.
D'autre part, j'ai rechargé et redémarré f2b à plusieurs reprises (au lieu de tester mon nouveau filtre avec fail2ban-regex
, pour tenter de lui faire lire le journal depuis l'instant T, etc.). Forcément, f2b perd du temps à ré-appliquer les bannissements depuis sa base de données. Il ne faut pas faire ça en plein incident, il faut utiliser le client (fail2ban-client
).
À ma décharge, fail2ban-client
a refusé certaines instructions parfaitement valides, comme unban --all
ou changer la durée de bannissement afin de purger les anciennes IPs (celles de la première heure de l'incident). J'ai fini par stopper f2b et supprimer sa base de données (/var/lib/fail2ban/fail2ban.sqlite3).
Puisque fail2ban ne semblait pas tenir la cadence, je l'ai configuré pour bannir par /8 en IPv4 ou /32 en IPv6.
Évidemment, en moins de 15 minutes, ce fut très efficace.
Évidemment, les dommages collatéraux sont très nombreux puisque pour une adresse IPv4 coupable, j'en bannis 16,7 millions d'autres… C'est les chiffres que j'ai exposés supra : j'ai banni plus de la moitié de l'espace d'adressage IPv4.
Je suis donc passé à /16 en IPv4 et /48 en IPv6. f2b tenait toujours la cadence.
Je voulais limiter le nombre de requêtes par seconde. Je savais le faire avec iptables, mais pas avec nftables.
iptables
fournit les programmes iptables-translate
et ip6tables-translate
:
$ iptables-translate -A INPUT -p tcp --dport 80 --tcp-flags SYN,ACK,FIN,RST SYN -m hashlimit --hashlimit-above 1/sec --hashlimit-burst 2 --hashlimit-mode srcip --hashlimit-name RL-HTTP-v4 --hashlimit-srcmask 8 -j DROP
nft 'add rule ip filter INPUT tcp dport 80 tcp flags syn / fin,syn,rst,ack meter RL-HTTP-v4 { ip saddr and 255.0.0.0 limit rate over 1/second burst 2 packets } counter drop'
$ ip6tables-translate -A INPUT -p tcp --dport 80 --tcp-flags SYN,ACK,FIN,RST SYN -m hashlimit --hashlimit-above 1/sec --hashlimit-burst 2 --hashlimit-mode srcip --hashlimit-name RL-HTTP-v6 --hashlimit-srcmask 32 -j DROP
nft 'add rule ip6 filter INPUT tcp dport 80 tcp flags syn / fin,syn,rst,ack meter RL-HTTP-v6 { ip6 saddr and ffff:ffff:0000:0000:0000:0000:0000:0000 limit rate over 1/second burst 2 packets } counter drop'
Je limite uniquement les paquets TCP SYN (initialisation d'une connexion). 1 nouvelle connexion par seconde par /8 (IPv4) ou /32 (IPv6), pic à 2/s. (J'aurais pu utiliser ct state new
, nouvelle syntaxe de -m state --state NEW
, mais l'analyse des drapeaux me semble moins coûteuse en temps CPU que le suivi des connexions.)
J'utilise une seule table de type inet. Je me demandais si chaque meter allait travailler sur son type d'IP (v4 ou v6) malgré tout. Dans le doute, j'avais ajouté le critère ip version 4
dans la première règle et ip6 version 6
dans la seconde. J'ai testé à froid : oui, meter se débrouille tout seul, pas besoin de filtrer le type d'IP en amont.
Je configure f2b pour bannir par /20 (IPv4) ou /56 (IPv6). L'idée c'est de bannir l'environnement immédiat d'un réseau contenant une IP qui me prend pour cible, car, ces quinze dernières années, les opérateurs ont souvent obtenu des réseaux d'une taille entre entre /16 et /22 au fil de la pénurie d'IPv4. Bannir l'environnement d'un réseau sans bannir tout un opérateur, en somme.
Malgré tout ça, mon Apache httpd journalise encore 8 requêtes/seconde en moyenne. Pollution inutile de mes journaux.
Plus pour tester que par nécessité, j'ai décidé de bloquer toutes les IPs qui ne sont pas attribuées à un opérateur français et qui causent au port 80 (HTTP) de ma machine.
Tout est dans le wiki officiel de nftables.
Seule adaptation : meta mark != $FR counter drop
(note la négation, qui ne se fait pas avec not
, dont la traduction n'est que !
).
Cette technique est redoutablement efficace : aucune requête web ne passe, rien.
Je n'aurais pas mis en œuvre cette technique si j'étais un site web international et/ou marchand, mais, dans mon cas, l'inaccessibilité de mon site web n'a aucune conséquence.
Ironie tout de même : la première IP française qui s'est présentée, ce fût pour chercher une faille de sécurité sur mon blog…
J'ai laissé ainsi pendant 3 h et j'ai fait autre chose de ma vie.
Si IP française, elle passe. Sinon, limitation de trafic (1 requête/s, pic à 10/s par /8 ou /32). Dans les deux cas, fail2ban veille (bannissement par /20 ou /56).
Apache httpd continue de recevoir 2 requêtes/seconde en moyenne.
Les heures avançant, j'ai été plus coulant sur la limitation de trafic : 10 connexions/minute, pic à 30, etc.
Je n'ai pas cru à la fin de l'incident, je m'attendais à ce que ça recommence le lendemain.
Cela aurait été intelligent car avec une durée de bannissement de 12 h, f2b allait gracier toutes les IPs avant l'aube.
J'ai donc exporté la configuration de nftables, y compris les ensembles d'IP avec nft list ruleset > sauvegarde.nft
. Ainsi, dans l'urgence, je pourrais re-bannir toutes les IPs qui ont participé. On charge ce fichier avec nft -f
.
Ce genre d'incident fait grossir les journaux (d'Apache httpd, de fail2ban, etc.).
Avant que tout cela finisse dans mes sauvegardes et les fasse grossir inutilement, j'ai fait le ménage.
J'ai viré l'incident des journaux access.log
et error.log
d'Apache httpd et de fail2ban.log
.
J'ai forcé SQLite à supprimer sur disque les données supprimées dans la base de données interne de f2b (VACUUM
).
nft -t list ruleset
: « -t » permet de ne pas afficher le contenu des ensembles (set) et des tableaux associatifs (map).
nft -f <fichier>
: charger un jeu de règles. Se souvenir que, désormais, il peut y avoir plusieurs tables et chaînes d'un même type (filter, nat, etc.). Toutes ont une priorité qui indique leur ordre dans le traitement des paquets. Un verdict « drop » est définitif, les paquets rejetés ne passeront pas dans une éventuelle chaîne de même type moins prioritaire. Un verdict « accept » n'est définitif que lorsqu'il se trouve dans la dernière chaîne, sinon les paquets passeront dans les chaînes de même type moins prioritaires. Source.
Compter les éléments d'un ensemble nommé : je n'ai pas trouvé. Il faut bricoler : nft list set <type_table> <nom_table> <nom_ensemble> | tail -n +5 | head -n -2 | wc -l
. En IPv4, il y a deux IP / réseaux par ligne. Une en IPv6. Sinon, en passant par JSON : nft -j list set <type_table> <nom_table> <nom_ensemble> | jq ".nftables[1].set.elem | length"
…
nft monitor
: écouter les événements (ajout/suppression d'une table, d'une règle, d'un élément dans un ensemble, etc. et tracer les paquets qui tapent une règle (lui ajouter « meta nftrace set 1 » et utiliser nft monitor trace
).
J'ai enregistré ce nom de domaine en juin 2010. J'ai commencé à publier du contenu en juillet 2010.
Hé bah.